
做了四年电商订单系统去年被拉去负责一个演唱会购票项目的整体方案设计。当时接手的第一反应是这不就是电商秒杀加了个选座吗后来真做起来才发现演唱会购票的水比普通秒杀深得多——你不仅要扛住瞬时流量还得处理座位锁定、订单超时释放、实名核验、风控拦截这一连串问题任何一个环节出岔子都可能变成热搜上的事故现场。这篇文章把我从需求拆解到上线压测的完整过程整理出来包含架构选型、库存模型、高并发处理和踩坑实录给准备做类似系统或者正在被这个项目折磨的朋友一个参考。1. 项目概述与需求拆解1.1 购票系统到底难在哪很多人一听到演唱会购票系统下意识会觉得这就是一个普通的商品下单系统只是商品换成了门票。实际接手之后你会发现它和普通电商有本质区别。普通电商卖的是实物商品库存可以提前备货超卖了大不了发个道歉补货但演唱会门票是强稀缺资源一个场馆就那么多座位票卖超了是重大事故。更特殊的是门票和场次强绑定用户买的是某一天某个场馆某个座位这意味着系统必须支持精细的座位级库存管理而不是简单扣一个商品总数。另一个难点是流量的脉冲性。一场热门演唱会开票几十万人在同一秒涌进来QPS瞬间冲到几万甚至十几万但开票结束之后流量立刻归零。这种极端波峰波谷的流量模型要求系统从架构层面就为峰值设计而不是靠事后扩容硬扛。还有一层是黄牛问题。热门演出的票有巨大的溢价空间系统必须在不伤害普通用户体验的前提下尽可能识别并拦截脚本抢票、批量注册等异常行为。这已经超出了纯技术范畴需要业务规则和风控策略配合。我接手时梳理出来的核心目标很清晰不超卖、不重复卖、能扛峰值、能拦黄牛。所有技术方案都围绕这四个目标展开。1.2 核心角色与业务流程在设计之前先把业务链路摸清楚。一个完整的购票系统涉及的角色和流程比想象中多用户端浏览演出、选场次、选座位、下单、支付、查看订单、申请退票票务运营端创建演出、配置场次、设置票价分区、导入座位图、开票关闭、查看销售数据风控端实时识别异常请求拦截脚本和黄牛标记高风险账号财务端对账、退款处理、报表统计主流程可以概括为用户选好场次和座位后提交订单系统锁定座位库存生成待支付订单用户在限定时间内完成支付订单变为已支付状态如果超时未支付系统自动释放座位供其他用户购买。这里有个容易被忽略的细节流程看起来和电商一致但选座的引入改变了库存模型。电商是一个SKU一个库存数字这里则是一个座位一个唯一状态锁座和锁库存本质上是一回事但实现上完全不同。这也直接影响了后面的数据表设计和并发控制策略。2. 技术选型与架构设计2.1 技术栈选型为什么这么配技术选型没有绝对的标准答案关键看团队熟悉度和业务约束。我当时的环境是Java技术栈为主运维有成熟的Kubernetes集群选型上尽量不引入团队没玩过的重型组件。我最终敲定的组合是层级选型选型理由接入层Nginx OpenResty承担静态资源、限流、IP维度封禁OpenResty的Lua脚本可以灵活做多级限流应用层Spring Boot 3.x团队主力框架生态成熟运维经验丰富缓存层Redis Cluster库存预扣、分布式锁、排队状态存储都依赖RedisCluster模式保证可用性消息队列RocketMQ订单创建异步化、支付回调处理事务消息能力强数据库MySQL 8.0订单、场次、用户等核心数据落库按场次分表风控服务自研 规则引擎设备指纹、行为埋点、规则判断解耦方便运营调整策略这套方案的核心理念是Redis抗流量MySQL保数据MQ做缓冲。所有不涉及最终一致性的读请求尽量走缓存写请求先做本地校验和Redis原子操作再异步落库数据库只承受真实有效的订单量而不是承受所有点击量。2.2 整体架构与数据流画架构图之前先想清楚一条请求从用户点击到订单落库要经过哪些节点。以最核心的提交订单动作为例用户点击购票后请求依次经过Nginx、网关服务、订单服务。网关层先做基础校验登录态、风控预检、限流随后请求进入订单服务订单服务先检查Redis中该场次的座位缓存状态尝试用Lua脚本原子地抢占座位抢到座位后将订单创建事件发到RocketMQ由消费者异步生成订单记录同时启动一个延迟消息用于超时未支付的座位释放前端则通过WebSocket或者轮询接口感知订单状态引导用户去支付。这套流程里最关键的设计决策是同步链路只做抢座这一件事其他全部异步。用户提交请求后接口在几十毫秒内返回抢座成功请支付订单真正落库可能要几百毫秒之后。大多数用户根本感知不到差异但系统的扛峰值能力因此提升了一个量级。数据存储上做了分拆场次、座位图等低频变动数据放在MySQL主库加Redis缓存订单数据按场次ID做水平分表库存状态不落MySQL而是以Redis为准MySQL里的库存数据仅作为最终对账和运营展示使用。这个设计在业界争议不小我后面会详细讲一致性怎么保证。3. 核心模块设计与实现3.1 场次与座位管理模块场次和座位是整个系统的数据基础。一场演唱会的信息包括艺人、时间、场馆、票价分区而每个分区又对应场馆中的一片物理区域。设计数据模型时我最深的体会是座位图要独立建模不要和场次揉在一起。场馆的座位布局是相对固定的比如一个体育馆有内场和看台看台又分几个区每个区的排数和座位数是稳定的。如果每创建一个场次就复制一份完整的座位数据既浪费存储又容易造成数据不一致。正确的做法是场馆基础数据独立成表场次引用场馆的座位布局同时可以针对场次调整票价、开放区域和分区售卖规则。座位状态用一个字段管理取值包括可售、锁定用户下单未支付、已售、不可售比如遮挡席被关闭。为了提高Redis中的查询和扣减效率我用场次ID 分区ID 排号 座位号作为唯一键存储方式选择Hash字段是座位唯一标识值是状态码。这样判断某个座位是否可售只需要一条HGET命令时间复杂度O(1)。3.2 订单与库存模型座位级锁定的正确姿势这是整个系统最核心的部分也是我踩坑最多的地方。先说结论常规的商品库存扣减方案DECR库存数字在这里不适用必须做座位级锁定。为什么假设内场A区有500个座位你用DECR把库存从500扣到499系统只知道还剩499个可售座位但不知道具体少了哪一个。等到用户支付的Asynchronously完成后你还需要再分配一个具体座位给用户——这就产生了扣了库存但分配座位失败的窗口非常容易造成超卖或者座位错乱。正确方案是用Lua脚本实现原子化的座位抢占。用户提交选座请求时前端已经把座位坐标传过来服务端在Redis里执行一个Lua脚本检查目标座位状态为可售后将其原子地改为锁定并写入锁定的用户ID和过期时间。这个操作全程在Redis单线程内完成不存在并发竞争问题。-- 座位抢占Lua脚本KEYS[1]: 座位HashKeyARGV[1]: 用户ID if redis.call(HGET, KEYS[1], ARGV[2]) 0 then redis.call(HSET, KEYS[1], ARGV[2], 1) redis.call(HSET, KEYS[1] .. :lock, ARGV[2], ARGV[1]) return 1 end return 0锁定之后订单创建走异步链路。RocketMQ的消费者收到订单创建消息后落库生成待支付订单并发送一条延迟消息延迟时间一般为15分钟和支付超时时间一致。延迟消息到期后如果订单仍未支付消费者执行座位释放操作Redis状态改回可售MySQL订单状态更新为已取消。这个方案有几个好处第一所有座位状态的变更都在Redis内完成性能极高第二Lua脚本原子执行从根上杜绝了超卖第三MySQL只负责记录最终结果不参与高并发路径数据库压力骤减。3.3 支付与售后闭环支付环节我直接接的第三方支付聚合服务包括主流App支付和H5支付。很多第一次做票务系统的同学容易忽略一个关键问题支付回调和订单状态的幂等处理。支付回调是异步的而且可能会重复推送加上用户可能同时打开了多个页面同一笔订单可能同时被多个请求触发状态流转。如果状态更新不做幂等就可能出现用户付了款订单还是待支付或者退款单重复创建的事故。我的处理方式是订单状态流转全部走数据库的乐观锁更新条件里带上当前状态和预期状态更新行数为0则说明状态已被其他请求修改直接丢弃本次操作。同时支付回调落库后记录一条流水日志用于后续对账和排查问题。UPDATE t_order SET status 2, pay_time NOW() WHERE order_id #{orderId} AND status 1注意真实项目中除了状态流转的幂等还要做消息去重和回调签名校验任何一个环节漏了线上都会出大问题。退票则相对简单用户申请退票后运营审核通过系统释放座位Redis状态改回可售发起原路退款订单状态标记为已退。这里有一个业务规则需要运营配合——已开场或开演前一定时限内的票是否允许退不同项目差异很大系统里做成配置项即可。4. 高并发抢票的优化手段4.1 缓存预热与库存预扣开票前两小时系统会做一次缓存预热把场次信息、座位分布、各区可售数量全部加载到Redis中。这一步看似简单但做不好会在开票瞬间把MySQL打挂。预热有一个容易忽视的点不只是把数据塞进Redis就完了还要验证缓存的完整性和一致性。我遇到过座位Hash在预热过程中因为脚本中断导致部分分区缺失的情况结果开票后那些分区显示无票其实还有大量余票。后来我在预热脚本里加了校验逻辑预热完成后比对Redis中的座位总数和MySQL中的可售总数不一致则告警并重新预热。库存预扣的核心命令是HINCRBY和Lua脚本的组合。每个分区维护一个可售数量的计数器用户在分区内任意选择一个座位提交时先HINCRBY扣减分区计数再执行座位抢占脚本。这两个操作必须在同一个Lua脚本里完成否则并发下会出现计数扣了但座位没占上、或者座位占了但计数没扣的不一致问题。4.2 排队削峰把瞬时流量摊平即使有了Redis扛量应用服务器和数据库也无法承受所有用户同时提交请求。这里必须引入排队机制来削峰。我采用的方案是业界成熟的分级排队用户点击购票后并不直接把请求打向后端而是先进入一个基于Redis的排队队列。系统按照当前处理能力和排队人数动态发放入场令牌拿到令牌的用户才能进入下一阶段——选座和提交订单。// 排队令牌发放简单版本用Redis的INCR做发号 String ticket stringRedisTemplate.opsForValue() .increment(queue: sessionId) ; // 判断当前号是否已放行 Long allowNum stringRedisTemplate.opsForValue() .get(queue:allow: eventId); if (Long.parseLong(ticket) allowNum) { // 放行进入选座阶段 return enterSeatSelection(sessionId); }这个设计的核心逻辑是系统处理能力有限但排队等待是无限的。与其让所有请求都打进来导致服务雪崩不如让用户在排队页安心等待。实际上演唱会抢票的用户对这种排队有很高的容忍度只要页面能真实反馈排队进度体验并不差。队列参数需要根据压测结果调优。我当时的配置是单机应用最大并发处理300 QPSRedis集群每个分片能扛2万 QPSMySQL峰值写入控制在500 TPS以内。根据这个能力我把每秒放行令牌数量设为一个动态值初始为500系统负载升高时自动降低低时调高。4.3 多级限流与防刷限流是抗峰值和防脚本的底层能力。我从三个维度做了限流网关层Nginx OpenResty的Lua脚本做IP维度的令牌桶限流默认每个IP每秒最多10个请求。开票瞬间可以适当放宽但对于同一个IP在短时间内高频请求的直接返回错误码并加入临时黑名单。应用层使用Guava或者Redis实现的分布式限流器按用户ID 场次ID做维度每个用户每秒最多3次提交订单请求。同时限制同一用户的并发订单数防止一个人用脚本开多线程抢多个座位。风控层设备指纹和账号行为特征的综合评分。我会在后面单独讲。这里要提一个经验限流参数不能拍脑袋定一定要基于业务场景和压测结果。例子热门场次开票一个正常用户从点击页面到提交订单期间产生的请求量大概在20到30个左右页面加载、座位图拉取、轮询状态、提交订单如果限流设置得太严格正常用户都可能被误伤所以每个维度的阈值基本都比正常用户峰值高一倍到三倍作为缓冲。5. 风控与反黄牛设计5.1 实名制与购票资格校验现在正规的演出购票普遍要求实名制购票。这个既合规又有助于防黄牛技术上也能做很多事情。实名制在购票流程中的具体做法用户在下单前必须绑定身份证信息系统调用实名认证接口核验姓名和证件号是否匹配。核验通过后该身份证号才能参与购票。每张票绑定一个身份证号入场时人、证、票三者一致才放行。设计上要注意一个性能问题实名认证接口是外部依赖如果每个用户在开票瞬间都去同步调用基本会把外部接口打死。我的优化方案是分层处理——用户在购票前提前完成一次实名认证系统将其结果缓存在本地开票当天直接读取缓存只有未认证或者认证过期的用户才实时调用外部接口。5.2 设备指纹与行为风控黄牛脚本再厉害终归要跑在某个设备上。设备指纹技术的核心思路是通过浏览器或客户端采集设备特征生成一个唯一标识用于识别同一设备上的所有请求。采集的特征包括浏览器UA、屏幕分辨率、时区、Canvas指纹、WebGL渲染信息、字体列表等。这些特征组合起来重复概率极低。服务端保存设备指纹和账号的绑定关系如果同一设备上出现大量不同账号抢票系统会果断拦截。行为风控则关注操作节奏。正常人手速再快从进入选座页面到提交订单也需要至少几百毫秒脚本则可以做到单次请求间隔低于50毫秒且长时间不间歇。我构建了一套评分规则风险特征分数触发处置同一设备关联账号数 550限流、要求滑块验证请求间隔 100ms 且持续1分钟40限流、二次验证开票前突然批量登录新账号30账号观察期同IP不同账号提交订单 2020IP限流总分 ≥ 80-强制人脸验证或直接拦截这套规则上线后运营反馈脚本抢票的量显著下降。当然风控是持续对抗的过程对方也在不断调整脚本所以规则引擎一定要做成可配置、可灰度、可回滚出问题的时候运营能快速调整。5.3 订单锁定与异常识别除了事前拦截事中也要有兜底。我在订单模块里加了几道异常识别的逻辑同身份证重复购票检测同一个身份证号在同一场次只能购买一张票或按业务规则限购N张。这个校验在提交订单时同步执行在座位抢占完成后、订单落库前再检查一次。两道校验是为了防止并发场景下第一道校验漏过。高频建单失败熔断如果一个账号在短时间内频繁创建订单但都不支付说明大概率是黄牛在探库存或者占用座位。触发条件后该账号进入黑名单锁定到本场次结束。订单分布异常告警如果某个分区的订单创建量短时间内暴增或者大量订单集中在某个IP段、某个设备指纹下系统自动触发告警运营和风控同学介入处理。这些风控手段跑下来整体思路是不要追求单点绝对拦截而是用多层机制让黄牛脚本的成本无限抬高。每多一层验证脚本作者的维护成本就高一分最终他就会权衡值不值得。6. 踩坑实录与排查技巧6.1 库存超卖的经典事故复盘内测阶段我们就出过一次超卖事故非常典型。当时我图省事在分区维度直接用Redis的DECR做库存扣减再异步分配座位。结果并发压测时出现了库存扣成功但座位分配失败的情况——因为两个Redis操作不是原子的多个线程同时扣减了同一个分区的计数但后续座位分配时发现目标座位已被占用不得不回滚库存或者放弃分配最终导致部分订单落库时没有对应座位。复盘结论库存操作和座位锁定的原子性是不可妥协的。所有库存相关的并发修改必须放在同一个Redis Lua脚本里完成绝对不要拆成多个Redis命令。6.2 缓存与数据库一致性的解法前面提到库存以Redis为准MySQL只存最终结果这就要求有一个可靠的同步机制。我用的是 RocketMQ事务消息座位锁定成功后发送一条事务消息消息消费者负责写订单和更新MySQL的座位状态。如果MySQL写入失败怎么办我的兜底策略是订单服务定期扫描Redis中的锁定座位发现锁定时间超过阈值但MySQL中无对应订单记录的触发补偿流程——将Redis座位释放并记录一条异常日志。这个兜底逻辑可以由定时任务保证最终一致唯一的成本是极端情况下用户可能抢到座位但订单没建成需要做补偿通知。6.3 支付回调重复与乱序处理上线后遇到一个经典问题第三方支付的回调有时候会重复推送而且不保证顺序。第一次收到回调时订单从待支付变成已支付第二次收到同样的回调时因为状态已经不是待支付乐观锁更新行数为0操作被正确丢弃。这当然是在状态机设计正确的前提下。但还有一种更隐蔽的乱序退款回调先到支付成功的回调后到。如果状态机没有严格定义流转方向就可能导致订单从已支付被改成已退款之后又被改回已支付。解决办法是在状态更新时不仅校验当前状态还要校验目标状态是否是合法流转非法流转直接拒绝。当前状态允许流向禁止流向待支付已支付、已取消已退款已支付已退款、已完成待支付已取消无任何状态已退款无任何状态6.4 压测中的性能瓶颈定位压测是最能暴露问题的环节。我第一次压测时发现即使Redis和MQ都扛得住应用服务器CPU使用率却先爆了。排查后发现是座位图接口每次请求都从Redis读取整个分区的座位数据序列化后返回给前端体量大且频繁。优化方式很简单座位图数据在开票前生成好静态JSON上CDN前端直接加载开票期间的实时座位状态变化通过WebSocket增量推送。这样把最重的读请求从应用层剥离开应用服务器CPU使用率立刻降了70%。压测的一个建议不要只测正常路径一定要测异常路径——比如支付超时释放座位的并发量、退款触发的库存回补、风控拦截后的令牌回收。这些链路平时的流量很小但一旦出问题都是大问题。7. 实操过程中的几个深刻体会最后分享几个我在这个项目里沉淀下来的个人体会不算什么高深理论但是真的管用。第一先定库存模型再谈架构。很多票务系统翻车都是库存模型设计错了后面所有优化都是在填坑。座位级锁定Lua脚本这个方案虽然多写了些代码但换来的是整个系统在并发安全性上的一劳永逸。第二排队机制要有但排队体验更要设计。一开始我做得比较简单用户排队时一直白屏很多人以为卡了就反复刷新反而加重了系统压力。后来加了实时进度百分比和预计等待时间用户主动刷新的比例明显降低系统压力也小了很多。第三风控规则一定要可灰度可回滚。我见过有团队把风控规则的变更做成发布上线结果误伤了一大片正常用户场面极其尴尬。正确做法是风控系统独立部署规则通过配置中心动态下发上线前先在小流量上验证确认无误再全量。第四监控告警要覆盖到业务层面。技术监控只能告诉你系统挂了业务监控才能告诉你卖票异常。我专门做了几个业务大盘开票成功率、座位释放率、支付转化率、风控拦截率。这几个指标任何一个出现异常波动都要第一时间有人跟进别等技术指标报警之后再从底层往上排查。演唱会购票系统的设计和实现本质上是一场工程能力和业务理解力的综合考验。技术方案选得再好如果不懂票务业务本身的约束做出来也是空中楼阁。希望这篇复盘能帮你少走一些弯路尤其是库存模型和并发控制这两块值得在动手写代码之前多花几天时间想清楚。