分布式鎖可以這么簡單?

本文只討論如何基于已實現了分布式鎖的第三方框架進行二次封裝,減少分布式鎖的使用成本,而且當需要替換分布式鎖實現時,只需要少量代碼的調整,比如只需修改配置文件即可完成改造。
另外,本文是對另一篇文章中的實現的優化版,但主要思想是一致的。見 使用Redisson實現分布式鎖,Spring AOP簡化之

源碼

在開始之前,我們先回想一個比較經典的場景——超賣,而解決超賣的一個方案就是 加鎖,真正扣減庫存之前,必須拿到對應的鎖,下面來看一段示例代碼,其中鎖的實現是借助了 Redisson

@Transactional(rollbackFor = Throwable.class)
public void seckill(Long itemId, int purchaseCount) {
    RLock lock = redissonClient.getLock("item:" + itemId);
    boolean locked = false;

    try {
        locked = lock.tryLock(5000, TimeUnit.MILLISECONDS);
        if (locked) {
            doSeckill(itemId, purchaseCount);
        }

        
    } catch (InterruptedException e) {
        throw new RuntimeException("無法在指定時間內獲得鎖");
    } finally {
        // 是否未獲得鎖
        if (!locked) {
            throw new RuntimeException("嘗試獲取鎖超時, 鎖獲取失敗");
        }

        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        } else {
            throw new RuntimeException("鎖釋放失敗, 當前線程不是鎖的持有者");
        }
    }
}

public void doSeckill(Long itemId, int purchaseCount) {
    // 獲取庫存
    // 比較并扣減庫存
    // 更新庫存
    // 異步執行其他邏輯
}

上面的代碼,看著也算還好,不太復雜,但其實除了真正的業務邏輯 doSeckill 外,其他的都是一些可模板化的代碼結構。想象一下如果每一處使用分布式鎖的地方都要寫這堆東西,那還得了,更可怕的是,如果要換一種分布式鎖的實現方式呢?

既然我們能預想到這種寫法有這么嚴重的弊端,那就得想辦法優化。最好能夠實現:只需要關注真正的業務邏輯,需要使用分布式鎖時,只需增加少量代碼即可,比如加個注解;另外,如果需要更換分布式鎖的實現方式,也不需要改任何代碼。怎么實現呢?這就是這篇文章要解決的問題了。通過優化后,上面的示例代碼將可以變得如下這么簡單:

@DistributedLock(lockName = "#{#itemId}", lockNamePre = "item")
public void doSeckill(Long itemId, int purchaseCount) {
    // 獲取庫存
    // 比較并扣減庫存
    // 更新庫存
    // 異步執行其他邏輯
}

下面,正式開始~~~

Redisson概述

可參考另一篇文章的 Redisson概述

使用Redisson實現分布式鎖

1. 定義回調接口

/**
 * 分布鎖回調接口
 */
public interface DistributedLockCallback<T> {

    /**
     * 調用者必須在此方法中實現需要加分布式鎖的業務邏輯
     *
     * @return
     */
    public T process() throws Throwable;

    /**
     * 得到分布式鎖名稱
     *
     * @return
     */
    public String getLockName();

}

定義分布式具體實現的鎖模板接口

/**
 * 分布式鎖具體實現的模板接口
 *
 * @author sprainkle
 * @date 2019.04.20
 */
public interface DistributedLockTemplate {

    /** 嘗試獲取鎖的默認等待時間 */
    long DEFAULT_WAIT_TIME = DistributedLock.DEFAULT_WAIT_TIME;
    /** 鎖的默認超時時間. 超時后, 鎖會被自動釋放 */
    long DEFAULT_TIMEOUT = DistributedLock.DEFAULT_WAIT_TIME;
    /** 時間單位。默認為毫秒。 */
    TimeUnit DEFAULT_TIME_UNIT = DistributedLock.DEFAULT_TIME_UNIT;
    /** 獲得鎖名時拼接前后綴用到的分隔符 */
    String DEFAULT_SEPARATOR = DistributedLock.DEFAULT_SEPARATOR;
    /** lockName后綴 */
    String LOCK = DistributedLock.LOCK;

    /**
     * 使用分布式鎖,使用鎖默認超時時間。
     *
     * @param callback
     * @param fairLock 是否使用公平鎖
     * @return
     */
    <T> T lock(DistributedLockCallback<T> callback, boolean fairLock);

    /**
     * 使用分布式鎖。自定義鎖的超時時間
     *
     * @param callback
     * @param leaseTime 鎖超時時間。超時后自動釋放鎖。
     * @param timeUnit
     * @param fairLock  是否使用公平鎖
     * @return
     */
    <T> T lock(DistributedLockCallback<T> callback, long leaseTime, TimeUnit timeUnit, boolean fairLock);

    /**
     * 嘗試分布式鎖,使用鎖默認等待時間、超時時間。
     *
     * @param callback
     * @param <T>
     * @param fairLock 是否使用公平鎖
     * @return
     */
    <T> T tryLock(DistributedLockCallback<T> callback, boolean fairLock);

    /**
     * 嘗試分布式鎖,自定義等待時間、超時時間。
     *
     * @param callback
     * @param waitTime  獲取鎖最長等待時間
     * @param leaseTime 鎖超時時間。超時后自動釋放鎖。
     * @param timeUnit
     * @param <T>
     * @param fairLock  是否使用公平鎖
     * @return
     */
    <T> T tryLock(DistributedLockCallback<T> callback, long waitTime, long leaseTime, TimeUnit timeUnit, boolean fairLock);

    /**
     * 鎖是否由當前線程持有
     *
     * @param lock
     * @return
     */
    boolean isHeldByCurrentThread(Object lock);

}

基于 Redisson 實現

/**
 * Base DistributedLockTemplate. 用于封裝一些公共方法
 */
public abstract class AbstractDistributedLockTemplate implements DistributedLockTemplate {
    /**
     * 處理業務邏輯
     *
     * @param callback
     * @param <T>
     * @return 業務邏輯處理結果
     */
    protected <T> T process(DistributedLockCallback<T> callback) {
        try {
            return callback.process();
        } catch (Throwable e) {
            if (e instanceof BaseException) {
                throw (BaseException) e;
            }

            throw new BaseException(CommonResponseEnum.SERVER_ERROR, null, e.getMessage(), e);
        }
    }
}
@Slf4j
public class RedisDistributedLockTemplate extends AbstractDistributedLockTemplate {

    private final RedissonClient redisson;
    private final String namespace;

    private final long lockTimeoutMs;
    private final long waitTimeoutMs;

    // 鎖前綴
    private final String lockPrefix;
    // 鎖后綴
    private final String lockPostfix;

    public RedisDistributedLockTemplate(RedissonClient redisson, DistributedLockProperties properties) {
        this.redisson = redisson;
        this.namespace = properties.getNamespace();
        this.lockTimeoutMs = Optional.ofNullable(properties.getLockTimeoutMs()).orElse(DEFAULT_TIMEOUT);
        this.waitTimeoutMs = Optional.ofNullable(properties.getWaitTimeoutMs()).orElse(DEFAULT_WAIT_TIME);

        this.lockPrefix = namespace + DEFAULT_SEPARATOR;
        this.lockPostfix = ".lock";
    }

    @Override
    public <T> T lock(DistributedLockCallback<T> callback, boolean fairLock) {
        return lock(callback, lockTimeoutMs, DEFAULT_TIME_UNIT, fairLock);
    }

    @Override
    public <T> T lock(DistributedLockCallback<T> callback, long leaseTime, TimeUnit timeUnit, boolean fairLock) {
        RLock lock = getLock(callback.getLockName(), fairLock);

        try {
            lock.lock(leaseTime, timeUnit);
            return process(callback);
        } finally {
            if (lock != null && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    @Override
    public <T> T tryLock(DistributedLockCallback<T> callback, boolean fairLock) {
        return tryLock(callback, waitTimeoutMs, lockTimeoutMs, DEFAULT_TIME_UNIT, fairLock);
    }

    @Override
    public <T> T tryLock(DistributedLockCallback<T> callback,
                         long waitTime,
                         long leaseTime,
                         TimeUnit timeUnit,
                         boolean fairLock) {
        RLock lock = getLock(callback.getLockName(), fairLock);
        boolean locked = false;

        DistributedLockContext context = DistributedLockContextHolder.getContext();
        context.setLock(lock);

        try {
            locked = lock.tryLock(waitTime, leaseTime, timeUnit);
            if (locked) {
                return process(callback);
            }
        } catch (InterruptedException e) {
            ResponseEnum.LOCK_NOT_YET_HOLD.assertFailWithMsg("無法在指定時間內獲得鎖", e);
        } finally {
            // 是否未獲得鎖
            if (!locked) {
                ResponseEnum.LOCK_NOT_YET_HOLD.assertFailWithMsg("嘗試獲取鎖超時, 獲取失敗.");
            }

            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            } else {
                log.warn("鎖釋放失敗, 當前線程不是鎖的持有者");
            }
        }
        return null;
    }

    public RLock getLock(String lockName, boolean fairLock) {
        RLock lock;
        lockName = lockPrefix + lockName + lockPostfix;
        if (fairLock) {
            lock = redisson.getFairLock(lockName);
        } else {
            lock = redisson.getLock(lockName);
        }
        return lock;
    }

    @Override
    public boolean isHeldByCurrentThread(Object lock) {
        if (!(lock instanceof RLock)) {
            return false;
        }

        return ((RLock) lock).isHeldByCurrentThread();
    }
}

簡單使用 RedisDistributedLockTemplate

DistributedLockTemplate lockTemplate = ...;
final String lockName = ...; 
lockTemplate.lock(new DistributedLockCallback<Object>() {
    @Override
    public Object process() {
        //do some business
        return null;
    }

    @Override
    public String getLockName() {
        return lockName;
    }
}, false);

會不會還是很麻煩?

雖說比使用原生 Redisson 時簡單一點點,但是每次使用分布式鎖都要寫類似上面的重復代碼,還是不夠優雅。有沒有什么方法可以只關注核心業務邏輯代碼的編寫,即上面的"do some business"。下面介紹如何使用Spring AOP來實現這一目標。

使用 Spring AOP 進一步封裝

定義注解 DistributedLock

/**
 * <pre>
 *     可以使用該注解實現分布式鎖。
 *
 *     獲取lockName的優先級為:lockName > argNum > param
 *
 *     使用的是公平鎖, 即先來先得.
 * </pre>
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DistributedLock {
    /**
     * 鎖的名稱。
     * 如果lockName可以確定,直接設置該屬性。
     * <br><br>
     * 支持 SpEL, 格式為: #{expression}, 內置 #root, 屬性包括: target, method, args 等, 其中 target 為注解所在類的 Spring Bean
     * 也支持 占位符 ${}
     */
    String lockName() default "";

    /**
     * lockName 前綴
     */
    String lockNamePre() default "";

    /**
     * lockName 后綴
     * @see #LOCK
     */
    String lockNamePost() default "";

    /**
     * 在開始加鎖前, 執行某個方法進行校驗.
     * <br><br>
     * 支持 SpEL, 格式為: #{expression}, 所以方法必須為 public, 如果方法的所在 Spring Bean 與注解的方法相同,
     * 寫法為: #{#root.target.yourMethod(#param1, #param2, ...)}
     *
     * @return
     */
    String checkBefore() default "";

    /**
     * 獲得鎖名時拼接前后綴用到的分隔符
     * @see #DEFAULT_SEPARATOR
     */
    String separator() default DEFAULT_SEPARATOR;

    /**
     * <pre>
     *     獲取注解的方法參數列表的某個參數對象的某個屬性值來作為lockName。因為有時候lockName是不固定的。
     *     當param不為空時,可以通過argSeq參數來設置具體是參數列表的第幾個參數,不設置則默認取第一個。
     * </pre>
     */
    String param() default "";

    /**
     * 將方法第argSeq個參數作為鎖名. 0為無效值.
     */
    int argSeq() default 0;

    /**
     * 是否使用公平鎖。
     * 公平鎖即先來先得。
     */
    boolean fairLock() default false;

    /**
     * 是否使用嘗試鎖。
     */
    boolean tryLock() default true;

    /**
     * 最長等待時間。
     * 該字段只有當tryLock()返回true才有效。
     * @see #DEFAULT_WAIT_TIME
     */
    long waitTime() default DEFAULT_WAIT_TIME;

    /**
     * <pre>
     *     鎖超時時間。
     *     超時時間過后,鎖自動釋放。
     *     建議:
     *       盡量縮簡需要加鎖的邏輯。
     * </pre>
     * @see #DEFAULT_TIMEOUT
     */
    long leaseTime() default DEFAULT_TIMEOUT;

    /**
     * 時間單位。默認為毫秒。
     */
    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

    /**
     * 嘗試獲取鎖的默認等待時間
     */
    long DEFAULT_WAIT_TIME = 10000L;
    /**
     * 鎖的默認超時時間. 超時后, 鎖會被自動釋放
     */
    long DEFAULT_TIMEOUT = 5000L;
    /**
     * 時間單位。默認為毫秒。
     */
    TimeUnit DEFAULT_TIME_UNIT = TimeUnit.MILLISECONDS;
    /**
     * 獲得鎖名時拼接前后綴用到的分隔符
     */
    String DEFAULT_SEPARATOR = ":";
    /**
     * lock
     */
    String LOCK = "lock";
}

定義切面織入的代碼

/**
 * 分布式鎖切面邏輯
 */
@Slf4j
@Aspect
public class DistributedLockAspect implements ApplicationContextAware, BeanFactoryAware, Ordered {

    /**
     * 解析模板
     */
    private static final ParserContext PARSER_CONTEXT = ParserContext.TEMPLATE_EXPRESSION;
    /**
     * SpEL 解析器
     */
    private static final SpelExpressionParser spElParser = new SpelExpressionParser();

    private ApplicationContext applicationContext;

    private BeanFactory beanFactory;
    /**
     * 用于解析 @BeanName 為對應的 Spring Bean
     */
    private BeanResolver beanResolver;

    private final DistributedLockTemplate lockTemplate;

    public DistributedLockAspect(DistributedLockTemplate lockTemplate) {
        this.lockTemplate = lockTemplate;
    }


    @Around(value = "@annotation(distributedLock)")
    public Object doAround(ProceedingJoinPoint pjp, DistributedLock distributedLock) {
        EvaluationContext evaluationCtx = getEvaluationContext(pjp);

        doCheckBefore(distributedLock, evaluationCtx);

        String lockName = getLockName(pjp, evaluationCtx);

        return lock(pjp, lockName);
    }

    /**
     * 執行 {@link DistributedLock#checkBefore()} 指定的方法
     *
     * @param distributedLock
     * @param evaluationCtx
     */
    private void doCheckBefore(DistributedLock distributedLock, EvaluationContext evaluationCtx) {
        String checkBefore = distributedLock.checkBefore();
        resolveExpression(evaluationCtx, checkBefore);
    }

    /**
     * 獲取鎖名
     *
     * @param jp
     * @return
     */
    private String getLockName(JoinPoint jp, EvaluationContext evaluationCtx) {
        DistributedLock annotation = getAnnotation(jp);
        String lockName = annotation.lockName();

        if (StrUtil.isNotBlank(lockName)) {
            lockName = resolveExpression(evaluationCtx, lockName);
        } else {
            Object[] args = jp.getArgs();
            if (args.length > 0) {
                String param = annotation.param();
                if (StrUtil.isNotBlank(param)) {
                    Object arg;
                    if (annotation.argSeq() > 0) {
                        arg = args[annotation.argSeq() - 1];
                    } else {
                        arg = args[0];
                    }
                    lockName = String.valueOf(getParam(arg, param));
                } else if (annotation.argSeq() > 0) {
                    lockName = String.valueOf(args[annotation.argSeq() - 1]);
                }
            }
        }

        if (StrUtil.isBlank(lockName)) {
            CommonResponseEnum.SERVER_ERROR.assertFailWithMsg("無法生成分布式鎖鎖名. annotation: {0}", annotation);
        }

        String preLockName = annotation.lockNamePre();
        String postLockName = annotation.lockNamePost();
        String separator = annotation.separator();

        if (StrUtil.isNotBlank(preLockName)) {
            lockName = preLockName + separator + lockName;
        }
        if (StrUtil.isNotBlank(postLockName)) {
            lockName = lockName + separator + postLockName;
        }

        return lockName;
    }

    /**
     * 從方法參數獲取數據
     *
     * @param param
     * @param arg 方法的參數數組
     * @return
     */
    private Object getParam(Object arg, String param) {

        if (StrUtil.isNotBlank(param) && arg != null) {
            try {
                return BeanUtil.getFieldValue(arg, param);
            } catch (Exception e) {
                CommonResponseEnum.SERVER_ERROR.assertFailWithMsg("[{0}] 沒有屬性 [{1}]", arg.getClass(), param);
            }
        }
        return "";
    }

    /**
     * 獲取鎖并執行
     *
     * @param pjp
     * @param lockName
     * @return
     */
    private Object lock(ProceedingJoinPoint pjp, final String lockName) {
        DistributedLock annotation = PointCutUtils.getAnnotation(pjp, DistributedLock.class);

        boolean fairLock = annotation.fairLock();
        boolean tryLock = annotation.tryLock();

        if (tryLock) {
            return tryLock(pjp, annotation, lockName, fairLock);
        } else {
            return lock(pjp,lockName, fairLock);
        }
    }

    /**
     *
     * @param pjp
     * @param lockName
     * @param fairLock
     * @return
     */
    private Object lock(ProceedingJoinPoint pjp, final String lockName, boolean fairLock) {
        return lockTemplate.lock(new DistributedLockCallback<Object>() {
            @Override
            public Object process() throws Throwable {
                return pjp.proceed();
            }

            @Override
            public String getLockName() {
                return lockName;
            }
        }, fairLock);
    }

    /**
     *
     * @param pjp
     * @param annotation
     * @param lockName
     * @param fairLock
     * @return
     */
    private Object tryLock(ProceedingJoinPoint pjp, DistributedLock annotation, final String lockName, boolean fairLock) {

        long waitTime = annotation.waitTime(), leaseTime = annotation.leaseTime();
        TimeUnit timeUnit = annotation.timeUnit();

        return lockTemplate.tryLock(new DistributedLockCallback<Object>() {
            @Override
            public Object process() throws Throwable {
                return pjp.proceed();
            }

            @Override
            public String getLockName() {
                return lockName;
            }
        }, waitTime, leaseTime, timeUnit, fairLock);
    }

    // 省略若干
}

這里由于篇幅過長,只貼了主要代碼,就不貼其他相關類的代碼,如果有需要的可以去 Github 自行獲取。

如何使用

如果是在本地測試,什么都不用配置,因為使用了 springboot-starter 的規范封裝的,只需像其他 springboot-starter 一樣,引入對應的依賴即可,類似如下。

<dependency>
    <groupId>com.sprainkle</groupId>
    <artifactId>spring-cloud-advance-common-lock</artifactId>
    <version>${spring-cloud-advance.version}</version>
</dependency>

然后,就可以像如下代碼一樣,直接在業務代碼前加上分布式鎖注解即可使用:

@DistributedLock(lockName = "#{#itemId}", lockNamePre = "item")
public void doSeckill(Long itemId, int purchaseCount) {
    // 獲取庫存
    // 比較并扣減庫存
    // 更新庫存
    // 異步執行其他邏輯
}

注解 DistributedLock 個參數的使用,可參考各參數的說明。

開始使用

因為這里直接在模塊中編寫測試用例,所以不用引入依賴。可參考 源碼

定義實體類 TestItem

@Data
@TableName("test_item")
public class TestItem {

    @TableId
    private Integer id;
    private String name;
    private Integer stock;

    public TestItem() {
    }

    public TestItem(Integer id, String name) {
        this.id = id;
        this.name = name;
    }
}

服務實現類 TestItemService

@Slf4j
@Service
public class TestItemService extends ServiceImpl<TestItemMapper, TestItem> {
    // 具體邏輯見下文
}

本次測試 ORM 框架使用了 Mybatis Plus,其他類,如 TestItemMapper 等,由于篇幅過長,這里就不展示了。

測試用例

定義 Worker 類
public static class Worker implements Runnable {

        private final CountDownLatch startSignal;
        private final CountDownLatch doneSignal;
        private final Action action;

        public Worker(CountDownLatch startSignal, CountDownLatch doneSignal, Action action) {
            this.startSignal = startSignal;
            this.doneSignal = doneSignal;
            this.action = action;
        }

        @Override
        public void run() {
            try {
                System.out.println(Thread.currentThread().getName() + " start");
                // 阻塞, 直到接收到啟動信號. 保證所有線程的起跑線是一樣的, 即都是同時啟動
                startSignal.await();
                // 具體邏輯
                action.execute();
                // 發送 已完成 信號
                doneSignal.countDown();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

該類是下文所有測試用例會用到的類。

testPlainLockName

首先我們定義 TestItemService 類,其中 initStock 為初始化庫存,之后每個測試用例的第一步邏輯都是調用該方法,初始化/重置庫存

testPlainLockName 才是該測試用例的主要邏輯,邏輯也很簡單,就不廢話了,重點看分布式鎖注解。 可以看到要開啟分布式鎖,只需在方法簽名加上 @DistributedLock(lockName = "item:1", lockNamePre = "item") 即可,非常方便。

那這么寫是什么意思呢?其實就是,有很多線程在扣減商品(id=1)的庫存前,需要拿到一把鎖,鎖名為 distributed-lock-test:item:1.lock,其中 distributed-lock-test:.lock 是框架自己補進去的,剩下的 item:1 則是根據 lockNamelockNamePre 拼接的,拼接符默認為 :

很明顯,lockName 寫死為 "1" 肯定不合適,這里只是演示需要,具體優化請繼續往下,下文會給出。

@Slf4j
@Service
public class TestItemService extends ServiceImpl<TestItemMapper, TestItem> {

    private static final AtomicInteger i = new AtomicInteger(10);

    @Transactional(rollbackFor = Throwable.class)
    public TestItem initStock(Long id, Integer stock) {
        TestItem item = this.getById(id);

        if (item == null) {
            item = new TestItem(1, "牛奶");
        }

        item.setStock(stock);
        this.saveOrUpdate(item);

        return this.getById(id);
    }

    /**
     * 鎖名為固定的字符串
     */
    @DistributedLock(lockName = "1", lockNamePre = "item")
    public Integer testPlainLockName(TestItem testItem) {
        TestItem item = this.getById(testItem.getId());
        Integer stock = item.getStock();

        if (stock > 0) {
            stock = stock - 1;
            item.setStock(stock);
            this.saveOrUpdate(item);
        } else {
            stock = -1;
        }

        return stock;
    }
}

接下來,定義測試用例類。

@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest(classes = DistributedLockTestApplication.class)
public class DistributedLockTests {
    // 10 個線程一起跑
    private static int count = 10;

    @Autowired
    private TestItemService testItemService;

    @Test
    public void testPlainLockName() {
        Consumer<TestItem> consumer = testItem -> {
            Integer stock = testItemService.testPlainLockName(testItem);
            if (stock >= 0) {
                System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
            } else {
                System.out.println(Thread.currentThread().getName() + ": sold out.");
            }
        };

        commonTest(consumer);
    }

    private void commonTest(Consumer<TestItem> consumer)  {
        try {
            CountDownLatch startSignal = new CountDownLatch(1);
            CountDownLatch doneSignal = new CountDownLatch(count);

            TestItem item = testItemService.initStock(1L, 8);

            for (int i = 0; i < count; ++i) {
                Action action = () -> consumer.accept(item);
                new Thread(new Worker(startSignal, doneSignal, action)).start();
            }
            
            // let all threads proceed
            startSignal.countDown(); 
            doneSignal.await();
            System.out.println("All processors done. Shutdown connection");
        } catch (Exception e) {
            log.error("", e);
        }
    }
}

其中,每次啟動的線程數為 10,然后,commonTest(Consumer<TestItem> consumer) 為這里及之后所有測試用例的公共代碼,所以抽象出來了,而 Consumer<TestItem> consumer 才是測試用例間不同的邏輯,并且每次都會把庫存初始化為 8

  1. 這里省略了各種配置文件和配置類等。
  2. 下文的所有截圖,有一處單詞拼寫錯誤,rest stock 寫成了 reset stock,還請將就著看。

啟動測試用例,可以看到類似如下的控制臺打印:


testPlainLockName

理論上應該只有8個線程能正常扣減庫存,而結果也與預想的一樣。這時如果去掉注解 @DistributedLock(lockName = "1", lockNamePre = "item"),會出現什么結果呢?類似如下:

without DistributedLock annotation
testSpel

很明顯,上一個測試用例中,lockName 寫死為 "1" 是不可取,而是應該取入參 testItemid 的值,接下來,使用 SpEL 來實現該需求。

public class TestItemService {
    @DistributedLock(
            lockName = "#{#testItem.id}",
            lockNamePre = "item"
    )
    public Integer testSpel(TestItem testItem) {
        TestItem item = this.getById(testItem.getId());
        Integer stock = item.getStock();

        if (stock > 0) {
            stock = stock - 1;
            item.setStock(stock);
            this.saveOrUpdate(item);
        } else {
            stock = -1;
        }

        return stock;
    }
}

可以看到表達式 #{#testItem.id} 可能與大家以前使用的不太一樣,這是因為在解析 SpEL 表達式時,使用了 解析模板 #{},即表達式必須使用 #{} 包裹起來。其中對 SpEL 的支持,因為不是本文的重點,可以參考: https://cloud.tencent.com/developer/article/1497676

public class DistributedLockTests {
    @Test
    public void testSpel() {
        Consumer<TestItem> consumer = testItem -> {
            Integer stock = testItemService.testSpel(testItem);
            if (stock >= 0) {
                System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
            } else {
                System.out.println(Thread.currentThread().getName() + ": sold out.");
            }
        };

        commonTest(consumer);
    }
}

啟動測試用例,可以看到控制臺類似輸出:


testSpel
testCheckBefore
public class TestItemService
    @DistributedLock(
            lockName = "#{#testItem.id}",
            lockNamePre = "item",
            checkBefore = "#{#root.target.check(#testItem)}"
    )
    public Integer testCheckBefore(TestItem testItem) {
        TestItem item = this.getById(testItem.getId());
        Integer stock = item.getStock();

        if (stock > 0) {
            stock = stock - 1;
            item.setStock(stock);
            this.saveOrUpdate(item);
        } else {
            stock = -1;
        }

        return stock;
    }

    public void check(TestItem testItem) {
        int randomInt = RandomUtil.randomInt(100, 10000);
        if (randomInt % 3 == 0) {
            System.out.println(String.format("current thread: %s, randomInt: %d", getCurrentThreadName(), randomInt));
            CommonResponseEnum.SERVER_BUSY.assertFail();
        }
    }
}

其中,參數 checkBefore 用于在開始加鎖前, 執行某個方法進行校驗,比如這里,使用方法 check 模擬了流量控制,符合一定條件時,直接拋異常返回。

public class DistributedLockTests {
    @Test
    public void testCheckBefore() {
        Consumer<TestItem> consumer = testItem -> {

            Integer stock = -1;

            try {
                stock = testItemService.testCheckBefore(testItem);
            } catch (Exception e) {
                System.out.println(Thread.currentThread().getName() + ": 系統繁忙");
                return;
            }

            if (stock >= 0) {
                System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
            } else {
                System.out.println(Thread.currentThread().getName() + ": sold out.");
            }
        };

        commonTest(consumer);
    }
}

啟動測試用例,類似控制臺輸出如下:


testCheckBefore

這里有一點需要注意,方法 check 必須為 public

testTryLock、testFairLock、testWaitTime

這幾個測試用例比較簡單,這里就不展示了,有興趣的可以自己跑一下。

testLeaseTime

首先來理解一下參數 leaseTime 的作用,即:鎖超時時間,超時時間過后,鎖自動釋放

挺好理解的,但重點是,鎖被自動釋放后,之前執行的邏輯需要怎么處理。
在最后釋放鎖的時候,發現鎖已經不是當前線程持有,有可能已經被其他持有,那之前獲得鎖后執行的邏輯,都變得不可信了,所以理論上需要撤銷,如果是數據庫操作,那就是回滾。

首先來看 testLeaseTime 的第一個測試用例

public class TestItemService
    @DistributedLock(
            lockName = "#{#testItem.id}",
            lockNamePre = "item",
            leaseTime = 2000
    )
    public Integer testLeaseTime(TestItem testItem) {
        int ci = TestItemService.i.getAndDecrement();
        if (ci == 10) {
            sleep(5000L);
            log.info("模擬阻塞完成");
        } else {
            sleep(300L);
        }

        TestItem item = this.getById(testItem.getId());
        Integer stock = item.getStock();

        if (stock > 0) {
            stock = stock - 1;
            item.setStock(stock);
            this.saveOrUpdate(item);
        } else {
            stock = -1;
        }

        return stock;
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
public class DistributedLockTests {
    @Test
    public void testLeaseTime() {
        Consumer<TestItem> consumer = testItem -> {

            Integer stock = testItemService.testLeaseTime(testItem);

            if (stock >= 0) {
                System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
            } else {
                System.out.println(Thread.currentThread().getName() + ": sold out.");
            }
        };

        commonTest(consumer);
    }
}

啟動測試用例,結果類似如下:


testLeaseTime

這樣乍一看,看下沒什么毛病,數據庫的庫存也是正常的,為 0。但再細看一下代碼,模擬阻塞是在一開始就進行的,那如果把該部分代碼挪到從數據庫獲取到數據之后,會發生什么呢?

來看第二個測試用例:

public class TestItemService
    /**
     * 超賣
     *
     * @param testItem
     * @return
     */
    @DistributedLock(
            lockName = "#{#testItem.id}",
            lockNamePre = "item",
            leaseTime = 2000
    )
    public Integer testLeaseTimeOversold(TestItem testItem) {
        TestItem item = this.getById(testItem.getId());

        int ci = TestItemService.i.getAndDecrement();
        if (ci == 10) {
            sleep(5000L);
            log.info("模擬阻塞完成");
        } else {
            sleep(300L);
        }

        Integer stock = item.getStock();

        if (stock > 0) {
            stock = stock - 1;
            item.setStock(stock);
            this.saveOrUpdate(item);
        } else {
            stock = -1;
        }

        return stock;
    }
}
public class DistributedLockTests {
    @Test
    public void testLeaseTimeOversold() {
        Consumer<TestItem> consumer = testItem -> {

            Integer stock = testItemService.testLeaseTimeOversold(testItem);

            if (stock >= 0) {
                System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
            } else {
                System.out.println(Thread.currentThread().getName() + ": sold out.");
            }
        };

        commonTest(consumer);
    }
}

啟動測試用例,結果類似如下:


testLeaseTimeOversold

可以看到,很明顯超賣了,sold out 之后,居然還能繼續扣減庫存,這時候數據庫的庫存應該也是 7,為什么呢?
因為它扣減庫存時,鎖已經被釋放了,未持有鎖的情況下,扣減的庫存大概率都是有問題的,只要并發足夠大。

testLeaseTimeWithTransactional
public class TestItemService
    @DistributedLock(
            lockName = "#{#testItem.id}",
            lockNamePre = "item",
            leaseTime = 2000
    )
    @Transactional(rollbackFor = Throwable.class)
    public Integer testLeaseTimeWithTransactional(TestItem testItem) {
        TestItem item = this.getById(testItem.getId());
        Integer stock = item.getStock();

        int ci = TestItemService.i.getAndDecrement();
        if (ci == 10) {
            sleep(5000L);
            log.info("模擬阻塞完成");
        } else {
            sleep(300L);
        }

        if (stock > 0) {
            stock = stock - 1;
            item.setStock(stock);
            this.saveOrUpdate(item);
        } else {
            stock = -1;
        }

        return stock;
    }
}
public class DistributedLockTests {
    @Test
    public void testLeaseTimeWithTransactional() {
        Consumer<TestItem> consumer = testItem -> {

            Integer stock = testItemService.testLeaseTimeWithTransactional(testItem);

            if (stock >= 0) {
                System.out.println(Thread.currentThread().getName() + ": rest stock = " + stock);
            } else {
                System.out.println(Thread.currentThread().getName() + ": sold out.");
            }
        };

        commonTest(consumer);
    }
}

啟動測試用例,結果類似如下:


testLeaseTimeWithTransactional

可以看到,雖然只有一個線程打印 "sold out", 但 "Thread-11" 也扣減失敗了,所以超賣并沒有出現。這時的庫存為 0
上圖中,輸出的東西比其他其他測試用例多了異常的堆棧日志,在哪里拋出來的呢?如下:

2021-08-16 16:23:32.332  INFO 96595 --- [      Thread-11] c.s.s.c.a.c.l.service.TestItemService    : 模擬阻塞完成
2021-08-16 16:23:32.349  WARN 96595 --- [      Thread-11] c.a.c.l.a.i.RedisDistributedLockTemplate : 鎖釋放失敗, 當前線程不是鎖的持有者
com.sprainkle.spring.cloud.advance.common.core.exception.BusinessException: 系統繁忙,請稍后重試
    at com.sprainkle.spring.cloud.advance.common.core.exception.assertion.BusinessExceptionAssert.newException(BusinessExceptionAssert.java:35)
    at com.sprainkle.spring.cloud.advance.common.core.exception.assertion.Assert.newExceptionWithMsg(Assert.java:52)
    at com.sprainkle.spring.cloud.advance.common.core.exception.assertion.Assert.assertFailWithMsg(Assert.java:789)
    at com.sprainkle.spring.cloud.advance.common.lock.api.UnlockFailureProcessor.beforeCommit(UnlockFailureProcessor.java:28)
    at org.springframework.transaction.support.TransactionSynchronizationUtils.triggerBeforeCommit(TransactionSynchronizationUtils.java:96)
    // ... 省略若干
    at com.sprainkle.spring.cloud.advance.common.lock.DistributedLockTests.lambda$commonTest$8(DistributedLockTests.java:185)
    at com.sprainkle.spring.cloud.advance.common.lock.DistributedLockTests$Worker.run(DistributedLockTests.java:217)
    at java.lang.Thread.run(Thread.java:748)
Caused by: com.sprainkle.spring.cloud.advance.common.core.exception.WrapMessageException: 釋放鎖時, 當前線程不是鎖的持有者
    at com.sprainkle.spring.cloud.advance.common.core.exception.assertion.Assert.newExceptionWithMsg(Assert.java:51)
    ... 33 more

這里可以拋出一個問題,為什么加上 @Transactional(rollbackFor = Throwable.class) 注解后,就不會出現超賣呢?僅僅是因為 @Transactional 注解,還是還有其他原因?

與該項目相關的第一行日志為:at *.lock.api.UnlockFailureProcessor.beforeCommit(UnlockFailureProcessor.java:28),且最后的 Caused by釋放鎖時, 當前線程不是鎖的持有者,大概可以猜出:數據庫的交互在最后的 commit 前,判斷了當前線程是否為鎖的持有者,如果不是,則拋異常讓數據回滾。

這里可以簡單給出 UnlockFailureProcessor 的源碼:

/**
 * 鎖釋放失敗時的處理器. 如果當前線程不是鎖的持有者, 直接拋異常讓數據回滾
 */
public class UnlockFailureProcessor implements TransactionSynchronization {

    private final Object lock;

    private final DistributedLockTemplate distributedLockTemplate;

    public UnlockFailureProcessor(DistributedLockTemplate distributedLockTemplate, Object lock) {
        this.distributedLockTemplate = distributedLockTemplate;
        this.lock = lock;
    }

    @Override
    public void beforeCommit(boolean readOnly) {
        boolean heldByCurrentThread = distributedLockTemplate.isHeldByCurrentThread(lock);

        if (!heldByCurrentThread) {
            ResponseEnum.LOCK_NO_MORE_HOLD.assertFailWithMsg("釋放鎖時, 當前線程不是鎖的持有者");
        }
    }

}

因此,可以得出結論,其實真正起作用的并不僅僅是因為加了 @Transactional 注解,還需要有相應的其他支持,@Transactional 注解只是讓其擁有管理事務的環境,方便數據回滾。

如果有興趣,可以將 ResponseEnum.LOCK_NO_MORE_HOLD.assertFailWithMsg("釋放鎖時, 當前線程不是鎖的持有者"); 注釋,然后再跑一遍,結果為:

testLeaseTimeWithoutRollback

不出意外的話,這時的庫存為 7

基于 ZooKeeper 實現

基于 ZooKeeper 的分布式鎖實現,在源碼中已給出,請參考實現類 ZooDistributedLockTemplate。使用的時候,只需將配置調整為:

sca-common:
  distributed:
    lock:
      impl: zoo
      zoo:
        # zookeeper服務器地址. 多個時用','分開
        connectString: "127.0.0.1:2181"
        # zookeeper的session過期時間. 即鎖的過期時間. 可用于全局配置鎖的過期時間
        sessionTimeoutMs: 10000
        # zookeeper的連接超時時間
        connectionTimeoutMs: 15000

當然也是需要引入相關依賴:

        <dependency>
            <groupId>org.apache.curator</groupId>
            <artifactId>curator-recipes</artifactId>
        </dependency>

結語

至此,本文的主要內容已介紹完畢, 由于本文的篇幅過長,貼了太多代碼,所以只演示了簡單使用,當業務比較復雜的時候,上面的使用方法可能沒辦法很好的支持;另外,在使用過程中也有需要注意的地方,不然有可能出現分布式鎖注解不生效的情況;還有部分關鍵代碼的原理以及背后的原因,都不好在這里一一說明,只能放到另外一篇文章做詳細分析。

然后,這篇文章僅作為拋磚引玉,如果有其他更好的方案,歡迎留言,一起討論學習。
謝謝!!!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容