Skip to content

以下是将您提供的内容整理为Markdown格式的文档。内容保持了原意,对结构进行了优化,增加了代码高亮和层级划分,以便于阅读。


业务讲解:高并发购票与数据一致性保障

本文档整合了关于大麦网项目中应对高并发购票压力的核心策略,包括分布式锁优化、无锁化设计(Lua脚本)、幂等性保护以及缓存与数据库的一致性保障。

相关参考文档:


1. 业务简介

针对购票流程,项目使用了多种技巧来提高效率,包括幂等性、本地锁、分布式锁、Redis、Lua脚本、限流算法等。

业务流程示意图


2. 幂等性保护

在此业务中做幂等性的保护,是为了防止用户多次提交。虽然前端可以将按钮置灰,但如果前端控制失效、网络延迟,或者有人刷号直接调用接口,后端必须做好兜底。

为什么有分布式锁了还要加幂等组件?

疑惑:直接使用分布式锁不就行了,为什么还要额外设计出幂等组件?

虽然分布式锁可以实现幂等(配合业务验证),但分布式锁会浪费性能。

  • 分布式锁的特点:多个请求并发执行,来自不同用户的请求需要依次等待锁执行。最终目标是都要获得锁并执行(除非超时)。
  • 幂等的特点:多个请求并发执行,但来自同一个用户的重复请求,只要保证第一个请求能执行,其余的请求要直接拒绝掉。即:只有第一个请求获得锁执行,其余看到已上锁/已执行,直接结束。

掌握这个区别,才能理解为什么需要独立的幂等组件。 详细介绍跳转:幂等性组件设计


3. 分布式锁与业务验证

分布式锁

使用 节目ID 作为锁的粒度。

组合模式的业务验证

代码位置:com.damai.service.ProgramOrderService#create

java
// 进行业务验证
compositeContainer.execute(CompositeCheckType.PROGRAM_ORDER_CREATE_CHECK.getValue(), programOrderCreateDto);

此组件使用了组合模式和树形结构,将业务验证逻辑复用并串联起来执行。program_order_create_check 包含以下逻辑:

  1. 验证座位参数
  2. 将节目缓存
  3. 验证缓存是否存在节目数据
  4. 验证用户是否存在

详细介绍跳转:组合模式业务验证


4. 核心业务逻辑与缓存设计

Redis Key 设计

数据类型Redis Key 结构说明
余票数据d_mai_program_ticket_remain_number_resolution_节目id_票档id存储票档余票
未售座位d_mai_program_seat_no_sold_resolution_hash_节目id_票档idHash结构,未售出的座位
锁定座位d_mai_program_seat_lock_resolution_hash_节目id_票档idHash结构,下单锁定的座位
已售座位d_mai_program_seat_sold_resolution_hash_节目id_票档idHash结构,支付成功的座位

为什么缓存Key除了节目ID还要加票档ID?

  1. 减小Hash体积:如果只有节目ID,大型演唱会(如1万个座位)的所有座位都在一个Hash中,每次验证和更新都要取出大量数据,压力巨大。加上票档ID后,数据被拆分(如5个票档,每个Hash仅2000数据)。
  2. 提升并发吞吐量:在Redis集群环境下,不同票档的数据可能分布在不同分片上。如果两个用户购买不同票档(如一等票 vs 二等票),他们可以实现并发执行,互不阻塞。

更新缓存余票与座位状态 (Lua脚本封装)

代码位置:com.damai.service.ProgramOrderService#updateProgramCacheDataResolution

此方法负责生成订单(扣减余票、锁定座位)和取消订单(恢复余票、释放座位)两种相反操作的参数拼装,并调用Lua脚本。

java
private void updateProgramCacheDataResolution(Long programId, List<SeatVo> seatVoList, OrderStatus orderStatus){
    // 1. 状态校验:只允许未支付(下单)或取消状态
    if (!(Objects.equals(orderStatus.getCode(), OrderStatus.NO_PAY.getCode()) ||
            Objects.equals(orderStatus.getCode(), OrderStatus.CANCEL.getCode()))) {
        throw new DaMaiFrameException(BaseCode.OPERATE_ORDER_STATUS_NOT_PERMIT);
    }
    List<String> keys = new ArrayList<>();
    keys.add("#"); // 占位符
    
    String[] data = new String[3];
    
    // 2. 组装票档数据 (Ticket Category Data)
    Map<Long, Long> ticketCategoryCountMap =
            seatVoList.stream().collect(Collectors.groupingBy(SeatVo::getTicketCategoryId, Collectors.counting()));
    JSONArray jsonArray = new JSONArray();
    ticketCategoryCountMap.forEach((k,v) -> {
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("programTicketRemainNumberHashKey", RedisKeyBuild.createRedisKey(
                RedisKeyManage.PROGRAM_TICKET_REMAIN_NUMBER_HASH_RESOLUTION, programId, k).getRelKey());
        jsonObject.put("ticketCategoryId", String.valueOf(k));
        
        // 生成订单:扣减余票;取消订单:恢复余票
        if (Objects.equals(orderStatus.getCode(), OrderStatus.NO_PAY.getCode())) {
            jsonObject.put("count","-" + v);
        } else if (Objects.equals(orderStatus.getCode(), OrderStatus.CANCEL.getCode())) {
            jsonObject.put("count",v);
        }
        jsonArray.add(jsonObject);
    });

    // 3. 组装座位数据 (Seat Data)
    Map<Long, List<SeatVo>> seatVoMap = 
            seatVoList.stream().collect(Collectors.groupingBy(SeatVo::getTicketCategoryId));
    JSONArray delSeatIdjsonArray = new JSONArray();
    JSONArray addSeatDatajsonArray = new JSONArray();
    
    seatVoMap.forEach((k,v) -> {
        JSONObject delSeatIdjsonObject = new JSONObject();
        JSONObject seatDatajsonObject = new JSONObject();
        String seatHashKeyDel = "";
        String seatHashKeyAdd = "";
        
        // 生成订单:从"未售"删除,添加到"锁定"
        if (Objects.equals(orderStatus.getCode(), OrderStatus.NO_PAY.getCode())) {
            seatHashKeyDel = (RedisKeyBuild.createRedisKey(RedisKeyManage.PROGRAM_SEAT_NO_SOLD_RESOLUTION_HASH, programId, k).getRelKey());
            seatHashKeyAdd = (RedisKeyBuild.createRedisKey(RedisKeyManage.PROGRAM_SEAT_LOCK_RESOLUTION_HASH, programId, k).getRelKey());
            for (SeatVo seatVo : v) { seatVo.setSellStatus(SellStatus.LOCK.getCode()); }
        } 
        // 取消订单:从"锁定"删除,添加到"未售"
        else if (Objects.equals(orderStatus.getCode(), OrderStatus.CANCEL.getCode())) {
            seatHashKeyDel = (RedisKeyBuild.createRedisKey(RedisKeyManage.PROGRAM_SEAT_LOCK_RESOLUTION_HASH, programId, k).getRelKey());
            seatHashKeyAdd = (RedisKeyBuild.createRedisKey(RedisKeyManage.PROGRAM_SEAT_NO_SOLD_RESOLUTION_HASH, programId, k).getRelKey());
            for (SeatVo seatVo : v) { seatVo.setSellStatus(SellStatus.NO_SOLD.getCode()); }
        }
        
        // 组装删除Key和ID列表
        delSeatIdjsonObject.put("seatHashKeyDel", seatHashKeyDel);
        delSeatIdjsonObject.put("seatIdList", v.stream().map(SeatVo::getId).map(String::valueOf).collect(Collectors.toList()));
        delSeatIdjsonArray.add(delSeatIdjsonObject);
        
        // 组装添加Key和数据列表
        seatDatajsonObject.put("seatHashKeyAdd", seatHashKeyAdd);
        List<String> seatDataList = new ArrayList<>();
        for (SeatVo seatVo : v) {
            seatDataList.add(String.valueOf(seatVo.getId()));
            seatDataList.add(JSON.toJSONString(seatVo));
        }
        seatDatajsonObject.put("seatDataList", seatDataList);
        addSeatDatajsonArray.add(seatDatajsonObject);
    });
    
    data[0] = JSON.toJSONString(jsonArray);        // 票档变动数据
    data[1] = JSON.toJSONString(delSeatIdjsonArray); // 需删除的座位ID
    data[2] = JSON.toJSONString(addSeatDatajsonArray); // 需添加的座位数据
    
    // 执行Lua脚本
    programCacheResolutionOperate.programCacheOperate(keys, data);
}

Data 数组结构说明

  1. 第一个元素 (票档数量)[{"programTicketRemainNumberHashKey":"key_name","ticketCategoryId":"2","count":"-1"}]
  2. 第二个元素 (删除座位)[{"seatHashKeyDel":"key_name","seatIdList":["1"]}]
  3. 第三个元素 (添加座位)[{"seatDataList":["1","{seat_json_obj}"],"seatHashKeyAdd":"key_name"}]

Lua 脚本执行逻辑

脚本位置:resources/lua/programDataResolution.lua

核心逻辑

  1. 遍历票档列表,执行 HINCRBY 扣减或恢复库存。
  2. 遍历删除列表,执行 HDEL 删除旧状态的座位。
  3. 遍历添加列表,执行 HMSET 添加新状态的座位。

原子性保障:通过Lua脚本,将库存扣减和座位状态变更合并为一个原子操作,执行到这里意味着余票已扣除且座位已锁定,后续只需组装订单数据。


5. 订单创建与分库分表优化

代码位置:com.damai.service.ProgramOrderService#doCreate

订单编号生成的基因法

java
// 生成订单编号
orderCreateDto.setOrderNumber(uidGenerator.getOrderNumber(programOrderCreateDto.getUserId(), ORDER_TABLE_COUNT));

场景痛点: 订单表分库分表通常使用 订单编号 作为分片键。但业务中常需要通过 用户ID 查询该用户的所有订单。如果只用订单号分片,用户维度的查询会造成读扩散(扫描所有表)。

解决方案(基因法): 将 用户ID 融入到 雪花算法 生成的订单编号中。

  • 原理:让 订单编号 % 分片数 的结果 与 用户ID % 分片数 的结果一致。
  • 效果:订单编号和用户ID都成为了分片键,既能通过订单号查,也能通过用户ID查,且不需要建立额外的“订单-用户”映射表,避免了读扩散。

6. 订单服务流程

代码位置:com.damai.service.OrderService#create

  1. 验重:检查订单号是否存在。
  2. 落库:插入主订单表和购票人订单表。
  3. 统计:Redis记录用户该节目的下单数量。
  4. 异常处理
    • 失败回滚:如果落库失败,需调用 updateProgramCacheDataResolution 回滚Redis中的库存和座位。
    • 超时取消:创建成功后,发送消息至 延迟队列。若超时未支付,自动取消订单。

7. 支付与数据一致性

支付回调流程

支付回调流程

  1. 用户购票:操作Redis(扣余票,座位设为Lock)。
  2. 支付回调:操作Redis(座位从Lock改为Sold)。

注意:截止到支付成功,所有关于余票和座位的操作都在Redis缓存中进行,数据库并未更新。

数据库的最终一致性 (延迟更新)

当支付回调成功更新缓存后,发送消息 delay_operate_program_data 到延迟队列,由节目服务消费该消息来更新数据库。

节目服务消费逻辑

代码位置:com.damai.service.ProgramService#operateProgramData

  1. 幂等性检查:使用 @RepeatExecuteLimit,基于 节目ID + 座位ID集合 进行防重。
  2. 数据库更新
    • 检查数据库中座位是否已售(防止重复卖)。
    • 批量更新座位状态为 SOLD
    • 批量扣减票档库存。

关键点:这里必须使用 节目ID 作为参数,因为座位表也是以节目ID为分片键的,否则更新数据库时也会发生读扩散。


8. 总结:数据库与缓存一致性问题

为什么选择延迟队列异步更新DB?

在整个购票流程中,始终优先使用 Redis 中的余票数量做验证和扣减(Lua脚本保障原子性),因此不会出现超卖。数据库与缓存的不一致,仅仅是“显示数据”的短暂延迟。

1. 解耦峰值压力

支付/下单是高并发热点。Redis 能承受几万QPS,但关系型数据库无法承受瞬间的大量写操作。延迟队列将写库操作“缓一缓”,后台线程按节奏批量写入,避免打垮数据库。

2. 批量聚合写库

可以将同一个节目或票档的多条消息在队列中积累(如100ms),然后一次性 Batch Update,大幅降低数据库连接次数和事务开销。

3. 顺序一致性 & 重试机制

延迟队列自带重试。如果写库失败(网络抖动等),可以自动重试或进入死信队列,而不需要在主业务流程中处理复杂的“缓存回滚”和“补偿事务”。

结论

虽然存在短暂的数据不一致(缓存 > 数据库),但通过 Redis强一致性校验 保证了业务的准确性(不超卖)。利用 延迟队列 实现数据的最终一致性,是平衡高并发性能与数据准确性的最佳实践。