Skip to content

以下是基于您提供的文章内容整理的 V2版本:高并发购票锁优化方案 的 Markdown 文档。


业务讲解:如何优化锁机制应对高并发购票 (V2版本)

1. 背景与问题 (V1版本的不足)

在 V1 版本中,我们使用了分布式锁来保证数据的一致性。 V1 锁策略

java
@ServiceLock(name = PROGRAM_ORDER_CREATE_V1, keys = {"#programOrderCreateDto.programId"})

存在问题(锁粒度过大): 锁的粒度是整个节目(programId)。

  • 用户1购买“一等票”,用户2购买“二等票”。
  • 虽然票档库存是分开管理的,但在 V1 策略下,用户2必须等待用户1释放锁。
  • 结论:这导致并发性能较差,同一时间内该节目只能有一个用户进行购票操作。

2. 优化方案一:细化锁粒度

2.1 优化策略

将锁的粒度从“节目”缩小到“节目ID + 票档ID”。

  • 原理:请求1(买一等票)和 请求2(买二等票)互不干扰,可以并发执行。
  • 效果:系统的处理效率理论上可提高数倍(取决于票档数量)。

2.2 应对复杂场景(多票档购买)

如果用户同时购买多种类型的票(如同时买一等票和二等票),会面临锁重合问题。

  • 解决方案:根据要购买的票档类型,依次获取多把锁。
  • 防止死锁
    1. 排序:对要获取的锁进行排序(如按票档ID升序),保证所有线程获取锁的顺序一致。
    2. 安全解锁:无论业务执行成功或异常,必须保证已获取的锁能被安全释放。

3. 优化方案二:引入本地锁 (多级锁架构)

3.1 缓解 Redis 压力

细化粒度后,短时间内会有大量请求竞争分布式锁,对 Redis 造成巨大压力(网络I/O开销大)。

解决方案本地锁 + 分布式锁

  1. 本地锁(Local Lock):先竞争当前服务实例的本地锁(内存操作,速度快)。
  2. 分布式锁(Distributed Lock):获得本地锁的请求,才有资格去竞争分布式锁。

效果:相当于一个漏斗,先在 JVM 层面拦截掉大部分竞争,只有少部分请求会打到 Redis,显著降低 Redis 压力。

3.2 本地锁的内存管理 (Caffeine)

问题:如果使用 ConcurrentHashMap 存储本地锁,随着节目和票档增多,锁对象(ReentrantLock)会无限增加,导致 OOM(内存溢出)

解决方案:使用 Caffeine 缓存。

  • 原因:Caffeine 是基于 Java 8 的高性能缓存(Spring 5 默认缓存实现),支持 过期策略
  • 机制:设置过期时间(如2小时)。当某个“节目+票档”长时间无访问时,自动回收锁对象。

LocalLockCache 代码实现

java
public class LocalLockCache {
    // 本地锁缓存
    private Cache<String, ReentrantLock> localLockCache;

    // 本地锁过期时间(小时)
    @Value("${durationTime:2}")
    private Integer durationTime;

    @PostConstruct
    public void localLockCacheInit(){
        localLockCache = Caffeine.newBuilder()
                .expireAfterWrite(durationTime, TimeUnit.HOURS)
                .build();
    }

    // 获得锁,Caffeine的get是线程安全的
    public ReentrantLock getLock(String lockKey, boolean fair){
        return localLockCache.get(lockKey, key -> new ReentrantLock(fair));
    }
}

4. 锁的类型选择 (公平 vs 非公平)

本项目支持两种锁模式,可根据业务需求切换。

特性公平锁 (Fair)非公平锁 (Unfair)
定义严格遵循 FIFO (先进先出),先来先得。允许插队,尝试获取锁时若锁可用则直接占用,失败才入队。
优点避免线程饥饿,保证公平性。性能更高,减少线程上下文切换开销。
缺点性能较低,维护等待队列开销大。可能导致某些线程长时间等待 (饥饿)。
适用场景追求极致的用户体验 (先点先得)。追求高吞吐量与性能 (推荐)

关于混合锁的公平性局限: 即使本地锁和分布式锁都设置为“公平锁”,也只能保证局部有序,无法保证全局有序原因:请求1和请求4分别在不同机器先拿到本地锁,但请求4可能因为网络延迟低,比请求1先拿到分布式锁。


5. V2版本核心代码实现

5.1 控制层入口

java
@ApiOperation(value = "购票V2")
@PostMapping(value = "/create/v2")
public ApiResponse<String> createV2(@Valid @RequestBody ProgramOrderCreateDto programOrderCreateDto) {
    return ApiResponse.ok(programOrderLock.createV2(programOrderCreateDto));
}

5.2 核心加锁逻辑 (ProgramOrderLock)

流程总结

  1. 参数验证:前置检查业务参数。
  2. 排序防死锁:对票档ID进行排序。
  3. 循环加锁:依次获取 本地锁 -> 分布式锁。
  4. 执行业务:所有锁获取成功后,执行下单逻辑。
  5. 逆序解锁:无论成功失败,按加锁相反顺序释放锁。
java
/**
 * 订单优化版本v2
 * 策略:本地锁 -> 分布式锁
 * 粒度:节目id + 票档id
 */
@RepeatExecuteLimit(
    name = RepeatExecuteLimitConstants.CREATE_PROGRAM_ORDER,
    keys = {"#programOrderCreateDto.userId", "#programOrderCreateDto.programId"}
)
public String createV2(ProgramOrderCreateDto programOrderCreateDto) {
    // 1. 业务参数验证 (前置)
    compositeContainer.execute(CompositeCheckType.PROGRAM_ORDER_CREATE_CHECK.getValue(), programOrderCreateDto);

    List<SeatDto> seatDtoList = programOrderCreateDto.getSeatDtoList();
    List<Long> ticketCategoryIdList = new ArrayList<>();

    // 2. 统计并排序票档ID (避免死锁关键步骤)
    if (CollectionUtil.isNotEmpty(seatDtoList)) {
        ticketCategoryIdList = seatDtoList.stream()
                .map(SeatDto::getTicketCategoryId)
                .distinct()
                .sorted() // 排序
                .collect(Collectors.toList());
    } else {
        ticketCategoryIdList.add(programOrderCreateDto.getTicketCategoryId());
    }

    // 初始化锁集合
    List<ReentrantLock> localLockList = new ArrayList<>(ticketCategoryIdList.size());
    List<RLock> serviceLockList = new ArrayList<>(ticketCategoryIdList.size());
    List<ReentrantLock> localLockSuccessList = new ArrayList<>(ticketCategoryIdList.size());
    List<RLock> serviceLockSuccessList = new ArrayList<>(ticketCategoryIdList.size());

    try {
        // 3. 加锁流程
        for (Long ticketCategoryId : ticketCategoryIdList) {
            String lockKey = StrUtil.join("-", PROGRAM_ORDER_CREATE_V2, 
                                          programOrderCreateDto.getProgramId(), ticketCategoryId);
            
            // 3.1 获取并尝试加本地锁 (非公平锁)
            ReentrantLock localLock = localLockCache.getLock(lockKey, false);
            // ... (省略加锁tryLock逻辑,成功则加入localLockSuccessList)

            // 3.2 获取并尝试加分布式锁 (非公平锁)
            RLock serviceLock = serviceLockTool.getLock(LockType.Reentrant, lockKey);
             // ... (省略加锁tryLock逻辑,成功则加入serviceLockSuccessList)
        }

        // 4. 执行下单业务
        return programOrderService.create(programOrderCreateDto);

    } finally {
        // 5. 解锁流程 (逆序释放)
        // 释放分布式锁
        for (int i = serviceLockSuccessList.size() - 1; i >= 0; i--) {
            try {
                serviceLockSuccessList.get(i).unlock();
            } catch (Throwable t) {
                // log error
            }
        }
        // 释放本地锁
        for (int i = localLockSuccessList.size() - 1; i >= 0; i--) {
            try {
                localLockSuccessList.get(i).unlock();
            } catch (Throwable t) {
                // log error
            }
        }
    }
}

6. 总结与展望

V2 版本优化点

  1. 锁粒度细化:从“节目”级别降级为“节目+票档”级别,大幅提升并发度。
  2. 多级锁架构:引入本地锁作为前置屏障,显著减少 Redis 访问压力。
  3. 防内存溢出:利用 Caffeine 管理本地锁生命周期。

遗留问题与后续方向

虽然 V2 版本解决了大部分并发问题,但在生成订单逻辑中,依然需要在持有锁的情况下进行数据库/Redis交互(查询余票、扣减库存、锁定座位),这依然是串行操作。

终极杀招 (V3版本预告)无锁化! 将余票验证、座位验证、扣减库存、锁定座位等操作封装在 Lua 脚本 中一次性执行。如果脚本执行成功,则直接生成订单,从而彻底移除应用层的分布式锁,实现极致性能。