本文只討論如何基于已實現了分布式鎖的第三方框架進行二次封裝,減少分布式鎖的使用成本,而且當需要替換分布式鎖實現時,只需要少量代碼的調整,比如只需修改配置文件即可完成改造。
另外,本文是對另一篇文章中的實現的優化版,但主要思想是一致的。見 使用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
則是根據 lockName
和 lockNamePre
拼接的,拼接符默認為 :
。
很明顯,
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。
- 這里省略了各種配置文件和配置類等。
- 下文的所有截圖,有一處單詞拼寫錯誤,
rest stock
寫成了reset stock
,還請將就著看。
啟動測試用例,可以看到類似如下的控制臺打印:
理論上應該只有8個線程能正常扣減庫存,而結果也與預想的一樣。這時如果去掉注解 @DistributedLock(lockName = "1", lockNamePre = "item")
,會出現什么結果呢?類似如下:
testSpel
很明顯,上一個測試用例中,lockName
寫死為 "1"
是不可取,而是應該取入參 testItem
的 id
的值,接下來,使用 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);
}
}
啟動測試用例,可以看到控制臺類似輸出:
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);
}
}
啟動測試用例,類似控制臺輸出如下:
這里有一點需要注意,方法
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);
}
}
啟動測試用例,結果類似如下:
這樣乍一看,看下沒什么毛病,數據庫的庫存也是正常的,為 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);
}
}
啟動測試用例,結果類似如下:
可以看到,很明顯超賣了,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);
}
}
啟動測試用例,結果類似如下:
可以看到,雖然只有一個線程打印 "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("釋放鎖時, 當前線程不是鎖的持有者");
注釋,然后再跑一遍,結果為:
不出意外的話,這時的庫存為 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>
結語
至此,本文的主要內容已介紹完畢, 由于本文的篇幅過長,貼了太多代碼,所以只演示了簡單使用,當業務比較復雜的時候,上面的使用方法可能沒辦法很好的支持;另外,在使用過程中也有需要注意的地方,不然有可能出現分布式鎖注解不生效的情況;還有部分關鍵代碼的原理以及背后的原因,都不好在這里一一說明,只能放到另外一篇文章做詳細分析。
然后,這篇文章僅作為拋磚引玉,如果有其他更好的方案,歡迎留言,一起討論學習。
謝謝!!!