从12306系统拆解高并发与强一致性:库存计算、分布式锁与架构权衡

📅 发布时间:2026/8/13 4:10:49
从12306系统拆解高并发与强一致性:库存计算、分布式锁与架构权衡
1. 项目概述从“抢票难”到理解系统复杂性每年春运、节假日12306这个名字就会成为大家讨论的焦点。表面上看它只是一个铁路购票网站或App但稍微深入一点你就会发现它背后是一个极其复杂的分布式高并发系统。我最初接触这个“项目”也是出于好奇为什么一个看似简单的“查票-选座-下单-支付”流程在特定时间点会变得如此脆弱为什么市面上总有各种“抢票脚本”在流传它们真的有效吗带着这些问题我开始系统地梳理和拆解12306铁路购票系统的核心逻辑这不仅仅是为了学习技术更是为了理解一个国家级关键业务系统在面对海量、瞬时、高并发请求时所必须解决的工程挑战。这个学习笔记我会分上下两部分来写。上半部分我们聚焦于系统的核心业务模型、数据一致性难题以及最关键的库存余票计算逻辑。下半部分我们再深入探讨高并发架构、缓存策略和防刷机制。很多人一提到12306就想到“抢票脚本”但我想说的是真正理解了12306的设计你才会明白那些简单的脚本为何大多徒劳以及一个健壮的系统应该如何从设计层面抵御这类冲击。这不仅仅是一个购票系统更是一个经典的、活生生的高并发、高可用、强一致性分布式系统案例库。2. 核心业务模型与数据一致性困局要理解12306首先得抛开我们作为用户的单一视角。用户只关心“从A到B有没有票”但系统需要管理的是一个极度复杂的、动态变化的资源网络。2.1 车票的本质一段行程的资源占用许可很多人会把火车票类比成电影票但这是一个严重的误解。电影票是“座位”维度的一个座位对应一张票锁定后就不会变化。火车票则是“行程”维度的它本质上是对某趟列车在特定区段内座位资源的占用许可。这里的关键在于“区段”。一趟从北京西开往深圳北的G79次列车它不仅仅服务“北京西-深圳北”这一个ODOrigin-Destination。中间会经过石家庄、郑州东、武汉、长沙南等多个车站。因此一个座位从北京西到深圳北的全程可以被拆分为无数个区段组合例如“北京西-武汉”、“石家庄-长沙南”、“郑州东-深圳北”等等。系统需要为所有这些可能的区段组合计算余票并保证任何一个座位在同一时间只能被一个区段占用。这就引入了第一个核心难题票额分配与席位复用。铁路部门会预先制定一个票额分配计划比如G79次列车可能分配200张票给“北京西-深圳北”全程100张给“北京西-武汉”50张给“武汉-深圳北”等等。但这只是静态计划。在实际售卖中系统需要动态计算。例如当一张“北京西-武汉”的车票售出后这个座位在“武汉-深圳北”这个区段就空出来了理论上可以再卖给另一个人。这就是席位复用它极大地提升了运力利用率但也让余票计算从简单的“总数减一”变成了一个多维度的、动态的组合优化问题。2.2 “卖超”与“死锁”数据一致性的终极挑战在并发购票的场景下最怕的就是“一票多卖”超卖和“票卖不出去”死锁。超卖场景假设座位A的“北京西-武汉”区段还剩最后1个名额。此时用户甲和用户乙同时发起购买这个区段车票的请求。如果系统简单地先查询余票发现0然后各自进行“余票-1”和“生成订单”的操作在没有锁保护的情况下很可能两个请求都成功导致同一个座位被卖出两张票。这就是典型的超卖。死锁场景用户甲想买“北京西-武汉”用户乙想买“武汉-深圳北”。系统检查发现两个区段都可用。但如果系统处理逻辑是“先锁全程资源再分段扣减”就可能发生死锁。甲锁住了座位A的“北京西-深圳北”全程为了买前半段等待支付乙也需要锁同一个座位的全程为了买后半段但发现已被甲锁定于是等待。如果此时甲因为某种原因如支付超时需要回滚释放锁但乙还在等待整个流程就可能卡住。更复杂的是如果丙想买“北京西-深圳北”全程他会发现这个座位既被甲占着前半段又被乙等着占后半段导致全程票也无法售出形成资源浪费。注意这里说的“锁”是一个广义概念在分布式系统中可能表现为数据库的行锁、乐观锁版本号、分布式锁如Redis的RedLock或直接在应用层通过事务和业务逻辑实现的资源预留机制。所以12306的核心设计目标之一就是在席位复用模型下实现高并发下的强一致性即保证1. 不超卖2. 最大限度减少死锁和资源浪费3. 快速响应。这是一个“不可能三角”在特定领域的权衡实践。3. 余票计算的核心从“查库存”到“做决策”当我们点击“查询”按钮时系统返回的“有”或“无”不是一个简单的数据库查询结果而是一个实时决策的结果。这个决策过程是12306系统最精妙也最复杂的地方之一。3.1 静态库存 vs. 动态计算早期的售票系统可能采用为每个车次每个区段设置一个固定库存数如“北京西-武汉100张”的静态模式。但这样无法实现席位复用资源利用率极低。现在的12306采用的是一种动态计算模式。系统内部维护的是席位池和售出记录。席位池可以理解为这趟车所有座位如二等座500个的列表。售出记录则是已经成功售出的车票记录了席位号、乘车日期、车次、发到站等信息。当用户查询“北京西-武汉”的余票时系统并不是去查一个叫“北京西-武汉余票”的计数器而是需要实时执行一个查询算法遍历所有二等座席位。对于每个席位检查其在“北京西-武汉”这个区段是否已被售出记录占用。同时还需要考虑这个席位是否被其他已占用的、但未支付完成的订单临时锁定占座。统计所有未被占用且未被锁定的席位数量返回给用户。这个计算过程在低并发时没问题但在春运期间一趟热门车次可能有数十万人同时查询如果每次查询都进行全量遍历计算数据库会瞬间崩溃。3.2 缓存与异步计算性能与一致性的平衡为了解决实时计算的性能瓶颈12306必然引入了多级缓存和异步计算的策略。但这带来了新的问题缓存数据的一致性。常见的架构思路实时计算层处理下单、支付等强一致性事务。当一张票成功售出时必须实时、原子性地更新核心的席位占用状态通常是在数据库中。异步计算层有一个独立的后台计算服务监听席位状态的变化如通过数据库的binlog。一旦有票售出或订单失效这个服务就触发对受影响车次、席别、相关所有区段的余票数量进行重新计算。缓存层将异步计算出的最新余票结果例如“G792023-10-01二等座北京西-武汉15张”存入像Redis这样的高性能缓存中并设置一个较短的过期时间如几秒到几十秒。查询层用户的前端查询请求绝大部分被导向缓存层。直接返回缓存中预计算好的结果速度极快。这里的关键细节与权衡缓存更新延迟从票售出到binlog被捕获到计算完成再到缓存更新这中间有短暂延迟可能是毫秒到秒级。这意味着用户可能在查询时看到“有票”但点击购买时票刚好在那一瞬间被另一个请求买走导致下单失败。这就是我们常遇到的“票已售完”提示。从体验上讲这有点遗憾但从系统角度看这是为了保证最终一致性和系统可用性而必须接受的权衡。它避免了在超高并发下为了绝对的实时一致而导致数据库锁竞争和系统雪崩。缓存粒度缓存什么如果把所有车次、所有日期、所有席别、所有区段的组合都缓存那是一个天文数字。系统需要做智能的缓存预热和淘汰。例如只缓存未来3天内热门车次的热门区段组合。对于非热门查询可能 fallback 到准实时计算。库存扣减的决策点真正的“卖出一张票”这个决策绝不能只在缓存层做。必须在实时事务层完成。流程通常是用户下单 - 系统携带查询到的车次、席别、区段信息请求下单服务 - 下单服务基于最新数据库状态进行一致性检查如使用乐观锁、SELECT FOR UPDATE等方式 - 检查通过则预留席位、生成订单 - 异步通知计算层更新缓存。这个过程是串行且受保护的。3.3 “抢票脚本”为何常常失效理解了上述架构就能明白大多数简单的“抢票脚本”为何作用有限。脚本在刷缓存很多脚本只是高频地向查询接口发送请求它们刷到的是缓存数据。即使它比人眼更快地看到“有票”并触发下单请求这个请求也要进入上述的实时事务处理队列。在这个队列里它和来自App、网站的正常请求是公平竞争的。无法绕过业务逻辑脚本无法解决核心的库存一致性逻辑。系统在下单时会有严格的校验包括用户身份、操作频率、席位真实状态等。频繁的无效请求反而容易被系统的风控策略识别并限制。候补购票的优先级12306推出的“候补购票”功能在系统设计上很可能拥有比普通查询/下单更高的优先级。当有退票或新释放的席位时系统会优先满足候补队列而不是释放到公共缓存中让所有人抢。这使得单纯靠速度“刷票”的成功率进一步降低。真正的“抢票”比拼的其实是在系统释放票源如退票、预留票释放的那个瞬间你的请求能否恰好排在事务处理队列的前列并且通过所有业务校验。这其中有很大的随机性脚本只是提高了“发送请求”的频率但无法保证“请求成功”的概率。4. 订单创建与库存扣减的原子性实现这是整个购票链路中最核心、技术挑战最大的一环。目标是将“查询余票”、“锁定席位”、“生成订单”这几个步骤在一个高并发环境下原子性地完成既要快又要准。4.1 基于数据库事务与行锁的方案简化模型我们先从一个最直观的、基于关系型数据库的方案来理解其思想。假设我们有一张核心表seat_occupation记录某个座位在某个行程日期、某个区段是否被占用。-- 简化的席位占用表 CREATE TABLE seat_occupation ( id BIGINT PRIMARY KEY, train_no VARCHAR(20), -- 车次 travel_date DATE, -- 乘车日期 seat_no VARCHAR(10), -- 座位号 from_station_code VARCHAR(10), -- 发站代码 to_station_code VARCHAR(10), -- 到站代码 order_id VARCHAR(32), -- 关联订单号NULL表示未被占用 status TINYINT, -- 状态0-可用1-已锁定占座未支付2-已售出 version INT DEFAULT 0 -- 乐观锁版本号 UNIQUE KEY uk_seat_route (train_no, travel_date, seat_no, from_station_code, to_station_code) );当用户购买“北京西(BXP)-武汉(WHN)”的车票时系统的核心操作可能在一个数据库事务中完成START TRANSACTION; -- 1. 检查并锁定资源使用SELECT ... FOR UPDATE悲观锁 -- 这里需要检查该座位在目标区段是否已被占用status!0同时要检查是否与现有占用区段冲突例如该座位已有“石家庄(SJP)-郑州东(ZAF)”的记录这与“BXP-WHN”不冲突可以售卖但如果有“BXP-长沙南(CSQ)”的记录则与“BXP-WHN”冲突因为后者被前者包含。 -- 实际SQL比这复杂得多可能需要联合查询。 SELECT * FROM seat_occupation WHERE train_noG79 AND travel_date2023-10-01 AND seat_no05车10F AND ( (from_station_code WHN AND to_station_code BXP) -- 冲突条件简化示例 ) FOR UPDATE; -- 如果查询结果为空说明无冲突可以继续。 -- 2. 插入占用记录状态为“已锁定” INSERT INTO seat_occupation (train_no, travel_date, seat_no, from_station_code, to_station_code, order_id, status, version) VALUES (G79, 2023-10-01, 05车10F, BXP, WHN, 临时订单号, 1, 1) ON DUPLICATE KEY UPDATE ...; -- 实际需要处理更复杂的唯一键冲突逻辑 -- 3. 生成订单记录 INSERT INTO order_main (...) VALUES (...); COMMIT;这个方案的优点是强一致利用数据库的ACID特性。但缺点也极其明显锁粒度粗、性能差。SELECT ... FOR UPDATE会在事务期间锁定相关行在超高并发下大量事务排队等锁数据库连接迅速耗尽系统响应时间飙升甚至出现死锁。这显然无法支撑12306的峰值流量。4.2 分布式环境下的优化方案在实际的超高并发系统中12306必然采用了更复杂的分布式方案来化解数据库压力。业内常见的思路包括1. 排队与异步化将下单请求先放入一个分布式消息队列如RocketMQ, Kafka。下单服务作为消费者按顺序从队列中取出请求处理。这相当于把并发的请求变成了串行处理彻底避免了数据库层面的锁竞争。用户体验上从点击“提交订单”到看到结果可能会有几秒的等待“排队中”但这比系统崩溃或卡死要好得多。支付成功后再异步更新库存和订单状态。2. 库存分段与分库分表将一趟列车的库存席位分成若干段Segment例如每50个座位一段。不同的段可以路由到不同的数据库分片Shard上。用户下单时系统可以尝试从某个分片获取席位。这样就将全局竞争拆分为多个局部竞争提高了并发度。这需要一套精妙的席位分配和路由算法。3. 基于Redis的分布式锁与原子操作对于“检查并占用”这个动作可以尝试在Redis中完成。例如为每个座位-日期-区段组合设置一个键值为状态。使用Redis的SETNXSET if Not eXists或SET key value NX EX命令来实现分布式锁或者使用Lua脚本保证检查与占用的原子性。Redis的性能远高于数据库可以承受更高的并发。但这也带来了数据持久化、故障恢复等新的复杂性通常需要和数据库结合使用Redis作为快速决策层数据库作为最终持久层。4. 合并请求与批量处理在极端高并发下系统可以尝试将短时间内对同一车次同一区段的请求合并。例如1秒钟内收到了1万个购买“G79北京西-武汉”的请求系统可以先快速检查余票是否充足例如还有200张然后从这1万个请求中随机或按规则如候补优先级选取200个进入下一步处理其余的立即返回“库存不足”。这虽然有些残酷但却是保护系统不垮掉的有效手段。实操心得在设计和实现这类系统时有一个黄金法则能异步的绝不同步能缓存的绝不查库能合并的绝不分散能快速失败的绝不长时间等待。所有的技术选型和架构设计都是围绕“在保证最终正确性的前提下最大化系统的吞吐量和可用性”这个目标进行的。对于12306来说瞬间的“有票-无票”状态不一致缓存延迟是可以接受的业务折衷但“超卖”或“系统长时间不可用”是完全不可接受的。5. 高并发查询的架构应对下单是写操作挑战在于一致性。而查询是读操作挑战在于极致的吞吐量和低延迟。春运期间数亿人反复刷新查询页面其请求量是下单请求的成百上千倍。5.1 读写分离与多级缓存这是应对高并发读的基石。读写分离将读流量导向只读数据库副本或专门的查询数据库如Elasticsearch用于复杂的车次、时刻表查询。主库只负责处理下单、支付等写事务。CDN静态资源加速将图片、JS、CSS等静态文件推送到全国各地的CDN节点加速页面加载。前端缓存对一些相对静态的数据如车站列表、车次基础信息可以在用户浏览器或App端进行本地缓存减少不必要的网络请求。应用层缓存Redis/Memcached如前面所述这是余票信息的核心缓存层。热点数据未来几小时、几天内的热门车次余票常驻内存。Nginx缓存对于某些聚合后的、变化不频繁的API结果可以在反向代理层如Nginx设置短期缓存进一步减轻应用服务器压力。5.2 查询请求的削峰与限流即使有缓存海量的查询请求本身也是对服务器资源的消耗。需要采取措施限流Rate Limiting在网关层对来自同一IP或同一用户的频繁查询请求进行限流例如每秒最多10次查询。超过限制的请求直接返回友好提示或默认结果防止恶意刷票和脚本攻击。请求合并在应用内部可以将短时间内相同的查询请求合并只向缓存或数据库查询一次然后将结果返回给所有等待的请求。这需要精巧的本地缓存和协程/异步编程模型支持。降级与熔断当查询服务压力过大时可以启动降级策略。例如返回的余票信息从精确的“XX张”降级为“有/无”状态甚至跳过一些复杂的计算逻辑直接返回一个保守的“票量紧张”状态以保护核心的下单服务不被拖垮。5.3 智能预加载与数据预热系统可以根据历史数据和实时趋势预测哪些车次、哪些日期会成为热点提前进行数据预热。缓存预热在抢票高峰开始前如早上8点放票前提前将相关车次的余票计算结果加载到Redis缓存中避免高峰时刻所有请求都击穿缓存到数据库。计算预热后台服务提前计算好未来一段时间内各种可能的行程组合的席位占用情况生成中间结果当真实查询到来时只需进行简单的组合运算即可减少实时计算量。6. 风控与反爬系统稳定性的守护者任何面对公众的高流量系统都必须考虑恶意流量。对于12306主要威胁来自刷票脚本和恶意占座。6.1 常见的风控策略行为模式识别频率异常同一IP或用户账号在极短时间内发起大量相同或类似的查询/下单请求。时间规律性脚本请求往往间隔极其均匀如精确的100毫秒而人工操作则有随机性。操作路径异常正常用户会浏览页面、查看时刻、选择乘客脚本可能直接调用核心下单接口。验证码体系静态图片验证码最基本的形式但容易被OCR识别。滑动拼图/点选文字交互式验证码增加破解难度。智能风险验证对于低风险请求如正常速度的查询可能不弹出验证码对于高风险请求如高频下单则触发更复杂的验证。这需要在用户体验和安全之间取得平衡。资源限制与令牌桶对未登录用户、新注册用户实施更严格的请求频率限制。为每个用户或会话分配一个“令牌桶”每次操作消耗令牌令牌按时间缓慢恢复。恶意刷请求会很快耗尽令牌。关联分析分析订单是否来自同一IP段、同一设备指纹、是否使用虚拟手机号等。对异常关联群组进行整体监控和限制。6.2 针对“占座不支付”的策略这是另一个头疼的问题。用户提交订单后系统会为其锁定席位并给予一定的支付时间如30分钟。恶意用户或“黄牛”可能利用这个机制大量占座但不支付囤积票源。缩短支付时限在高峰期将支付等待时间从30分钟缩短到10分钟甚至更短加速库存回流。阶梯式库存回滚不是所有占座订单都在同一时刻如30分钟整释放库存。可以采用随机延迟释放或者分批释放避免在某个时间点引发新的抢票洪峰。用户信用体系建立用户购票信用记录。对于频繁“占座不支付”的用户降低其信用分可能导致其未来购票时需要更复杂的验证或缩短其支付时间甚至限制其购票功能。这是最根本的治理手段。7. 个人学习与实践建议研究12306这样的系统最好的学习方法不是去逆向它的接口这既不合法也不道德而是自己动手尝试用简化的模型去实现核心难题。一个可行的学习项目路径设计数据模型设计车次、车站、座位、订单、席位占用记录等核心表结构。重点思考如何用最有效的结构表达“席位复用”。实现单机版余票查询与下单在一个数据库事务中完成“检查冲突-插入占用记录-生成订单”的流程。体会悲观锁FOR UPDATE下的并发控制。引入缓存用Redis缓存热门车次的余票。实现缓存更新策略如监听数据库变更日志。体会缓存不一致带来的“超卖”或“卖不完”幻觉。模拟高并发使用Jmeter、Locust等工具模拟数百上千个用户同时查询和下单。观察数据库连接、CPU、锁等待的情况。你会立刻理解为什么需要队列和异步化。引入消息队列将下单请求发送到RabbitMQ或Kafka由消费者异步处理。体验从“同步实时响应”到“异步排队处理”的架构转变。实现简单的分布式锁用Redis实现一个分布式锁来控制对同一个座位资源的并发占用尝试。思考更优方案阅读业界分享思考如何用“分段库存”、“合并请求”、“乐观锁”等方案进一步优化。通过这样一个从简到繁的实践过程你会对高并发、分布式事务、缓存一致性等概念有刻骨铭心的理解。你会发现每一个看似简单的业务需求背后都可能隐藏着巨大的技术挑战。12306不是一个完美的系统但它是在极端业务压力下工程智慧的一次集中体现。理解它不仅能学到技术更能学会如何在复杂的约束条件下进行权衡和设计。在下篇中我们将更深入地探讨其具体的微服务架构、容灾设计以及运维监控体系。