1. 程式人生 > >Spring事務管理下加鎖,為啥還執行緒不安全?

Spring事務管理下加鎖,為啥還執行緒不安全?

前言

在單體架構的秒殺活動中,為了減輕DB層的壓力,這裡我們採用了Lock鎖來實現秒殺使用者排隊搶購。然而很不幸的是儘管使用了鎖,但是測試過程中仍然會超賣,執行了N多次發現依然有問題。

程式碼演示

先上程式碼:

@RestController
@Slf4j
public class SeckillDistributedController {
    @Resource
    private ISeckillDistributedService seckillDistributedService;

    @PostMapping("/startSeckilLock")
    public Result startSeckilLock(long seckillId) {
        final long killId = seckillId;
        log.info("開始秒殺");
        for (int i = 0; i < 10000; i++) {
            final long userId = i;
            Runnable task = () -> { {
                    Result result = seckillDistributedService.startSeckilLock(killId, userId);
                }
            };
            executor.execute(task);
        }
        return Result.ok();
    }
}

@Service
public class SeckillServiceImpl implements ISeckillService {
    private Lock lock = new ReentrantLock(true);//互斥鎖 引數預設false,不公平鎖
    @Resource
    private DynamicQuery dynamicQuery;

    @Override
    @Transactional
    public Result startSeckilLock(long seckillId, long userId) {
         try {
            lock.lock();
            String nativeSql = "SELECT number FROM seckill WHERE seckill_id = ?";
            Object object =  dynamicQuery.nativeQueryObject(nativeSql, new Object[]{seckillId});
            Long number =  ((Number) object).longValue();
            if(number > 0){
                nativeSql = "UPDATE seckill SET number = number - 1 WHERE seckill_id = ?";
                dynamicQuery.nativeExecuteUpdate(nativeSql, new Object[]{seckillId});
                SuccessKilled killed = new SuccessKilled();
                killed.setSeckillId(seckillId);
                killed.setUserId(userId);
                killed.setState(Short.parseShort(number + ""));
                killed.setCreateTime(new Timestamp(new Date().getTime()));
                dynamicQuery.save(killed);
            }else{
                return Result.error();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
        return Result.ok();
    }
}

出現問題

多執行緒運行同一個事務管理下加鎖的方法,方法內操作的是資料庫,按正常邏輯得到最終的值應該是100,但經過多次測試,最終結果出現超賣101,如下圖所示:

這是為什麼呢?

問題分析

追蹤

事物未提交之前,鎖已經釋放(事物提交是在整個方法執行完),導致另一個事物讀取到了這個事物未提交的資料,出現了髒讀。

資料庫層面分析

資料庫預設的事務隔離級別為可重複讀(repeatable-read)[^腳註1],也就不可能出現髒讀[^腳註2],但會出現幻讀[^腳註3]。查詢資料庫可知,如下圖所示:

程式碼層面分析

程式碼寫在service層,bean預設是單例的,也就是說lock肯定是一個物件。

總結

這裡,總結一下為什麼會超賣101:秒殺開始後,某個事物在未提交之前,鎖已經釋放(事物提交是在整個方法執行完),導致下一個事物讀取到了上個事物未提交的資料,出現傳說中的髒讀。

解決方案

此處給出的建議是:鎖上移,上移到Controller層,包住整個事物單元。修改程式碼為:

public Result startSeckilLock(long seckillId) {
        final long killId = seckillId;
        log.info("開始秒殺");
        for (int i = 0; i < 10000; i++) {
            final long userId = i;
            Runnable task = () -> { {
                try{
                    lock.lock();
                    Result result = seckillService.startSeckilLock(killId, userId);
                }finally {
                    lock.unlock();
                }
                }
            };
            executor.execute(task);
        }
        return Result.ok();
    }

修改完成後,重新測試一下程式碼。意料之中,再也沒有出現超賣的現象。

原始碼解析

@Transactional 切片是一種特殊情況

1)多 AOP 之間的執行順序在未指定時是 :undefined ,官方文件並沒有說一定會按照註解的順序進行執行,只會按照@ Order的順序執行。

參考官方文件: 可在頁面裡搜尋 Control+F/Command+F「7.2.4.7 Advice ordering」

2)事務切面的 default Order 被設定為了 Ordered.LOWEST_PRECEDENCE,所以預設情況下是屬於最內層的環切。

解析原始碼可知:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
@Documented
public @interface Order {
    int value() default 2147483647;
}

public interface Ordered {
    int HIGHEST_PRECEDENCE = -2147483648;
    int LOWEST_PRECEDENCE = 2147483647;

    int getOrder();
}

參考官方文件: 可在頁面裡搜尋 Control+F/Command+F「Table 10.2. tx:annotation-driven/ settings」

可重複讀: 每次讀取的都是當前事務的版本,即使被修改了,也只會讀取當前事務版本的資料。 髒讀: 一個事務讀取到另外一個事務未提交的資料 幻讀: 是指在一個事務內讀取到了別的事務插入的資料,導致前