12306高并发架构拆解:负载均衡与秒杀库存系统设计

发布时间:2026/9/9 10:23:37
12306高并发架构拆解:负载均衡与秒杀库存系统设计 1. 场景为什么12306一直是高并发架构研究的“硬骨头”要说哪套系统最能考验高QPS架构设计我第一个想到的就是12306。每年春运、节假日几亿人同时盯着同一批车次余票一放出来就是“秒没”。对技术人来说它就是一个每年固定举办的极限压测现场查询请求量暴涨订单提交集中在同一瞬间库存只有一个且不可超卖同时还要保证整站不挂。做架构的人如果不研究它等于做负载均衡的没看过真实的海啸流量长什么样。这篇内容我想把自己对12306这套系统背后两个核心模块的拆解记录下来——负载均衡和秒杀库存系统。重点关注它们在超大规模请求下的设计思路、层层递进的方案以及我在类似高并发项目里踩过的一些坑。无论你是正在准备系统架构设计师考试还是在做抢购、秒杀、活动类业务这篇内容应该都能给你不少参考。先说清楚一个前提我不会去复述12306内部具体用了哪台机器、哪个中间件那些信息外界也拿不到。这篇内容是基于公开技术分享、行业内类似场景的普遍做法用工程视角推演“这个体量的系统应该怎么搭”。毕竟能扛住春运购票冲击的架构很多设计细节都值得慢慢品。1.1 这不是普通的秒杀场景很多人把12306类比成电商秒杀我一开始也这么觉得后来发现它比秒杀复杂得多。电商秒杀通常是一个商品固定库存用户先抢购到下单资格再付款库存锁定逻辑相对简单。12306面对的是多车次、多日期、多席别、多区间用户可以跨站买票热门线路还有多个中间站。每一个车次、每一个日期、每一个席别都是一个独立的库存维度。更麻烦的是它不是“抢完就结束”还要支持退票、改签、候补这些动作都会把库存动态释放回来。所以它的高QPS不只是读高写也高而且写操作的维度特别多。早期很多架构师拿着电商秒杀的方案去套结果发现根本行不通。后来整个系统转向了“查询与交易分离”的思路——查询走缓存和静态化交易走削峰填谷的异步链路这才是整个架构的关键转折点。1.2 从业务指标反推技术指标我们估一下量级。春运期间12306高峰期一天的页面浏览量能达到千亿级别峰值每秒请求量百万级订单提交的瞬时峰值也能到每秒几十万笔。这个数字背后意味着什么假设单台Nginx可以扛5万QPS那在最外层入口就需要至少20台以上做流量接入假设每笔订单需要查一次余票、锁一次库存、写一次订单秒杀瞬间的写QPS几十万对数据库来说是毁灭性的关键车次的余票可能是同一个热点数据这个数据的访问热度会高到单点无法承受。所以12306的架构核心不是某一个中间件有多牛而是每一层都在想尽办法把流量压缩下来把写操作延后把热点打散。理解了这一点再看负载均衡和库存系统思路就会清晰很多。2. 负载均衡分层从DNS到应用网关每一层都在解决不同问题负载均衡不是一台Nginx就完事它是一个分层体系。这是很多刚接触高并发的人最容易忽略的点——上来就在一台Nginx后面挂一堆应用节点然后说“我做了负载均衡”压测一上就发现Nginx成了瓶颈。2.1 各层负载均衡的职责划分我习惯把负载均衡分成四层DNS层、四层LB、七层LB、应用网关层。每一层解决的流量问题不一样不能互相替代。层级代表技术核心职责典型规模DNS层DNS轮询、智能DNS用户就近接入机房/区域流量分配多机房容灾四层LBLVS、F5IP端口转发性能极高不解析HTTP单机百万并发七层LBNginx、OpenResty按URL/Host路由做HTTPS卸载、限流、缓存单机数万到十万QPS应用网关Spring Cloud Gateway、自研网关业务路由、鉴权、风控、灰度、熔断按服务拆分在12306这种量级下DNS先把全国用户分到不同区域的机房到机房后LVS这样的四层负载均衡把大规模流量分发到后面的Nginx集群Nginx做七层处理按业务把请求路由到不同的后端服务集群再往下的微服务框架里像OpenFeign这类组件还会做服务间的客户端负载均衡。很多人问“OpenFeign内部集成了负载均衡吗”答案是集成了它可以通过Spring Cloud LoadBalancer选择具体服务实例这是最内层的负载均衡。2.2 七层负载均衡的实践细节Nginx在高并发场景里最关键的配置不是默认参数而是worker_processes、worker_connections、keepalive这几个。我见过很多团队直接拿默认配置上生产压测QPS只有理论值的零头。worker_processes auto; worker_rlimit_nofile 65535; events { use epoll; worker_connections 65535; } http { upstream backend_orders { least_conn; server 10.0.0.1:8080 max_fails3 fail_timeout30s; server 10.0.0.2:8080 max_fails3 fail_timeout30s; keepalive 64; } server { listen 443 ssl; # SSL相关配置省略 location /order/submit { limit_req zoneorder_zone burst10 nodelay; proxy_pass http://backend_orders; proxy_next_upstream error timeout http_500; } } }几个我实际测试下来的经验worker_processes auto会按CPU核心数启动但并非越大越好物理机8核以上时收益递减关键是减少进程切换。worker_connections要配合系统的ulimit同步调大否则连接数会被文件描述符卡死典型的表象就是Nginx报了too many open files。upstream里加上keepalive后Nginx到后端的连接会被复用这个对性能提升非常明显。least_conn算法适合后端请求处理时间差异大的场景如果后端能力都差不多轮询加权重就够用。2.3 一致性哈希与缓存亲和性12306这类场景有个典型问题同一个车次的余票查询请求会反复打过来。如果Nginx用普通轮询同一个用户在不同请求中可能落到不同后端节点导致每个节点的本地缓存命中率都不高。业界常用的解法是使用一致性哈希按用户ID或者车次ID做hash让同一类请求尽量落在同一组后端节点上。Nginx里可以通过hash $arg_userId consistent;实现一致性哈希。节点变更时一致性哈希只会影响少量key的映射不会像普通hash那样全部失效。但要注意热点key还是热点就算哈希到了同一节点单个节点也可能被打到CPU满。所以真实的架构里还要配合“热点key本地缓存二级缓存”来兜底。3. 余票库存系统秒杀的核心是“不能超卖”库存系统是12306架构里最敏感的一环。超卖在电商里最多是发不出货在铁路售票里就是系统级事故绝对不可接受。要理解库存系统怎么设计先要理解库存扣减这条链路上的每个动作。3.1 为什么不能用数据库行锁直接扣库存最简单粗暴的做法是在数据库里对余票字段做行锁UPDATE ticket_stock SET remaining remaining - 1 WHERE stock_id ? AND remaining 0;这句话在单行记录上依靠数据库的行锁保证原子性逻辑上确实是正确的。但在秒杀场景下会出问题所有请求都集中在这同一行上行锁竞争非常激烈InnoDB的行锁本质上是串行的几十万QPS打过来数据库连接池瞬间耗尽CPU飙升数据库直接被打挂。所以高并发秒杀库存的经典思路是把扣减库存这一步前置到缓存层用Redis这样的内存工具完成原子扣减数据库只负责异步落账和最终一致性。3.2 RedisLua实现原子扣减Redis单个实例的QPS可以到十万级而且Lua脚本可以保证多条命令的原子性。这是目前秒杀系统里非常主流的做法。核心逻辑是先在Redis里维护每个车次/日期的余票key比如stock:ticket_20240501_G1001用户提交订单时用Lua脚本判断余票是否充足充足则扣减并返回成功否则返回失败。-- KEYS[1] 余票key例如 stock:20240501:G1001 -- ARGV[1] 扣减数量默认1 local remaining redis.call(GET, KEYS[1]) if not remaining then return -1 -- key不存在 end remaining tonumber(remaining) if remaining tonumber(ARGV[1]) then return -2 -- 余票不足 end redis.call(DECRBY, KEYS[1], ARGV[1]) return remaining - tonumber(ARGV[1])这段脚本有几个细节值得说必须先GET再DECRBY而且整个操作都在Lua里Redis执行Lua脚本时是单线程的不会有并发问题这是原子扣减的保障。每次扣减前必须判断remaining是否充足不能先扣了再说否则就会出现负库存。key不存在时返回-1由上层决定是初始化库存还是拒绝请求。返回值是剩余票数可以直接用来做风控和文案提示比如“仅剩X张”。我用一个实际压测对比过数据库行锁方案在单表单key场景下稳定QPS大概几百到一两千RedisLua方案在同规格机器上能到数万QPS中间差了不止一个数量级。3.3 热点key拆分与库存分片Redis单key虽然快但热门车次就是一个超级热点。所有抢这趟车的人都在打同一个key不管Redis多快单实例CPU和网络IO都会到瓶颈。解决办法是把热点库存分片。比如某趟车次总票数1000张可以拆成10个分片每个分片100张用stock:20240501_G1001:0到stock:20240501_G1001:9这10个key来存储。用户请求进来时按用户ID或者请求ID做哈希路由到固定的分片。这样单个key的访问量降到原来的1/10。要注意的是分片方案并没有增加总库存量只是把一个热点打散成多个热点。1000张票还是1000张只是从“1000个请求抢一个key”变成“每个分片只承受部分请求”。库存分片带来的新问题是某个分片可能不够而另一个分片还有余票。所以一般有两种处理策略第一次哈希取分片如果分片库存不足再尝试其他分片类似二次探测各分片独立扣减后台把碎片库存汇总后再动态分配适合对实时性要求不那么极致的场景。实际项目里我偏向策略一逻辑简单最多就是多几次重试用户体验上也就多几十毫秒。3.4 异步下单与削峰库存扣减成功了订单还没创建呢。如果所有请求都在用户点击瞬间直接写数据库数据库还是扛不住。所以下单链路要改成异步化用户在客户端提交订单请求网关层限流、鉴权、风控校验通过订单服务进行Redis库存预扣预扣成功后把订单创建消息发送到消息队列消费者异步拉取消息写订单表、锁数据库库存、更新订单状态用户端通过轮询或者WebSocket实时获取订单状态。这套流程的核心就是削峰填谷。用户感知到的等待时间是1秒还是3秒其实影响不大但数据库的写入压力从每秒几十万骤降到每秒几千系统就能稳定下来。我见过很多人把“用户点击后必须立刻返回成功”当成硬性需求其实在秒杀场景里这是自己给自己上枷锁。12306现在的做法基本也是排队——你提交了系统告诉你“排队中”后面异步处理再通知你结果。注意异步化之后Redis里的库存扣减和数据库里的库存扣减要能对得上。预扣只是一种占位真正的扣减还是以数据库落账为准。如果用户超时未支付要释放预扣库存数据库扣减失败也要反向补偿。这些都需要一个库存对账任务兜底。4. 高QPS下的限流与防刷先让系统活下来高并发架构有个原则流量是不可预测的但系统容量是有限的。所以必须把超过容量的流量挡在外面。限流不是可选优化是系统的生命线。4.1 常用的限流算法怎么选限流算法常见的有固定窗口、滑动窗口、漏桶、令牌桶。我直接说结论和适用场景。算法特点适用场景固定窗口简单但临界问题明显低频接口要求不高的场景滑动窗口解决固定窗口临界问题精度高通用API限流漏桶匀速处理平滑突发流量下游能力固定的场景比如写数据库令牌桶允许一定突发流量控制平均速率大多数业务接口Nginx默认采用秒杀场景适合令牌桶。原因很简单余票一瞬间放出来如果所有请求都按匀速处理那一两秒内根本处理不完令牌桶允许桶里预存一定数量的令牌在短时间内打出一个小突刺同时又把整体速率控制在安全范围。Nginx里的limit_req就是令牌桶思想的实现burst参数就是桶的大小。一个经验burst不是越大越好它决定瞬间放行多少请求到后端设得太大会把后端打挂太小用户体验差。一般在压测后按后端吞吐的80%来设置。4.2 多层限流的配合限流要分层各层守各层的关口接入层Nginx/LVS按IP维度限流防止单IP高频率刷接口。网关层应用网关按用户ID、设备ID维度限流粒度更准确可以结合风控。服务层按业务接口维度限流比如秒杀接口限流、查询接口限流。MQ/DB层控制下游消费速率防止数据库被打满。很多团队只在网关层做了限流结果网关自己被高并发打挂了所有服务一起挂。正确做法是接入层先粗粒度限制网关层再做精细控制服务层再做兜底。这就是层层减负。Nginx里的限流配置也很简单核心是先定义zone再引用limit_req_zone $binary_remote_addr zoneorder_zone:10m rate10r/s; server { location /api/order/submit { limit_req zoneorder_zone burst20 nodelay; proxy_pass http://backend_orders; } }配置里的rate10r/s表示每秒只放10个请求burst20表示允许瞬间多放20个请求排队处理。需要注意的是limit_req_zone必须定义在http块里不能只写在server块中否则不生效。4.3 秒杀系统中如何减轻查询压力12306和电商秒杀有一个很大的不同用户会疯狂刷新余票查询。查询请求量往往比下单请求量高一个数量级。所以查询系统要做几件事页面静态化。车次列表、票价表、时刻表这些变化不频繁的数据直接生成静态页面用CDN分发连应用层都不需要进。余票信息也不能每次都查数据库或Redis要在业务层做本地缓存缓存时间为几百毫秒到几秒允许出现轻微的数据延迟。对于最热的车次可以在应用内存里再放一份热点余票通过异步任务定时刷新。这样最热的查询直接命中本机内存根本不走网络。从我的经验看这几个手段能把查询QPS从百万降到一个很小的量级。真正到后端服务的查询请求可能不到总流量的十分之一。5. 缓存与数据库的一致性别让余票对不上账用Redis做预扣库存以后有一个问题绕不开Redis里的库存和数据库里的库存不一致怎么办这是秒杀系统里最容易出问题的地方。5.1 缓存更新策略常见策略有几种我最终常用的方案是先更新数据库再删除缓存。很多人问为什么不是先删缓存再更新数据库因为先删缓存在并发下会有大量请求瞬间打到数据库上造成缓存击穿先更新数据库再删缓存虽然删除瞬间会有一点不一致但很快会自愈。而且删除缓存这个动作本身是幂等的重复执行没副作用。订单系统中数据库库存扣减成功之后删缓存删缓存失败通过消息队列异步重试删除。这样理论上不一致的窗口只有删缓存失败到下次重试成功之间的这段时间。再配合一个定时任务每小时比对一次Redis余票和数据库余票把偏差找出来修正。这就是最终一致性。5.2 订单状态机设计订单状态不能随便改不然对账会乱。我设计秒杀订单时习惯把状态机定成草稿/占位 — 已提交 — 已支付 — 已出票 — 已完成/已取消其中草稿/占位就是Redis预扣成功后生成的一个临时记录支付成功后变为已支付支付超时则进入已取消释放库存。已取消和已支付是两个终态不考虑退款的话。状态机的价值在于任何一笔订单在任何时刻都能明确它在哪个环节出了问题可以回溯也方便对账任务扫描。不要在代码里随手把状态字符串换来换去那样迟早会出“支付成功后订单变没了”这种事故。5.3 对账与补偿任务我见过很多系统上线后没有对账任务结果线上跑了一个月余票数和实际卖出数对不上查起来非常痛苦。强烈建议从第一天就把对账任务加上每隔几分钟扫描最近一段时间内的订单检查是否每个已支付订单都有对应的库存扣减扫描Redis中的预扣库存和数据库实际库存计算差额超过阈值报警处理超时未支付订单释放Redis中的预扣库存。对账任务代价不高但能救命的场景很多。比如消息队列消费失败了、Redis重启了、缓存删除重试了三天都没成功这些坑单靠代码review是发现不了的。6. 高频问题与排查技巧实录高并发系统的问题往往不是单个组件坏了而是多个组件之间的容量不匹配。这一部分我把自己做秒杀类项目时遇到过的高频问题整理成速查表后面再挑一个典型场景做复盘。6.1 高频问题速查表现象根因排查思路解决方案Redis某个key的CPU被打满热点key集中访问用redis-cli --hotkeys或monitor观察热点key分片、本地缓存数据库连接池被打满异步消费能力不足查看MQ积压数、数据库慢查询增加消费者、批量写入、调大连接池表面看没有超卖但账目不平缓存和数据库扣减时机不一致对比Redis和DB库存增加对账任务统一扣减顺序限流不生效Nginx limit_req配置了但没加zone查看Nginx error.log检查limit_req_zone定义位置Nginx连接数到了却上不去worker_rlimit_nofile没调大执行ulimit -n查看同步调大系统文件句柄数和Nginx配置抢购时部分用户请求一直超时负载均衡后端的慢请求占满线程查看后端线程池超时机制读超时熔断快速失败Redis重启后所有请求打到数据库缓存雪崩查看缓存命中率监控缓存预热、多级缓存、过期时间加随机值同一个用户疯狂刷接口防刷力度不够查看网关访问日志中单用户频率网关层按用户ID限流、加验证码这些问题的共同特点是不是某个组件坏了而是多个组件之间的容量不匹配。排查起来最忌讳只盯一个组件要去看全链路从入口到出口逐个环节确认耗时和吞吐。6.2 一次热点key事故的完整复盘我之前做过一个类似抢票的活动上线第一分钟监控报警Redis某个节点的CPU使用率直接拉到95%。当时第一反应是查慢查询和bigkey结果都不是。后来用redis-cli --hotkeys一看发现某个车次的余票key访问量占了全Redis节点访问量的70%。因为所有用户刷余票时都先查这个key而且这个key还没做分片。定位到之后第一步是紧急在业务层加了本地缓存余票查询先走本地内存缓存5秒只有本地缓存过期后才回源到Redis。这一步直接把这个key的访问量降低了90%以上。第二步才是把库存key改成10个分片从根上解决热点。复盘结论是热点key问题必须提前设计不能等报警了再处理。像余票这种高热度数据从设计第一天就应该想到分片和本地缓存兜底。6.3 全链路压测的实操建议我有个习惯所有关键接口都要在压测环境里跑全链路压测不能只压单个服务。很多问题在单服务压测时完全看不出来一上全链路就暴露。比如Redis预扣和数据库落账之间的竞态只有全链路压测到高并发才会触发。压测时要注意几点压测环境至少要模拟真实流量的70%不能拿两台低配机器随便跑要预留一个“压测水位线”比如后端能扛5万QPS线上只允许到4万留20%缓冲压测过程中必须盯全链路监控包括Nginx连接数、Redis命中率、MQ积压数、数据库连接池水位压测结束后要清掉测试数据否则会影响后面的对账任务。压测时流的汗比上线后流的泪便宜多了。我研究这类系统虽然不复杂但整个链路想通了很多高并发系统都能解。我个人的体会是做这类系统别一上来就提容器化、提服务网格先把最基础的负载均衡分层、库存扣减原子性、异步削峰、对账兜底这四件事做到位系统的稳定性就已经超过绝大多数团队了。最后再分享一个小技巧限流阈值一定要根据压测结果动态调整不要拍脑袋写死不然高峰期要么误伤用户要么挡不住流量。