找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1113

积分

0

好友

163

主题
发表于 昨天 00:00 | 查看: 5| 回复: 0

在并发编程与高并发业务场景(如秒杀)中,开发者常通过结合@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切面,实现更优雅、非侵入式的加锁,确保在事务开始前加锁,事务结束后释放。

  1. 自定义注解

    @Target({ElementType.PARAMETER, ElementType.METHOD})
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    public @interface ServiceLock {
    String description() default "";
    }
  2. 切面定义

    @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;
    }
    }
  3. 使用注解

    @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();
}

注意

  1. 业务层和AOP中不能抛出运行时异常,否则会导致消费线程终止。
  2. 队列长度需合理设置。若队列长度与商品总数相当,可能因入队/出队时间差导致“少卖”。
  3. 消费线程内调用的业务方法,其加锁(如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原子操作预减库存,将校验通过的请求送入KafkaRocketMQ,再由消费者异步完成数据库持久化,从而保护数据库并提升系统吞吐量。



上一篇:Linux工程师如何选择:RHCSA与RHCE认证的深度解析与路径指南
下一篇:反射型XSS漏洞的严重性升级:从令牌劫持到账户接管实战分析
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-17 18:17 , Processed in 0.150209 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表