在并发编程与高并发业务场景(如秒杀)中,开发者常通过结合@Transactional事务注解与显式锁(如Lock)来保证数据一致性。然而,一个常见的误区在于锁与事务的配合时机,处理不当极易导致超卖问题。
1. 典型错误示例与问题分析
以下是一个典型的错误实现。在Service层方法上添加事务注解,并在方法内部使用Lock加锁:
控制层 (Controller):
@ApiOperation(value="秒杀实现方式——Lock加锁")
@PostMapping("/start/lock")
public Result startLock(long skgId){
try {
log.info("开始秒杀方式一...");
final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000;
Result result = secondKillService.startSecondKillByLock(skgId, userId);
if(result != null){
log.info("用户:{}--{}", userId, result.get("msg"));
}else{
log.info("用户:{}--{}", userId, "哎呦喂,人也太多了,请稍后!");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
}
return Result.ok();
}
业务层 (Service):
@Override
@Transactional(rollbackFor = Exception.class)
public Result startSecondKillByLock(long skgId, long userId) {
lock.lock();
try {
// 校验库存
SecondKill secondKill = secondKillMapper.selectById(skgId);
Integer number = secondKill.getNumber();
if (number > 0) {
// 扣库存
secondKill.setNumber(number - 1);
secondKillMapper.updateById(secondKill);
// 创建订单
SuccessKilled killed = new SuccessKilled();
killed.setSeckillId(skgId);
killed.setUserId(userId);
killed.setState((short) 0);
killed.setCreateTime(new Timestamp(System.currentTimeMillis()));
successKilledMapper.insert(killed);
// 模拟支付
Payment payment = new Payment();
payment.setSeckillId(skgId);
payment.setUserId(userId);
payment.setMoney(40);
payment.setState((short) 1);
payment.setCreateTime(new Timestamp(System.currentTimeMillis()));
paymentMapper.insert(payment);
} else {
return Result.error(SecondKillStateEnum.END);
}
} catch (Exception e) {
throw new RuntimeException("业务处理异常");
} finally {
lock.unlock();
}
return Result.ok(SecondKillStateEnum.SUCCESS);
}
这段代码看似合理,但在高并发压力测试下(例如1000并发抢100件商品)会出现超卖现象。其核心问题在于锁的释放时机早于事务提交。@Transactional注解使得事务在整个方法执行完毕后才会提交,但锁却在finally块中、方法返回前就已释放。这可能导致其他线程在事务提交前读取到未最终提交的数据(如库存),从而引发超卖。
2. 解决方案:七种实现方式
解决上述问题的关键在于确保加锁范围涵盖整个事务生命周期。以下是七种不同的实现方案。
2.1 方式一:改进版显式锁(Controller层加锁)
将加锁操作上移至Controller层,确保锁在调用Service事务方法之前获取,并在事务方法执行完毕后释放。
@ApiOperation(value="秒杀实现方式——Lock加锁(改进版)")
@PostMapping("/start/lock/v2")
public Result startLock(long skgId){
// 在调用事务方法前加锁
lock.lock();
try {
log.info("开始秒杀...");
final long userId = (int) (new Random().nextDouble() * (99999 - 10000 + 1)) + 10000;
Result result = secondKillService.startSecondKillByLock(skgId, userId);
if(result != null){
log.info("用户:{}--{}", userId, result.get("msg"));
}else{
log.info("用户:{}--{}", userId, "秒杀失败");
}
} catch (Exception e) {
e.printStackTrace();
} finally {
// 在事务方法执行后释放锁
lock.unlock();
}
return Result.ok();
}
// Service方法保持原有的 @Transactional 注解,但移除其内部的 lock/unlock 操作。
总结:此方法简单直接地解决了锁时机问题。需注意,当并发数小于或等于商品数时,可能因锁竞争或异常处理导致“少卖”。
2.2 方式二:基于AOP的声明式锁
通过自定义AOP切面,实现更优雅、非侵入式的加锁,确保在事务开始前加锁,事务结束后释放。
-
自定义注解:
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ServiceLock {
String description() default "";
}
-
切面定义:
@Slf4j
@Component
@Scope
@Aspect
@Order(1) // Order值较小者优先执行,但后结束
public class LockAspect {
private static Lock lock = new ReentrantLock(true); // 公平锁
@Pointcut("@annotation(com.example.aop.ServiceLock)")
public void lockAspect() {
}
@Around("lockAspect()")
public Object around(ProceedingJoinPoint joinPoint) {
lock.lock();
Object obj = null;
try {
obj = joinPoint.proceed(); // 调用被注解的业务方法(包含事务)
} catch (Throwable e) {
e.printStackTrace();
throw new RuntimeException(e);
} finally{
lock.unlock();
}
return obj;
}
}
-
使用注解:
@Override
@ServiceLock // 通过AOP在事务外加锁
@Transactional(rollbackFor = Exception.class)
public Result startSecondKillByAop(long skgId, long userId) {
// ... 业务逻辑与方式一相同,但无需手动加锁
}
总结:利用Spring AOP机制,解耦了锁逻辑与业务逻辑,代码更清晰。其锁生效原理与方式一相同。
2.3 方式三:数据库悲观锁(SELECT ... FOR UPDATE)
利用数据库提供的行级锁,在事务中通过SELECT ... FOR UPDATE锁定查询记录,直到事务提交才释放。
DAO层:
@Repository
public interface SecondKillMapper extends BaseMapper<SecondKill> {
@Select("SELECT * FROM seckill WHERE seckill_id=#{skgId} FOR UPDATE")
SecondKill querySecondKillForUpdate(@Param("skgId") Long skgId);
}
Service层:
@Override
@Transactional(rollbackFor = Exception.class)
public Result startSecondKillByUpdate(long skgId, long userId) {
try {
// 使用FOR UPDATE查询,锁定该行数据
SecondKill secondKill = secondKillMapper.querySecondKillForUpdate(skgId);
Integer number = secondKill.getNumber();
if (number > 0) {
// 扣库存、创建订单等操作...
secondKill.setNumber(number - 1);
secondKillMapper.updateById(secondKill);
// ... 其他操作
}
} catch (Exception e) {
throw new RuntimeException("业务处理异常");
}
return Result.ok(SecondKillStateEnum.SUCCESS);
}
总结:锁粒度在数据库行级别,可靠性强。但会加大数据库连接压力,性能损耗较大,且需要注意死锁问题。当请求数与商品数一致时,也可能因锁竞争出现“少卖”。
2.4 方式四:数据库悲观锁(UPDATE锁)
通过一条原子性的UPDATE语句在更新数据时自带锁,并校验库存。
DAO层:
@Update("UPDATE seckill SET number=number-1 WHERE seckill_id=#{skgId} AND number > 0")
int updateSecondKillById(@Param("skgId") long skgId);
Service层:
@Override
@Transactional(rollbackFor = Exception.class)
public Result startSecondKillByUpdateTwo(long skgId, long userId) {
try {
// 直接执行带条件的更新,原子操作
int result = secondKillMapper.updateSecondKillById(skgId);
if (result > 0) {
// 创建订单等后续操作...
} else {
return Result.error(SecondKillStateEnum.END);
}
} catch (Exception e) {
throw new RuntimeException("业务处理异常");
}
return Result.ok(SecondKillStateEnum.SUCCESS);
}
总结:这是利用数据库本身原子性实现的最简洁方案之一,效率较高。通常结合Redis等缓存预减库存来提升性能。
2.5 方式五:数据库乐观锁
通过增加版本号(version)字段,在更新时校验数据是否被其他事务修改过。
DAO层:
@Update("UPDATE seckill SET number=number-#{number}, version=version+1 WHERE seckill_id=#{skgId} AND version = #{version}")
int updateSecondKillByVersion(@Param("number") int number, @Param("skgId") long skgId, @Param("version") int version);
Service层:
@Override
@Transactional(rollbackFor = Exception.class)
public Result startSecondKillByPesLock(long skgId, long userId, int number) {
try {
SecondKill kill = secondKillMapper.selectById(skgId);
if(kill.getNumber() >= number) {
// 基于版本号更新
int result = secondKillMapper.updateSecondKillByVersion(number, skgId, kill.getVersion());
if (result > 0) {
// 创建订单等后续操作...
} else {
// 更新失败,说明版本号已变,数据被其他事务修改
return Result.error(SecondKillStateEnum.END);
}
}
} catch (Exception e) {
throw new RuntimeException("业务处理异常");
}
return Result.ok(SecondKillStateEnum.SUCCESS);
}
总结:乐观锁在高并发场景下会产生大量的更新失败(返回result = 0),导致成功率低,不适用于秒杀这种极端竞争场景,更适合读多写少的环境。
2.6 方式六:内存阻塞队列
将瞬时请求放入一个固定容量的阻塞队列,由后台线程逐个消费处理,将高并发转为顺序执行。
秒杀队列(单例):
public class SecondKillQueue {
static final int QUEUE_MAX_SIZE = 100;
static BlockingQueue<SuccessKilled> blockingQueue = new LinkedBlockingQueue<>(QUEUE_MAX_SIZE);
private SecondKillQueue(){};
private static class SingletonHolder {
private static SecondKillQueue queue = new SecondKillQueue();
}
public static SecondKillQueue getSkillQueue(){
return SingletonHolder.queue;
}
public Boolean produce(SuccessKilled kill) {
return blockingQueue.offer(kill);
}
public SuccessKilled consume() throws InterruptedException {
return blockingQueue.take();
}
}
消费线程(实现ApplicationRunner):
@Slf4j
@Component
public class TaskRunner implements ApplicationRunner{
@Autowired
private SecondKillService seckillService;
@Override
public void run(ApplicationArguments var){
new Thread(() -> {
while(true){
try {
SuccessKilled kill = SecondKillQueue.getSkillQueue().consume();
if(kill != null){
// 调用业务方法处理
Result result = seckillService.startSecondKillByAop(kill.getSeckillId(), kill.getUserId());
// ... 日志记录
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
}
}
Controller层:
@ApiOperation(value="秒杀实现方式六——消息队列")
@PostMapping("/start/queue")
public Result startQueue(long skgId){
try {
final long userId = ... ; // 生成用户ID
SuccessKilled kill = new SuccessKilled();
kill.setSeckillId(skgId);
kill.setUserId(userId);
Boolean flag = SecondKillQueue.getSkillQueue().produce(kill);
if(flag){
log.info("用户:{} 进入队列成功", userId);
}
} catch (Exception e) {
e.printStackTrace();
}
return Result.ok();
}
注意:
- 业务层和AOP中不能抛出运行时异常,否则会导致消费线程终止。
- 队列长度需合理设置。若队列长度与商品总数相当,可能因入队/出队时间差导致“少卖”。
- 消费线程内调用的业务方法,其加锁(如AOP锁)与不加锁效果相同,因为请求已是串行处理。
2.7 方式七:Disruptor高性能队列
使用Disruptor——一个高性能的内存无锁队列,其设计目标是为了极致的低延迟。
事件、工厂、生产者、消费者及工具类(代码结构较长,以下为核心摘要):
- 事件(Event): 承载秒杀ID和用户ID。
- 事件工厂(EventFactory): 用于预分配事件对象。
- 生产者(Producer): 向RingBuffer发布事件。
- 消费者(Consumer): 实现
EventHandler,处理事件并调用业务逻辑。
- 工具类(DisruptorUtil): 初始化Disruptor,绑定消费者,启动。
Controller层调用:
@ApiOperation(value="秒杀实现方式七——Disruptor队列")
@PostMapping("/start/disruptor")
public Result startDisruptor(long skgId){
try {
final long userId = ... ; // 生成用户ID
SecondKillEvent kill = new SecondKillEvent();
kill.setSeckillId(skgId);
kill.setUserId(userId);
DisruptorUtil.producer(kill); // 发布事件
} catch (Exception e) {
e.printStackTrace();
}
return Result.ok();
}
总结:Disruptor在性能上远高于LinkedBlockingQueue,但其模型更复杂。同样需要注意消费线程异常处理,且也存在入队/出队间隙导致的“少卖”可能性。
3. 方案对比与总结
| 方式 |
核心原理 |
优点 |
缺点/注意事项 |
| 1. Controller锁 |
手动管理Lock,包裹事务 |
直观,解决锁时机问题 |
锁粒度大,代码侵入性强 |
| 2. AOP锁 |
通过切面自动管理锁 |
解耦,代码优雅,锁时机正确 |
依赖于Spring AOP,需理解执行顺序 |
| 3. 悲观锁(FOR UPDATE) |
数据库行锁 |
可靠性高,标准数据库特性 |
性能开销大,易引发死锁,连接压力大 |
| 4. 悲观锁(UPDATE) |
数据库原子更新 |
实现简单,效率较高 |
通常需配合缓存使用 |
| 5. 乐观锁 |
数据版本号控制 |
并发度高时节省锁开销 |
秒杀场景失败率极高,不适用 |
| 6. 阻塞队列 |
请求串行化 |
平滑流量,实现异步 |
队列容量管理,存在延迟,需防线程终止 |
| 7. Disruptor队列 |
高性能无锁队列 |
极致性能,低延迟 |
实现复杂,存在延迟,需防线程终止 |
综合建议:
- 对于一般并发场景,方式二(AOP锁)和方式四(UPDATE锁)是平衡了复杂度与可靠性的选择。
- 在真正的秒杀等高并发场景中,单一的数据库锁或Java锁很难扛住压力。主流架构通常采用“缓存预减库存 + 消息队列异步下单 + 数据库最终扣减”的组合方案。例如,用
Redis原子操作预减库存,将校验通过的请求送入Kafka或RocketMQ,再由消费者异步完成数据库持久化,从而保护数据库并提升系统吞吐量。