以下是将您提供的内容整理为Markdown格式的文档。内容保持了原意,对结构进行了优化,增加了代码高亮和层级划分,以便于阅读。
业务讲解:高并发购票与数据一致性保障
本文档整合了关于大麦网项目中应对高并发购票压力的核心策略,包括分布式锁优化、无锁化设计(Lua脚本)、幂等性保护以及缓存与数据库的一致性保障。
相关参考文档:
1. 业务简介
针对购票流程,项目使用了多种技巧来提高效率,包括幂等性、本地锁、分布式锁、Redis、Lua脚本、限流算法等。

2. 幂等性保护
在此业务中做幂等性的保护,是为了防止用户多次提交。虽然前端可以将按钮置灰,但如果前端控制失效、网络延迟,或者有人刷号直接调用接口,后端必须做好兜底。
为什么有分布式锁了还要加幂等组件?
疑惑:直接使用分布式锁不就行了,为什么还要额外设计出幂等组件?
虽然分布式锁可以实现幂等(配合业务验证),但分布式锁会浪费性能。
- 分布式锁的特点:多个请求并发执行,来自不同用户的请求需要依次等待锁执行。最终目标是都要获得锁并执行(除非超时)。
- 幂等的特点:多个请求并发执行,但来自同一个用户的重复请求,只要保证第一个请求能执行,其余的请求要直接拒绝掉。即:只有第一个请求获得锁执行,其余看到已上锁/已执行,直接结束。
掌握这个区别,才能理解为什么需要独立的幂等组件。 详细介绍跳转:幂等性组件设计
3. 分布式锁与业务验证
分布式锁
使用 节目ID 作为锁的粒度。
组合模式的业务验证
代码位置:com.damai.service.ProgramOrderService#create
// 进行业务验证
compositeContainer.execute(CompositeCheckType.PROGRAM_ORDER_CREATE_CHECK.getValue(), programOrderCreateDto);此组件使用了组合模式和树形结构,将业务验证逻辑复用并串联起来执行。program_order_create_check 包含以下逻辑:
- 验证座位参数
- 将节目缓存
- 验证缓存是否存在节目数据
- 验证用户是否存在
详细介绍跳转:组合模式业务验证
4. 核心业务逻辑与缓存设计
Redis Key 设计
| 数据类型 | Redis Key 结构 | 说明 |
|---|---|---|
| 余票数据 | d_mai_program_ticket_remain_number_resolution_节目id_票档id | 存储票档余票 |
| 未售座位 | d_mai_program_seat_no_sold_resolution_hash_节目id_票档id | Hash结构,未售出的座位 |
| 锁定座位 | d_mai_program_seat_lock_resolution_hash_节目id_票档id | Hash结构,下单锁定的座位 |
| 已售座位 | d_mai_program_seat_sold_resolution_hash_节目id_票档id | Hash结构,支付成功的座位 |
为什么缓存Key除了节目ID还要加票档ID?
- 减小Hash体积:如果只有节目ID,大型演唱会(如1万个座位)的所有座位都在一个Hash中,每次验证和更新都要取出大量数据,压力巨大。加上票档ID后,数据被拆分(如5个票档,每个Hash仅2000数据)。
- 提升并发吞吐量:在Redis集群环境下,不同票档的数据可能分布在不同分片上。如果两个用户购买不同票档(如一等票 vs 二等票),他们可以实现并发执行,互不阻塞。
更新缓存余票与座位状态 (Lua脚本封装)
代码位置:com.damai.service.ProgramOrderService#updateProgramCacheDataResolution
此方法负责生成订单(扣减余票、锁定座位)和取消订单(恢复余票、释放座位)两种相反操作的参数拼装,并调用Lua脚本。
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 数组结构说明
- 第一个元素 (票档数量):
[{"programTicketRemainNumberHashKey":"key_name","ticketCategoryId":"2","count":"-1"}] - 第二个元素 (删除座位):
[{"seatHashKeyDel":"key_name","seatIdList":["1"]}] - 第三个元素 (添加座位):
[{"seatDataList":["1","{seat_json_obj}"],"seatHashKeyAdd":"key_name"}]
Lua 脚本执行逻辑
脚本位置:resources/lua/programDataResolution.lua
核心逻辑:
- 遍历票档列表,执行
HINCRBY扣减或恢复库存。 - 遍历删除列表,执行
HDEL删除旧状态的座位。 - 遍历添加列表,执行
HMSET添加新状态的座位。
原子性保障:通过Lua脚本,将库存扣减和座位状态变更合并为一个原子操作,执行到这里意味着余票已扣除且座位已锁定,后续只需组装订单数据。
5. 订单创建与分库分表优化
代码位置:com.damai.service.ProgramOrderService#doCreate
订单编号生成的基因法
// 生成订单编号
orderCreateDto.setOrderNumber(uidGenerator.getOrderNumber(programOrderCreateDto.getUserId(), ORDER_TABLE_COUNT));场景痛点: 订单表分库分表通常使用 订单编号 作为分片键。但业务中常需要通过 用户ID 查询该用户的所有订单。如果只用订单号分片,用户维度的查询会造成读扩散(扫描所有表)。
解决方案(基因法): 将 用户ID 融入到 雪花算法 生成的订单编号中。
- 原理:让
订单编号 % 分片数的结果 与用户ID % 分片数的结果一致。 - 效果:订单编号和用户ID都成为了分片键,既能通过订单号查,也能通过用户ID查,且不需要建立额外的“订单-用户”映射表,避免了读扩散。
6. 订单服务流程
代码位置:com.damai.service.OrderService#create
- 验重:检查订单号是否存在。
- 落库:插入主订单表和购票人订单表。
- 统计:Redis记录用户该节目的下单数量。
- 异常处理:
- 失败回滚:如果落库失败,需调用
updateProgramCacheDataResolution回滚Redis中的库存和座位。 - 超时取消:创建成功后,发送消息至 延迟队列。若超时未支付,自动取消订单。
- 失败回滚:如果落库失败,需调用
7. 支付与数据一致性
支付回调流程

- 用户购票:操作Redis(扣余票,座位设为Lock)。
- 支付回调:操作Redis(座位从Lock改为Sold)。
注意:截止到支付成功,所有关于余票和座位的操作都在Redis缓存中进行,数据库并未更新。
数据库的最终一致性 (延迟更新)
当支付回调成功更新缓存后,发送消息 delay_operate_program_data 到延迟队列,由节目服务消费该消息来更新数据库。
节目服务消费逻辑
代码位置:com.damai.service.ProgramService#operateProgramData
- 幂等性检查:使用
@RepeatExecuteLimit,基于节目ID + 座位ID集合进行防重。 - 数据库更新:
- 检查数据库中座位是否已售(防止重复卖)。
- 批量更新座位状态为
SOLD。 - 批量扣减票档库存。
关键点:这里必须使用 节目ID 作为参数,因为座位表也是以节目ID为分片键的,否则更新数据库时也会发生读扩散。
8. 总结:数据库与缓存一致性问题
为什么选择延迟队列异步更新DB?
在整个购票流程中,始终优先使用 Redis 中的余票数量做验证和扣减(Lua脚本保障原子性),因此不会出现超卖。数据库与缓存的不一致,仅仅是“显示数据”的短暂延迟。
1. 解耦峰值压力
支付/下单是高并发热点。Redis 能承受几万QPS,但关系型数据库无法承受瞬间的大量写操作。延迟队列将写库操作“缓一缓”,后台线程按节奏批量写入,避免打垮数据库。
2. 批量聚合写库
可以将同一个节目或票档的多条消息在队列中积累(如100ms),然后一次性 Batch Update,大幅降低数据库连接次数和事务开销。
3. 顺序一致性 & 重试机制
延迟队列自带重试。如果写库失败(网络抖动等),可以自动重试或进入死信队列,而不需要在主业务流程中处理复杂的“缓存回滚”和“补偿事务”。
结论
虽然存在短暂的数据不一致(缓存 > 数据库),但通过 Redis强一致性校验 保证了业务的准确性(不超卖)。利用 延迟队列 实现数据的最终一致性,是平衡高并发性能与数据准确性的最佳实践。