ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

高并发抢票系统架构实战:从Redis扣减到限流排队

高并发抢票系统架构实战:从Redis扣减到限流排队

每次热门演出开票,都是一场真实的技术战役。我参与过多次票务平台的峰值保障,最直观的感受是:抢票这种场景,没做过的人以为“不就卖几万张票嘛”,做过的人才知道它比很多大促还难——因为流量在开售那一刻完全集中,百万级请求瞬间压到同一个场次、同一个票档上。这篇文章不讨论任何外挂、脚本或个人抢票技巧,只从系统架构师视角拆解这类票务系统怎么扛住秒级洪峰,怎么设计库存扣减、限流排队、缓存治理和性能优化。适合后端开发、架构师、运维同学参考,也能帮普通读者理解为什么一到开票就转圈。

这类系统的难点不在于某一个技术点,而在于流量模型本身反常识。平时系统平稳运行,开票那几秒突然涌入大量请求,几乎所有环节都面临极限压力。下面我按照业务特征、核心链路、性能调优、故障排查、自动化保障这条线,完整拆一遍。

1. 抢票场景的业务特征与架构设计初衷

1.1 抢票为什么难:流量尖峰与热点集中

票务系统的压力不是持续高QPS,而是开售瞬间的尖峰。比如某场热门演出,开售票量只有1万张,但同时涌入的用户可能几十万甚至上百万。开票后的第一个10秒,请求量可以占到全天请求量的80%以上。更麻烦的是,这些流量全部打在同一个场次、同一个票档上,属于极度的热点数据,和常规电商那种广泛分散在不同商品SKU上的流量模型完全不同。

我经常用地铁高峰做类比:平时系统像平峰期的地铁,座位充足,大家有序上车;开票瞬间,所有用户都挤在同一节车厢门口,都想第一个冲进去。如果系统不做提前设计,数据库连接池会在几秒内被打满,慢SQL堆积成山,整个服务雪崩式宕机。

抢票场景还有一个明显特征:读多写少,但写操作全部集中在热点数据上。大量用户都在看场次信息、票价、余票数量,读请求可能是写请求的几十倍。高频的余票查询如果直接打到数据库,再牛的机器也扛不住。所以必须有一整套缓存策略,把读流量尽量挡在存储层之前。

1.2 核心需求里藏着哪些硬指标

抛开业务细节,抢票场景要守住几个核心指标,每一个都是技术上的硬约束。

第一是不超卖。票务系统是强库存约束场景,卖1000张就绝不能出现第1001个用户成功拿到票。这里面的难点不只是扣库存,还包括在高并发下保证扣减操作的原子性,同时防止同一个用户重复下单造成的数据污染。

第二是削峰填谷。系统在生产环境中的处理能力是有上限的,设计目标不是无限提升上限,而是让瞬间洪峰变成可平滑处理的流量。排队、限流、异步化都是为了实现这个目标。否则,即便把机器翻三倍,数据库和下游依赖依然是瓶颈。

第三是防刷与公平性。从系统角度来说,我们得识别并拦截机器化、异常化的请求,比如同IP高频请求、无规律的批量行为,这些要在框架层做防护。这里要强调一句:我们讨论的是系统如何去识别和拦截异常请求,而不是教人如何绕过防护。

第四是可观测和可回滚。抢票期间一旦出新问题,必须在秒级发现、分钟级定位、快速降级回滚。技术方案再完美,如果出故障后没有快速的恢复手段,活动一样会崩。

1.3 整体架构怎么分层

我在设计这类系统时,会把它分成四层:接入层、排队控制层、业务订单层、数据存储层。每一层各管一段,互不越界。

接入层解决的是流量入口的高并发接入和基础防御,比如网关服务,负责限流、协议过滤、风控识别。这层必须无状态,可以随意横向扩容。排队控制层是抢票系统最特别的一层。因为业务处理能力有限,不能放所有请求进入下一层,所以要先把用户挡在队列里,通过发令牌的方式控制进入系统的速率。用户前端看到“前方排队××人”,本质就是在等待这一层发放令牌。

业务订单层负责真正的库存扣减、订单生成、支付链接创建,所有核心业务逻辑都在这一层。这层同样要做到无状态,通过水平扩容提升处理能力。数据存储层包含缓存、数据库和消息队列。Redis用来扛热点库存查询和原子扣减,MySQL存储订单和事务数据,消息队列负责异步削峰和系统解耦。

高并发系统不是把每层都做得飞快,而是要合理地把压力隔离开。排队层可以承接大流量,就算队列积压到几万人,也不至于压垮订单层。如果所有请求都直接打到业务层,没有缓冲,系统稳定性就完全取决于机器数量,这既不经济也不可靠。

2. 库存扣减与订单链路的核心实现

2.1 超卖问题的根因与Redis Lua扣减方案

超卖是票务系统的头号技术敌人,根源在于“先查再扣”的非原子操作。如果两个请求同时查出库存还剩1,然后都执行扣减,那数据库里就会出现两条有效订单对应同一张票。

最朴素的方案是在数据库层面扣库存:UPDATE ticket SET stock = stock - 1 WHERE id = ? AND stock > 0。这段SQL利用数据库行锁和条件判断,确实不会超卖。但问题也很明显:1万张票,高并发下1万个UPDATE同时挤在同一行记录上,数据库行锁等待、连接池耗尽,系统响应时间急剧拉长,用户体验非常差。

我采取的是Redis预扣加Lua脚本落库的方案。先用Redis缓存库存,再用Lua脚本保证扣减的原子性。核心脚本大概长这样:

local stock = redis.call('get', KEYS[1]) if not stock then return -2 end if tonumber(stock) <= 0 then return -1 end redis.call('decrby', KEYS[1], ARGV[1]) return 1

这个脚本在Redis单线程模型下执行,整个操作不会被其他命令打断,所以不会出现两个请求同时扣到同一张票。Lua脚本的好处不只是原子性,它还能把业务条件判断也放进同一个事务性操作里。比如用户限购的判断、场次维度的校验,都可以在扣减之前一并完成。

有了预扣,就要有回滚机制。用户下单后如果一直不支付,不能让他把票锁死。我的方案是:超过支付时限标记订单过期,异步把库存加回。这里最关键的是回滚幂等性——消息重复投递时不能把库存加两次。所以每一个订单都对应一条库存流水记录,回滚时按订单号加锁,同一个订单的库存回滚只允许执行一次。

为什么不直接用Redis的DECR命令?因为DECR虽然原子,但缺少业务条件判断能力。余票扣减看起来只是数字减一,实际上还要带上限购数量、场次状态、用户黑名单这些规则。Lua脚本把这些逻辑收拢到一次Redis操作中,既保证了性能,又保证了正确性。

2.2 订单异步化:消息队列削峰与幂等设计

库存扣减成功后,紧接着是订单创建。如果扣库存和创建订单都同步完成,每个请求的RT会很长,系统吞吐量上不去。我的实践是:库存扣减在Redis里先完成,拿到扣减成功的凭证后,把订单创建任务丢进消息队列,后端消费者异步落库。用户看到的体验是“订单正在创建中”,几秒后刷新就能看到订单号。

这个设计最需要解决的是消息可靠性和消费幂等性。消息投递可能成功但响应丢失,这时消费者可能会重复消费。我的方案是:每次消费前先按订单号查订单状态,如果订单已经存在就直接返回,不再重复创建。同时,订单表里加唯一订单号索引,靠数据库的唯一约束做兜底。这一步看起来很简单,但少了它,异步链路的故障率至少翻倍。

订单状态机也非常重要。我一般设计成几组状态:已锁定、待支付、已支付、已取消、已过期。每个状态必须有明确的触发条件和流转方向,不能出现跳状态或者乱回退的情况。比如“已支付”之后绝对不能再回到“待支付”,否则对账就会出现大问题。

我还会考虑消费失败的情况。消息消费失败后进入重试队列,连续重试超过一定次数就转入死信队列,由人工或者补偿任务处理。这套机制保证订单创建不会因为一次网络抖动就永久丢失。我自己踩过一次坑:当时只做了同步创建订单的接口,消息积压时用户刷新看不到订单,大量客诉涌进来。后来把查询和创建彻底分离,用户在任何时刻都能查到“创建中”的状态,问题才解决。

2.3 用户维度防重:令牌与分布式锁

抢票场景还有一个高频问题:用户手抖狂点“立即购买”,或者开了多个页面同时提交,结果同一个用户被下出多张重复订单。这个问题不能只靠数据库去重,因为请求可能在到达订单服务之前就已经产生多条待处理任务。

我在用户维度和场次维度加锁,用Redis的SET key value NX EX 5来实现5秒内的互斥。同一用户同一场次,5秒内只能有一次有效提交,多余请求直接返回“正在处理中”。这个分布式锁要特别注意锁的过期时间设置,太短会导致并发穿透,太长又可能拖慢正常请求。我的经验值是3到5秒,能在绝大多数场景覆盖一次完整的下单交互。

更严谨的方案是引入幂等令牌。用户点击购买时,服务端下发一个一次性令牌;提交订单时必须带上令牌,同一个令牌只能成功一次。这个令牌在服务端有有效期,过期自动失效。它和分布式锁配合使用,能挡掉绝大部分重复请求。

还有限购逻辑。一人限购N张的限制必须在扣库存之前判断,而且要在一个原子操作内完成。我习惯把用户已购数量也写进Lua脚本里判断,超过限购直接拒绝。如果先查询再扣减,中间就有并发间隙,两个请求可能各自查到的已购数量都是0,一起放行,就破限购了。原子操作把这些判断点收在一起,逻辑上不留缝隙。

在设计这一块时,我强烈建议先画一张完整的请求时序图,把“点击购买、令牌校验、限购校验、库存扣减、异步下单、状态同步”这几个环节和组件之间的调用关系理清楚。时序图理清了,代码实现才不容易出漏洞。链路越复杂,越要在动手写代码之前把边界情况想清楚。

3. 性能优化实操:从缓存到限流

3.1 热key治理与多级缓存设计

票务系统最难处理的就是热key。热门场次的库存key会被超高频率读取,单个Redis实例的CPU很快被打满。我之前踩过一个很深的坑:刚开始把库存直接放在一个key里,活动一开那个节点的CPU直接顶到100%,其他节点全闲着,请求还是大面积超时。原因很简单,所有读都集中在单点上,再怎么扩容Redis集群都没用。

解决方案是给热key做分片打散。把同一个场次的库存拆分到多个子key上,比如key-001到key-010,每个子key存一部分票。请求进入时,用用户ID对分片数取模,路由到对应的子key上。库存扣减时也按同样的规则路由,保证同一个用户每次都打在同一个子key上,不产生跨片一致性问题。这样一来,单个key的压力就被均摊到多个Redis节点上。

光靠Redis还不够,还得叠加本地缓存。业务服务里可以放一层进程内缓存,把场次信息、余票展示这些读多写少的数据放在本地,设置5到10秒过期。损失这么一点实时性,换来的是数量级提升的读性能。当然,本地缓存只解决读流量,所有的扣减操作还是要走Redis和数据库,不能被本地缓存干扰。

缓存更新策略同样要小心。实时库存变更时,直接删掉对应的Redis key,下一次请求读不到就从数据库加载并回填。这个回填过程必须加“缓存重建锁”,只允许一个请求真正访问数据库,其他请求等待第一个请求回填完成。否则,缓存失效瞬间所有请求一起打到数据库,生产环境很容易被打挂。

3.2 线程池与连接池怎么调才不翻车

很多系统崩溃不是代码逻辑的问题,而是线程池和连接池参数配置不合理。数据库连接池有个反直觉的结论:最大连接数不是越大越好。每个连接都要占用数据库的服务资源,连接数超过数据库服务能力后,响应时间不降反升。我在做容量评估时,一般先压测单数据库实例能稳定处理的连接数和查询吞吐,再反推连接池的上限。比如单实例稳定支撑200个连接,服务端的连接池最大连接就配150到180,留出缓冲。

线程池同样要做隔离。接收请求的线程池、调用Redis的线程池、投递消息的线程池必须分开管理。不要让一个慢接口拖垮所有接口,这是线程池隔离的核心价值。我的做法是:在网关层给核心链路单独开线程池,非核心任务比如查明细、发通知,用另外的线程池,两者互不干扰。这样即使通知服务变慢,也不会影响下单主流程。

调优建议是:先用默认参数跑全链路压测,观察线程池活跃度、等待队列深度、拒绝策略触发次数,再逐步调整参数。连接池也一样,不要拍脑袋直接改配置。我自己吃过一次亏:把最大连接数从200调到400,以为性能翻倍,结果数据库被打到CPU满载,整体吞吐量反而下降了30%。后来才明白,连接池参数必须结合下游的实际承载能力来定。

3.3 限流与排队:流量控制的取舍

抢票系统不能来多少请求就处理多少,必须在上游做流量整形。我常用的限流算法有三种,各有适用场景。

固定窗口算法是每秒最多100个请求,超过直接拒绝。实现最简单,但存在临界突变问题:窗口切换的瞬间可能放过两倍流量。更适合对精确性要求不高的接口。滑动窗口在时间轴上细分小窗口,能平滑流量峰值,适合接口级限流。令牌桶算法允许一定程度的突发流量,它以固定速率往桶里放令牌,请求拿到令牌才能执行。Guava的RateLimiter可以做单机版,分布式场景就用Redis实现。

排队机制是票务系统里最有特色的设计。用户点“购买”后先进入排队状态,前端显示“前方排队××人”。后端用一个队列服务统一管理用户的排队凭证,只有队列发放了令牌,用户才能发起真正的下单请求。这样一来,无论瞬时来了多少流量,真正进入订单系统的请求量都被控制在一个安全范围内。

限流阈值设置需要特别讲究。设置低了会误伤正常用户,设置高了系统扛不住。我的方法是:先通过压测摸清系统的真实处理上限,生产配置再按上限的70%到80%下发,同时保留动态调整通道。一旦发现系统还能扛更高流量,可以实时调高限流阈值。无论用什么算法,核心思路都是一样的:宁可让用户多等几秒,也不能让系统被击穿。

3.4 压测与容量规划的真实经验

压测是抢票系统上线前必须做的一步,这里说的不是单接口压测,而是全链路压测。很多系统单个接口压测数据非常漂亮,到活动当天全链路一跑就崩,原因就是没有模拟真实流量的组成。

压测时我构建的流量模型是:高比例的余票查询请求、中等比例的排队轮询请求、低比例的下单请求。下单请求里再区分有票和无票两种情况。这样的混合流量模型才最接近线上真实情况。

压测过程中重点盯四个指标:QPS曲线、RT的P99、错误率、资源利用率。系统能承受的峰值流量以P99响应时间开始明显劣化时的拐点为准,而不是看平均响应时间。很多系统平均耗时看着还行,但P99已经超时,用户体感就是页面一直转圈。

容量规划的经验值是:系统容量要留出至少30%到50%的冗余。比如压测峰值是每秒2万QPS,生产环境的开关阈值就控制在1.2万到1.4万左右,剩下的部分留给异常流量和故障场景。除了压测,我还强烈建议做故障演练,模拟Redis宕机、数据库主库抖动、MQ积压三种常见故障,观察降级逻辑是否真正生效。抢票链路上任何一个环节挂掉,都可能引发连锁反应,提前演练比临时救火强一百倍。

4. 常见故障与排查技巧实录

4.1 超卖事故的复现和根因分析

有一次内测模拟,我故意去掉Lua脚本的条件判断,改成“先查库存再扣减”的普通逻辑。流量一上来,MySQL里同一秒内有大量请求都查询到了余票为1,然后各自执行扣减,最终订单数远超库存数。这个案例完美演示了超卖产生的全过程。

排查时我直接用了这条SQL:

SELECT ticket_id, COUNT(*) FROM order_table WHERE status = 'paid' GROUP BY ticket_id;

把每个票档的有效订单数和库存数做对比,超卖事实立刻暴露。这也说明一个判断标准:不管中间用了多少层缓存和异步,最终都以数据库中的有效订单数为准。任何一环没有做原子性保护,最终对账时都会现形。抢票系统的数据正确性,最终要回到数据库层面来校验。

4.2 缓存同时失效引发的雪崩

有一次架构升级,我把所有缓存过期时间设置成了同一个值。活动进行中,某个场次刚卖完一批,结果所有缓存key在同一秒过期,一瞬间全部请求穿透到数据库。数据库CPU直接打满,整个系统停顿了好几分钟。这种事故比高并发本身更可怕,因为它发生在你以为系统还很健康的时候。

解决办法主要有三条:一是过期时间加随机扰动,不同key设置不同过期时间,避免同时过期;二是在请求数据库之前加分布式锁,只允许一个请求回源重建缓存,其他请求等待;三是对重建缓存做限流,超过阈值的请求先返回旧值或者默认值。这个问题的通用启示是:越是做性能优化,越要防止某一个环节的失效引发连锁雪崩。缓存过期时间这种看起来很小的参数,在热点场景下可能成为压垮系统的最后一根稻草。

4.3 限流误伤正常用户

限流失误是高频事故。有一次压测显示系统能扛每秒1万请求,我就把限流阈值设在了8000。结果活动当天,前端页面的轮询动作把请求量顶得非常高,真正的购买请求反而被限流拒掉了一大堆,用户反馈全是“点不了购买”“一直报错”。

后来我改成按请求路径和用户行为做分流限流。轮询查询请求走低优先级,购买下单走高优先级;再结合来源IP、账号等级做动态权重。这样一来,正常用户即使遇到流量高峰,也只是看到排队页面,不会直接失败。还有一个细节是给白名单用户开专用通道,内部测试和客服需要访问系统时不会被限流挡住。

4.4 订单状态不一致的补偿方案

异步化的代价就是状态不一致。最常见的问题是用户支付成功,但回调处理失败,订单一直停在待支付状态。排查时我先看回调日志,确认回调是否到达,再查支付结果对账单,判断是回调丢失还是回调处理异常。

针对这类问题,我制定了定时补偿机制。对超过30秒仍未完成状态翻转但支付记录存在的订单,通过补偿任务强制推进状态机。这里的关键是:状态机的所有流转都应该是可补偿的。设计之初就要留好重置、过期、强制关闭等边界操作接口,而不是等出问题再改代码。没有补偿机制的异步系统,故障只是时间问题。

4.5 活动结束后的数据核对与复盘

活动结束后我不会直接收工,而是做一次完整复盘。核对出票数和库存消耗数,确认无超卖无漏卖;核对支付成功订单数和收款记录,确保每一笔支付都有对应的订单;再分析系统各节点的负载曲线,和压测预估做对比。

这些数据积累下来,会形成我自己的容量评估基准。下一次再设计类似的系统时,所有参数都有历史数据做支撑。做技术复盘的意义不在于追责,而在于把每次活动的经验变成可复用的资产。做得多了,你对流量模型的判断会越来越准。

5. 自动化能力建设:压测、监控与自愈

5.1 全链路监控与告警

高并发系统的稳定性不能靠人盯,必须靠监控体系自动发现问题。我做全链路监控时会覆盖三个层面:基础设施层看CPU、内存、磁盘、网络;应用层看QPS、RT、错误率、线程池活跃度;业务层看订单创建量、支付成功率、库存消耗进度。

告警规则要避免两条极端:告警太多变成“狼来了”,告警太少关键问题被淹没。我的做法是区分P0和P1级别。P0告警直接触发电话和短信,比如订单成功率骤降、库存扣减异常、数据库连接耗尽。P1告警只推送工作群,比如某接口P99超过阈值、MQ消费积压超过5000条。告警阈值要基于正常业务波动的基线来定,不能随意拍脑袋。

5.2 自动化故障演练与回归压测

每次发版变动都可能引入新的稳定性问题,所以我把故障演练做成了自动化流程。通过演练平台一键注入故障,比如模拟Redis主节点宕机、数据库主从延迟、消息队列积压,然后自动检查系统降级逻辑是否按预期执行。

回归压测同样要自动化。每次代码变更后,跑一轮核心链路的自动化压测,对比之前记录的基准数据。如果P99明显劣化或者吞吐量下降,测试直接失败,阻止合并。这看起来增加了流程成本,但比起上线后才发现性能回退,这点成本微不足道。

5.3 灰度发布与快速回滚

票务系统上线必须支持灰度发布。先把流量切一小部分到新版本,比如5%,观察核心指标稳定后再逐步放大比例。如果发现异常,随时把流量切回旧版本。

快速回滚比上线的优先级更高。每次发布前我都会确认旧版本的镜像和配置还在,并且回滚脚本经过了演练。因为抢票期间时间窗口很小,如果回滚流程不顺畅,哪怕监控发现了问题,也只能看着事故扩大。自动化发布平台的核心价值就在这里:让你随时具备“一键还原”的能力,而不是让系统上线后只能硬撑着。

就我个人经验来说,抢票这类高并发系统最考验人的不是某一项技术栈,而是对整个链路的全局把控能力。你必须清楚流量从哪里进来、在哪里积压、哪个环节会最先被击穿、怎么靠缓存和队列把压力层层消化掉。我参与过几个类似的峰值系统,每次都发现真正逼出系统问题的,反而是那些最不起眼的环节——缓存过期时间没加随机值、连接池参数和压测模型不匹配、限流阈值把正常用户也挡在了门外。这些小坑,光看架构图是完全看不出来的,必须靠压测和演练把它们逼出来。

最后再分享一个习惯:每次活动结束后,我会把性能数据、故障记录、参数调整过程整理成一页纸存档。下次再碰同类型的系统,先翻旧账再定参数,能少走很多弯路。如果你正准备做票务或者任何强峰值场景的系统,别急着堆机器,先把流量模型、故障假设、降级预案想清楚,再动手写代码。系统能不能扛住峰值,在架构设计阶段其实已经决定了七八成。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进