高并发秒杀系统实战:从库存扣减到订单幂等的购票平台设计 1. 项目核心痛点与需求拆解做演唱会购票系统最刺激的时刻不是写代码的阶段而是开票后那几分钟。做过这类项目的人都知道平时在测试环境里跑得稳稳当当的下单流程一到正式开票瞬间流量曲线直接拉满平时无人问津的订单接口突然成了全系统最烫手的地方。这个系统核心要解决的就是在看起来像秒杀一样的场景里把有限的门票库存卖出去同时保证不能超卖、不能重复下单、不能让别人用脚本把票全抢走。1.1 购票场景的特殊性到底在哪演唱会购票和普通商品下单最大的区别在于库存的强关联性。电商买衣服库存是个数字拍下后仓库拣货超卖了几件还能退款补偿但演唱会票不一样每一个座位都是唯一的物理资源同一场次同一个座位如果被卖给了两个人到现场就是事故。所以库存模型要考虑座位维度而不只是总量维度。另一个痛点是流量脉冲极其集中。我做过一个中型体育馆的票务项目开票前半小时系统很安静但一到点几万人同时点击抢票按钮QPS从几十瞬间冲到几千甚至上万。日常开发时遇到的性能问题在这个瞬间会被无限放大比如说数据库连接池不够用、下单逻辑里的锁竞争过强、第三方支付回调延迟等都会在这个时间窗口爆发。这个系统的目标用户其实有两类。第一类是普通购票用户他们需要顺畅的浏览体验、清晰的购票流程、稳定的支付链路第二类是主办方和运营人员他们需要能管理场次、座位、票价、退改签策略还需要能看清实时售票数据。所以系统设计一开始就必须把这两个视角的需求都考虑进去否则后面很容易做成一堆能下单但没法运营的半成品。1.2 用户端和管理端的核心功能边界用户端的核心链路是查看演出信息 - 选择场次和座位 - 提交订单 - 支付 - 获取电子票。这里每一个环节都藏着一堆细节比如查看演出信息时要处理图片和场次列表的缓存选择座位时要支持选座模式和盲盒模式两种常见玩法提交订单时要校验限购规则支付完成后要异步生成票码。管理端的核心链路是创建演出项目 - 配置场次和票价 - 导入/锁定座位 - 查看售票进度 - 处理退票 - 统计分析。这里最容易被忽略的是座位导入功能很多小型票务系统是用Excel模板批量导入座位图的如果没做格式校验和座位冲突检测上线后会出现两场演出共用同一批座位的低级错误。1.3 四个必须解决的核心技术问题这个项目真正要攻坚的技术点我总结下来是四个第一是超卖问题也就是并发请求下库存被扣成负数第二是重复下单问题用户疯狂点击或者支付回调重复通知导致同一个座位生成多笔订单第三是恶意抢票问题脚本绕过前端直接打接口把热门票源全部锁住第四是支付超时导致的库存死锁问题订单生成了但用户没支付座位被占用不能及时释放。这四个问题的共同点是它们都不是单靠业务逻辑能解决的必须在架构层面和技术选型层面做防护。后面我展开讲实现时会围绕这四个问题逐一给出设计方案和踩坑经验。2. 系统架构设计与技术选型先说结论如果从一个可以落地、可以复现、不至于过度设计的角度出发这个系统我推荐的技术栈是 Spring Boot 3.x Redis 7 RabbitMQ MySQL 8 Vue 3。这套组合在Java生态里非常成熟资料多招聘市场上也好找人手中小型票务平台完全够用。选型时我也对比过把核心链路改成Go的方案性能确实更好但考虑到团队维护成本和业务复杂程度Java这套组合在够用和好用之间平衡得更好。2.1 技术选型的三个关键考量选型时我主要从三个维度考量。第一是库存扣减的原子性要求。售票系统的高并发写入集中在库存扣减这一条链路上Redis 的原子操作Lua 脚本天然适合做这件事。用 MySQL 的悲观锁虽然也能保证不超卖但行锁竞争会让数据库在开票瞬间成为瓶颈。Redis 里把库存热点数据放到内存中操作配合异步落库能扛住的并发量级完全不一样。第二是削峰填谷的需求。下单请求打到业务层之后如果直接在请求线程里完成所有数据库操作数据库连接池很快就会满。用 RabbitMQ 把下单请求转成消息队列让消费者按稳定速率去处理订单落库和库存确认可以让系统在流量尖峰时不至于被打垮。第三是可扩展性的考虑。演唱会票务有很强的波峰波谷特征平时流量很平开票瞬间暴涨。这套架构里Redis 和消息队列都是可以横向扩容的中间件压测发现瓶颈后加节点就能提升处理能力不需要改业务代码。2.2 系统模块划分整个系统按职责分成五个模块。网关与接入层负责统一接收客户端请求做限流、鉴权、参数校验。我用的方案是在 Spring Cloud Gateway 之上做了一层简单的计数器限流同时配合 CDN 缓存静态资源减少源站压力。业务核心层包含演出信息管理、场次管理、座位管理、订单管理、支付对接等基础模块这些是业务逻辑的主体全部通过 Spring Boot 微服务或模块化单体实现。这里要注意如果项目规模不大别急着拆微服务模块化单体足够拆了反而增加运维负担。缓存层基于 Redis承载三块数据热门演出信息的缓存、场次库存的预扣数据、分布式锁的锁资源。这一层是抗住高并发的关键后面会详细讲。异步处理层用 RabbitMQ 处理三类消息下单落库消息、支付回调通知、库存释放延迟消息。消费者按业务优先级分开部署避免库存释放这类高优任务被慢消费挤占。数据层以 MySQL 为主库订单表、库存流水表、演出信息表都在这里。数据量大了之后订单表按场次ID分表是常见做法我后面会给出设计建议。2.3 层层拦截的核心设计思想这个系统的抗压思路可以概括成一句话能在前面挡住的请求绝不放进数据库。用户点击抢票的瞬间请求路径是 浏览器 - CDN - 网关 - 业务层 - 缓存 - 消息队列 - 数据库。我在每一层都设置了关卡CDN 挡掉静态资源的重复请求网关做限流和黑白名单业务层做参数校验和限购校验缓存层做库存预扣和幂等判断消息队列把真正的落库压力变成匀速串行。数据库只在最后一步被触及而且每次触及都是必须落盘的关键数据。这个设计有点像餐厅在高峰期排号门口迎宾网关先拦住一部分人前台缓存层快速确认有没有空位真正进厨房炒菜数据库落库的节奏是厨房自己控制的客人再多也不会把厨房挤爆。3. 核心流程与数据模型设计3.1 数据库表设计的关键字段数据库设计是整个系统最需要耐心的地方。我把核心表拆成四张演出信息表、场次表、座位表、订单表。这里只说重点字段。场次表要记录演出ID、场次时间、总票数、已售数量、状态。已售数量这个字段要特别小心它在高并发下更新频繁如果在数据库层面直接做 update很容易引起锁竞争。我的方案是这个字段只做展示用真正判断是否售罄以 Redis 缓存里的数字为准数据库字段通过异步方式最终更新。座位表要有演出场次ID、区域、排号、列号、座位状态。座位状态我用 0-空闲、1-锁定、2-已售 来表示。这是防超卖的最后一道保障数据库里的座位状态必须通过带条件的 update 来变更。订单表是我花心思最多的表。除了常见的订单号、用户ID、场次ID、座位ID、金额、状态字段之外还增加了一个idempotent_key字段用来做幂等控制。这个字段的取值是用户ID 场次ID 座位ID拼接后加密的哈希值数据库给他建唯一索引。这样即使同一用户并发提交十次请求数据库层面也只允许一条订单插入成功。3.2 库存扣减流程的三步走设计库存扣减不能一步到位我设计成三步预扣、确认、释放。预扣发生在用户点击抢票的瞬间。业务层先拿用户ID和场次信息去 Redis 执行一个 Lua 脚本这个脚本会做三件事检查场次剩余库存是否大于 0、检查该用户在该场次的购买次数是否超过限购数、用原子操作把剩余库存减 1。如果脚本执行成功返回一个预扣成功的标识后续进入下单流程如果失败直接返回票已抢光或者超过限购数量。确认发生在支付成功之后。支付回调通知业务层时系统把 Redis 里预扣的库存正式变更为已售状态然后异步更新 MySQL 座位表里的座位状态和场次已售数量。释放发生在支付超时或用户主动取消时。订单生成后通常有 10 到 15 分钟的支付有效期一旦超时系统需要把之前预扣的库存还回去。这一步我用 RabbitMQ 的延迟队列实现下单成功后就发一条延迟消息延迟时间等于支付有效期消费者收到消息后检查订单状态如果还是待支付就自动取消订单并恢复 Redis 里的库存。3.3 订单状态机的设计订单状态我设计了六个状态待支付、已支付、已取消、已退款、已失效、已入场。待支付到已支付靠支付回调驱动待支付超时或用户主动取消则进入已取消已支付后如果用户申请退票进入已退款已支付且按期到场则核销成已入场。这里有一个很容易踩坑的细节已取消和已失效在业务上都是订单作废但语义不同。已取消是用户主动的已失效是系统超时自动触发的。这两个状态如果不分开后续做退票统计和风控分析时不方便区分用户行为和系统行为所以我强烈建议拆开。状态流转的代码里要增加一个统一的订单状态变更服务所有状态的变更都走这个服务并且加分布式锁防止并发更新同一订单。4. 高并发关键技术实现4.1 基于 Redis 的库存预扣方案库存预扣是整个高并发方案的核心代码看起来不长但每一行都有讲究。我用的是一个 Lua 脚本通过 Redis 的EVAL命令原子执行。-- KEYS[1] 场次库存key例如 ticket:stock:1024 -- KEYS[2] 用户购买记录key例如 ticket:bought:1024 -- ARGV[1] 用户ID -- ARGV[2] 单用户限购数量 -- ARGV[3] 本次购买数量 local stock tonumber(redis.call(GET, KEYS[1])) local bought tonumber(redis.call(GET, KEYS[2]) or 0) if stock 0 then return -1 end if bought tonumber(ARGV[3]) tonumber(ARGV[2]) then return -2 end redis.call(DECRBY, KEYS[1], ARGV[3]) redis.call(INCRBY, KEYS[2], ARGV[3]) redis.call(EXPIRE, KEYS[2], 86400) return 1这个脚本一次调用就完成了库存判断、库存扣减、限购校验三个动作而且全部在 Redis 单线程模型里执行天然不带并发问题。很多刚做这个功能的人会先把库存取出来在 Java 代码里判断再写回去这个方案在并发下几乎必然超卖原因就是判断和扣减中间有别的请求插进来。把这个逻辑放进 Lua 脚本是根治超卖最直接的手段。调用脚本的 Java 代码里要注意把 Redis 操作封装好同时处理好返回值的语义public boolean tryDeductStock(Long sessionId, Long userId, int count) { String stockKey ticket:stock: sessionId; String boughtKey ticket:bought: sessionId; Long result redisTemplate.execute(deductStockScript, Arrays.asList(stockKey, boughtKey), userId.toString(), 2, String.valueOf(count)); if (result null) { return false; } return result 1L; }4.2 分布式锁要克制地使用我之前踩过一个坑系统里到处都是分布式锁下单锁、支付锁、退款锁结果开票时大量线程阻塞在等锁上吞吐量反而比不加锁还低。后来总结出一个经验能用 Lua 脚本原子操作解决的事情就不要动用分布式锁分布式锁只用在真正需要跨多个操作的场景里比如订单状态变更和库存恢复。以订单支付回调为例回调可能会因为网络重试收到多次如果每次都执行一次待支付转已支付的更新会出现重复更新甚至状态倒流的问题。处理方式是先拿订单号去 Redis 抢一把短租约的分布式锁拿到锁之后再去数据库查询订单当前状态只有状态是待支付时才执行更新。锁的过期时间设成 5 秒避免服务宕机导致死锁。另一个合理使用分布式锁的场景是库存预扣成功后的锁定座位操作。Redis 预扣成功后业务层需要在数据库把对应座位状态改成锁定这个操作如果不加锁两个用户同时选了相邻座位并各自预扣成功后去更新数据库会由于没有全局锁而把同一个座位更新两次。我在座位状态更新的 SQL 里加了条件UPDATE seat SET status 1 WHERE session_id ? AND seat_id ? AND status 0这条 SQL 通过影响行数来判断是否真的锁住了座位如果影响行数为 0说明座位已经被别人锁了业务层立即回滚 Redis 里的预扣库存。这一步是座位维度的最终防线比任何锁都可靠。4.3 消息队列削峰填谷的落地细节消息队列在这个系统里做了一件很实际的事情把下单请求和数据库落库解耦。用户点抢票后Redis 预扣库存成功此时业务层并不直接去数据库创建订单而是把订单创建请求发给 RabbitMQ 的订单队列然后立刻返回给前端正在排队或抢票成功的提示。消费者的执行节奏是匀速的即使瞬间进来一万个下单请求落到数据库的处理速率也可以控制在安全范围内。// 发送下单消息 OrderMessage msg new OrderMessage(); msg.setOrderId(orderId); msg.setUserId(userId); msg.setSessionId(sessionId); msg.setSeatIds(seatIds); msg.setAmount(totalAmount); rabbitTemplate.convertAndSend(order.direct.exchange, order.create, msg);消费者端要注意消息的幂等消费。RabbitMQ 在极端情况下可能重复投递消息所以消费者里处理订单创建前要先查一下订单表里是否已经存在这个订单ID存在就直接跳过。我见过有人漏掉这步结果同一笔订单在数据库里插了两条虽然订单号不同但座位相同最后的对账阶段头疼得要命。削峰的同时还要注意队列积压的监控。开票高峰期订单队列积压几千条消息是正常的但如果积压持续超过几分钟不减就说明消费者处理速度跟不上。这种时候加消费者实例是最快的办法但要注意消费者并发数不要超过数据库连接池上限否则垮点会从队列转移到数据库。4.4 限流和接口幂等的实践限流方面我在网关层对抢票接口做了令牌桶限流每个用户每秒最多放行两个请求超出的直接返回操作过于频繁。这个限制对正常用户完全无感却能拦掉大量脚本的并发刷票。令牌桶实现的代码很成熟Google Guava 的 RateLimiter 就能用部署在网关里按用户维度维护令牌桶即可。接口幂等这块除了前面提到的订单表idempotent_key唯一索引之外前端的按钮也要做防重复提交。但要注意前端防重复只能提升体验后端幂等才是根本保障。我再强调一次任何涉及创建订单的接口无论如何都必须做幂等校验因为脚本可以直接绕过前端。另外防恶意抢票还需要做一层风控校验。常见手段是下单前强制校验手机验证码、同一设备ID或IP地址在短时间内的下单次数限制、实名信息与购票账号的一致性校验。这些逻辑不需要做得太复杂能用简单规则挡住多数黄牛脚本就够了。5. 压测报告与典型问题排查实录5.1 压测场景和数据表现我搭建的压测环境是三台 4C8G 的云服务器一台部署 Nginx 和应用一台部署 Redis一台部署 MySQL 和 RabbitMQ。用 JMeter 模拟 5000 个并发用户同时抢购一个 3000 座的场次压测结果在可接受范围内订单接口平均响应时间 320ms99 分位响应时间 780ms最终成功创建的订单数 2987 张数据库无超卖记录Redis 缓存中库存从 3000 扣减到 13与数据库最终数据完全一致。但压测也暴露了两个问题。第一个是部分请求超时后前端显示抢票失败但 Redis 里的库存已经扣了原因是预扣成功后业务层在生成订单时因为数据库连接池等待超时订单创建失败后没有回滚预扣的库存。这个问题的修复方案是把预扣和下单改成完全异步预扣成功先把预扣记录写入一个独立的 Redis 列表消费者异步读取列表后逐个创建订单创建成功或失败都有明确的日志。第二个问题是限购校验的漏洞。压测时发现同一个用户ID可以用多个设备同时请求Redis 里的购买记录校验只统计了当前场次的预扣数量没有统计已支付订单数量。后来我在校验逻辑里增加了从数据库查询已支付订单数做兜底才彻底堵住这个口子。5.2 典型问题一缓存击穿导致数据库被打爆开票时热门场次的演出信息是高频读的数据我把它缓存进了 Redis。但开票瞬间如果缓存恰好过期大量请求同时去数据库查演出信息就会出现缓存击穿。第一次压测时数据库 CPU 直接拉满接口响应时间暴涨到十几秒。解决方案是给热门缓存加永久缓存 定时刷新的策略不设置过期时间而是由后台任务每 5 分钟刷新一次数据。如果数据有变化比如票价调整、场次取消通过管理端接口主动删除缓存下一次请求时自动回源更新。这样从根上避免了过期时间一到、热点 key 失效的问题。5.3 典型问题二支付回调重复通知导致重复处理支付平台为了保证回调可达会按一定策略重复通知多次。有一次线上出现订单状态从已支付变成已退款而这笔订单用户根本没发起退款。排查后发现支付回调重复通知触发了退款逻辑前后两次通知分别走了不同的处理分支。修复方式是在支付回调处理入口加了一个状态机校验无论收到什么回调先查询订单当前状态只有当前状态允许的目标状态转换才执行更新。比如订单是已支付状态时即使收到重复的支付成功回调也不执行任何操作只有收到退款成功回调时才允许切换到已退款。这个校验逻辑虽然简单却能挡住绝大多数异常状态流转。5.4 典型问题三库存数据核对不平系统上线初期我发现 Redis 里的剩余库存和 MySQL 里的已售数量偶尔对不上。排查了很久最终定位到原因用户在支付超时后订单被自动取消库存释放消息发出去了但消费者在处理时因为网络抖动消费失败消息进了死信队列库存就没有被恢复。从那之后我做了两件事。第一给库存释放任务加了一个兜底定时任务每 5 分钟扫描一次超时未支付且在有效期内没有释放库存的订单强制释放第二建立了每日库存对账任务凌晨对比 Redis 保存的预扣记录、MySQL 的座位状态和订单状态发现差异就生成告警通知。这个对账流程是我强烈建议所有做票务系统的人都要有的它不会增加用户可见的功能但能把很多隐蔽的数据问题扼杀在萌芽状态。5.5 给新手的避坑清单最后整理一份我自己的避坑清单。第一别把库存扣减放在 Java 业务代码里做一定要用 Redis 的原子操作或者数据库的乐观锁靠代码层面的 synchronized 在分布式环境下根本不可靠第二分布式锁要控制粒度能用更细的粒度就不要锁整个订单流程第三所有异步消费者必须做幂等处理第四上线前做好压测而且压测要模拟真实抢票的流量曲线不是匀速压要模拟瞬间尖峰第五预留对账任务和监控告警的接口数据一致性比性能更值得投入精力。