前言
在单体架构的秒杀活动中,为了减轻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」
可重复读: 每次读取的都是当前事务的版本,即使被修改了,也只会读取当前事务版本的数据。
脏读: 一个事务读取到另外一个事务未提交的数据
幻读: 是指在一个事务内读取到了别的事务插入的数据,导致前后读取不一致。