在互联网大厂做核心系统架构最不缺的就是量的冲击。当业务体量涨到某个临界点所有教科书上的标准答案都会失效——比如你以为加个缓存就能顶住流量结果冷热数据一分离缓存本身成了新的瓶颈你以为分库分表能解决存储压力结果跨库查询、分布式事务直接把研发效率拖垮。这篇实录要聊的就是一套在某头部电商平台经受了百亿级请求量考验的交易系统架构设计全过程。我会尽量把设计背后的权衡、踩过的坑、以及验证过的参数取舍都摆出来适合正在做高并发系统设计、或者准备从单体架构往分布式方向演进的朋友参考。先说清楚这套系统的定位它不是某个中间件也不是纯理论方案而是围绕交易核心链路展开的一整套架构实践覆盖了订单创建、库存扣减、支付回调、用户资产变动这些高一致性要求的场景。整个方案最终支撑了日请求量百亿级别、峰值QPS超百万的流量规模。下面我从问题定义开始逐步拆解整个架构的来龙去脉。1. 这套百亿级系统架构到底在解决什么1.1 先从业务场景说起所谓的百亿级系统最直白的理解就是每天要处理上百亿次请求。这个量级放到任何一家公司都不是靠单机可以扛下来的。我们当时负责的是交易核心域包含订单服务、库存服务、支付网关对接、用户资产服务等业务特点是读写比例悬殊——读多写少但写操作的峰值极高尤其是在大促秒杀时段瞬时流量可以将平时的QPS拉高几十倍。还有个容易被忽略的痛点交易链路对数据一致性要求极高。商品少卖一件可以补券但多卖一件就是资损事故。所以架构设计不能只考虑性能还得在性能、一致性、可用性之间做动态取舍。这套系统的第一个设计目标就是在这个三角里找到可用性最优的平衡点。1.2 单体架构是怎么被流量压垮的两年前这套系统还是典型的单体应用加单库单表。当时日请求量大概在亿级MySQL单库日均写入几千万行高峰期连接数跑满应用层经常出现获取数据库连接超时的报错。最严重的一次线上故障是因为一张订单表数据量突破了1亿行某个范围查询走了全表扫描直接把数据库的IO打满导致下游所有依赖订单数据的接口全部抖动。单体架构的核心痛点不是代码层面的混乱而是资源无法隔离。一个慢SQL会把整个数据库的连接池耗尽一个非核心接口的流量激增会抢占核心交易链路的线程资源。到了那个阶段加机器已经解决不了问题——因为瓶颈在数据库这一层而不是应用层。所以整个架构重构的第一个落脚点一定是拆包括应用拆分和数据拆分。1.3 架构设计的量化约束在设计这套系统之前团队先定了几个硬性指标系统可用性要达到99.99%以上折算下来全年不可用时间不超过53分钟核心链路的TP99延迟控制在200毫秒以内数据库单表数据量上限不超过5000万行单机QPS达到5000以上时必须触发扩容或限流机制。这些指标不是凭空定的而是结合业务体量增长趋势和运维资源成本反推出来的。我自己的经验是架构设计最忌讳拍脑袋。如果你连系统的容量水位、性能目标、成本上限都没量化那后续所有技术选型都会变成盲人摸象。比如分库分表到底是分64个库还是128个库不是看心情而是要看单库的容量上限和总数据量增长曲线。我们当时的计算方式是单库MySQL的有效容量大概是5000万行以内业务预计三年后总数据量达到32亿行那至少需要分64个库。再叠加每个库的读写负载和单库QPS上限最终才敲定了128个库的规模。2. 整体架构的选型与设计思路2.1 微服务拆分的核心依据很多人做微服务拆分喜欢按技术层来拆——把控制层拆成一个服务数据层拆成一个服务。这种拆法在流量低峰期没什么问题但一旦流量涨起来你会发现在服务调用链上处处是瓶颈。我们当时采用了按业务域拆分的方案核心原则是一个业务域对应一个独立的服务服务内部拥有完整的应用逻辑和数据访问能力。以交易链路为例我们拆出了用户域、商品域、订单域、库存域、支付域、营销域六个核心服务。为什么这么拆因为每个业务域的变更频率、扩展方式、容灾要求都不一样。比如商品域读多写少可以横向扩展大量只读实例订单域写多读多需要同步双写和异步读扩散营销域属于大促衍生出来的非核心链路在大促时可以一键降级释放机器资源给核心交易链路。需要强调的是每个微服务必须拥有独立的数据库资源。很多团队拆了服务却没拆库结果服务调用倒是轻量了所有服务却还在争抢同一个数据库连接池等于白拆。我们当时对每个核心服务都做了独立的库和独立的缓存集群物理隔离这一步是不能省的。2.2 数据分库分表的维度选择交易系统最常见的分片维度有两种按用户ID哈希分片以及按订单ID分片。这两种方案各有取舍。按用户ID哈希分片的优势是同一个用户的所有订单数据都在同一个分片上做查询用户的订单列表这种高频操作时只需要路由到单个分片即可不需要跨库合并。但问题在于热点用户的数据会集中在一个分片上比如头部卖家的订单量可能是普通用户的几百倍容易形成数据倾斜。按订单ID分片的优势是数据分布更均匀但查询用户订单时需要通过用户ID到订单ID的映射关系二次路由增加了一次查询开销。我们最终的方案是用户ID哈希分片 订单ID全局唯一的双层路由。具体来说用户订单表按用户ID哈希分片同时订单ID使用雪花算法生成包含分片信息的时间序列ID。这样不管是按用户维度查订单列表还是按订单ID查订单详情都能准确定位到分片避免了全库扫描。这个设计虽然不是独创但能同时兼顾两种高频查询场景实际的工程收益非常明显。分片键一旦确定后续的数据迁移和扩容就是最头疼的问题。我们采用的方案是提前分片从始至终保持128个分片不变当前量不够时通过增加每个分片内的节点数来横向扩容而不是重新分片。这样避免了rehash带来的数据迁移风暴。代价是有一定的前期资源浪费但换来的是架构稳定性和运维确定性。2.3 异步化与最终一致性设计交易链路里有个经典问题用户下单这个动作本质上要经历一系列操作——扣减库存、锁优惠券、生成订单、发送消息通知。如果这些操作全部同步执行用户下单的耗时会被最慢的那个操作拖累系统的吞吐量也会被同步等待阻塞。我们的做法是把核心链路做了同步写 异步化的拆分。用户下单时同步链路只完成三项操作预扣库存、生成订单记录、发送一条订单创建成功的消息。其他操作比如优惠券核销、积分发放、短信通知、数据分析埋点全部通过消息队列异步消费对用户无感知。这里的关键设计点是最终一致性的保障。异步化不是简单地把操作丢进队列就完事而是要确保消息不会丢、不会重复、且消费失败后能自动重试。我们自研了一套基于本地消息表MQ的可靠消息方案业务操作和消息写入放在同一个本地事务里事务提交后后台任务扫描本地消息表把未发送的消息投递到MQ消费方处理成功后回执ACK再删除消息表中的记录。这套方案的本质是用数据库事务来保证业务操作和消息发送的原子性同时借助MQ的异步能力削峰填谷。3. 核心链路的细节设计与关键实现3.1 订单创建链路全流程拆解订单创建是整个交易系统最核心的链路也是最考验架构设计功力的一环。我在设计这组接口时遵循了一个原则把必须强一致的操作和可以异步化的操作分得清清楚楚。用户在客户端点击提交订单后请求会先经过接入层Nginx和API网关网关做第一层身份认证和限流。通过后请求到达订单服务订单服务首先做参数校验和风控校验然后调用库存服务预扣库存。库存预扣成功后订单服务在本地事务里生成一条订单记录状态是待支付同时写入一条订单创建事件到本地消息表。整个流程确保强一致的部分只有预扣库存生成订单两步操作通过分布式事务框架的TCC模式保证最终一致。订单创建成功后系统会立即给用户返回下单成功的响应耗时一般在30毫秒到50毫秒之间。用户跳转到支付页面的同时支付回调通知通过异步方式处理。支付网关回调到达后支付服务更新订单状态为已支付然后发送一条支付成功消息下游的库存服务订阅这个消息完成库存的正式扣减积分服务给用户加积分营销服务核销优惠券。这段链路设计里有个容易被忽视的细节库存预扣和正式扣减为什么要分开原因是用户从下单到实际支付有一个时间窗口如果下单就正式扣库存那些下单后不支付的用户会长时间占用库存导致超卖。我们采用了预扣库存设置过期时间这种方式比如订单15分钟内未支付预扣的库存自动释放回补到可售库存中。3.2 库存超卖的应对策略库存扣减是资损风险最高的一环超卖一次就可能是几十万级别的损失。我们做库存扣减时经过了多轮迭代最终采用的是Redis预扣DB兜底的双层方案。在Redis中我们用hash结构保存每个SKU的库存数量扣减动作使用Lua脚本保证原子性。Lua脚本的逻辑是先判断当前库存是否大于扣减数量如果是则执行扣减并返回成功否则返回库存不足。Redis单线程的特性天然保证了并发安全所以这个操作在峰值每秒几万次的情况下也不会出现超卖。但Redis在任何情况下都不能作为唯一的库存依据毕竟它只是缓存层。因此我们设计了DB兜底校验订单支付成功后库存服务在数据库层面再次执行条件更新SQL类似UPDATE sku_stock SET stock stock - ? WHERE sku_id ? AND stock ?靠数据库行锁保证最终的库存扣减不超卖。这个乐观锁更新是最后一道防线即使Redis出现数据不一致问题最终也能以数据库为准。这套方案踩过的一个坑是Redis中库存扣减和DB扣减不是严格同步的。如果Redis扣减成功但DB扣减失败会出现两边库存数据不一致。我们最终的解决方式是引入对账任务每5分钟扫描一次Redis库存和DB库存的差额发现不一致就触发补偿任务以DB为准修正Redis中的数据。对账虽然听起来不够实时但在这种高并发场景下它比每一步都做强校验更可行。3.3 缓存一致性的工程实践订单系统的读流量远高于写流量所以缓存是必不可少的。但缓存引入之后最棘手的问题就是缓存和数据库的一致性。我见过太多团队使用先更新数据库再删除缓存的方案结果在并发场景下一个线程更新数据库的同时另一个线程读到了旧缓存缓存删除后又立马被一个并发读请求写回去了导致脏数据长期存在。我们的设计采用先更新数据库再删除缓存订阅变更消息延迟双删的组合方案。具体来说写请求先更新数据库事务提交后删除Redis缓存同时发送一条数据变更消息到MQ消费端收到消息后延迟500毫秒再次删除Redis缓存。为什么延迟500毫秒因为在这段时间窗口内如果有读请求把旧数据回填到缓存第二次删除会把它清掉。500毫秒的延迟是经验值既不会让用户感知到明显延迟又能覆盖大多数并发读回填的场景。这种方案虽然不能做到理论上的强一致但实测下来脏数据出现的概率降到极低而且脏数据最长的存活时间也只有几百毫秒在交易场景下是可以接受的。在实际落地时我们还会对缓存设置不同的过期策略热点商品缓存设置30秒到60秒的短过期时间普通商品缓存设置5分钟到10分钟的过期时间。过期时间越短缓存失效的代价越小但穿透回源数据库的压力越大需要根据实际命中率动态调整。4. 高可用保障体系的搭建逻辑4.1 多机房容灾与流量切换百亿级系统最怕的不是流量峰值而是机房级别的故障。我们经历过一次底层交换机抖动导致的半个机房不可用从那以后整个容灾架构的设计标准就提高了。核心服务全部采用多机房部署每个机房至少有完整的服务集群和独立的数据副本。数据层的容灾采用了异步复制的方案。主库在A机房B机房部署只读副本正常情况下读流量按比例分发到两个机房写流量只走A机房主库。当A机房出现故障时通过DNS切换和路由配置调整将写流量切到B机房B机房的副本提升为主库角色。整个切换过程需要控制在3分钟以内这个时间窗口内的订单请求会被网关层拦截并返回系统繁忙的提示。容灾切换看起来是运维操作实际上在架构阶段就要做好准备。比如应用层不能硬编码数据库连接地址必须通过配置中心动态获取分库分表的路由规则必须在多机房之间保持一致消息队列的消费位点必须支持从任意机房续跑。这些细节如果不在设计阶段考虑进去容灾切换就只能停留在PPT层面。4.2 限流降级与流量治理流量高峰来临时系统内部的每个组件都可能成为瓶颈。限流降级不是要消灭瓶颈而是要在瓶颈出现时保护核心链路不被打垮。我们在网关层实现了全局限流采用令牌桶算法针对不同的接口设置不同的流量阈值。比如订单创建接口的单机阈值是每秒5000次库存扣减接口的单机阈值是每秒3000次超过阈值后多余的请求直接返回排队中的提示而不是让请求穿透到下游。降级策略按照业务重要性做了分级。一级降级是关闭非核心服务比如优惠券列表、个性化推荐、用户足迹这类功能在大促期间可以全部关闭二级降级是关闭核心服务的非必需功能比如订单列表页可以不做实时库存展示三级降级是核心链路本身降低一致性要求比如库存扣减可以临时切换为牺牲部分准确性以Redis数据为准。降级开关通过配置中心动态下发可以在10秒内生效避免紧急情况下的重启操作。我印象最深的一次大促前演练中我们模拟了支付服务宕机的场景。由于提前配置好了降级策略支付服务不可用时订单服务只保留下单能力支付能力挂起用户看到的是当前支付系统繁忙请稍后尝试。核心的下单链路没有受到任何影响演练结束后支付恢复积压的支付请求通过消息队列逐步消化最终实现了数据一致。4.3 全链路监控与告警没有监控的架构等于盲飞。这套系统的监控体系分为三层基础设施层、应用层、业务层。基础设施层监控CPU、内存、磁盘IO、网络带宽应用层监控每个服务的QPS、延迟、错误率、线程池状态业务层监控核心业务指标比如订单创建量、支付成功率、库存扣减失败次数。链路追踪选用的是开源方案改造后的版本解析毫秒级的延迟分布。简单的表格能说清楚典型的性能问题定位路径故障现象可能的原因排查工具TP99延迟升高数据库慢查询、Redis热点key慢日志分析、Redis热key探测错误率突增下游服务超时、连接池耗尽全链路追踪、服务依赖拓扑线程池活跃线程满上游流量突增、锁竞争线程Dump分析、锁监控监控告警的阈值需要根据历史数据动态调整。刚开始我们设置了大量的告警规则结果运维团队天天被报警轰炸真正的问题反而被淹没。后来改了策略告警必须分级P1级是资损、服务不可用这类重大问题要求立即响应P2级是性能退化、错误率超阈值要求10分钟内响应P3级是偏离基线但不影响可用性的指标只记录不报警。这套分级机制让有限的人力集中在了高价值的问题上。5. 实战中踩过的坑与排查实录5.1 缓存雪崩的经典场景百亿级系统一定会遇到缓存雪崩区别只是在哪个环节遇到、怎么修复。我们的经验是在一个商品详情页的缓存优化中踩到的。当时为了提升缓存命中率商品缓存设置了统一的过期时间结果高峰期时大量缓存同时失效海量请求直接穿透到数据库数据库连接数瞬间飙满。后来的解决办法做了三个调整缓存过期时间抖动给每个key的过期时间加上一个随机偏移量避免同时过期热点数据永不过期而是通过后台任务定期更新数据库层增加读保护当单key的请求量超过阈值时直接返回空结果并短暂熔断。这套方案组合下来缓存雪崩的风险基本可控。5.2 数据倾斜导致的单分片过载分库分表之后我们遇到了一个意料之外的问题——按用户ID哈希分片后数据分布并不均匀。头部卖家产生的订单量是普通用户的数百倍导致某几个分片的数据量和访问量远超平均水平这些热分片容易成为系统瓶颈。排查下来问题的本质是哈希分片假设数据均匀分布但业务数据天然就有长尾效应。我们的解决方案是引入分片内再分片的二级机制针对热点用户将其订单数据继续细分到多个子表中应用层做一层路由映射。同时把热点用户的写操作优先分发到性能更好的物理节点上。这个方案实施后热分片的负载降低了60%以上系统的整体水位均衡了很多。5.3 频繁Full GC与延迟抖动在一次大促压测中我们发现订单服务的TP99延迟从80毫秒涨到了300毫秒而且呈现周期性波动。排查了很久最后通过GC日志定位到问题是新生代内存设置过小大量订单对象在创建后被迅速移动到了老年代触发频繁的Full GC。调优方案是重新划分堆内存结构新生代占比从默认的1/3调整为接近一半同时将大对象直接分配在老年代避免在新生代和老年代之间频繁复制。此外我们还排查出了一个隐藏问题日志框架中的某些同步写操作占用了大量内存和CPU资源。把日志改成异步写入后服务延迟恢复到正常水平。做高并发系统久了你会发现一个规律很多性能问题的根源不在代码逻辑本身而在于内存分配和GC策略这些底层机制。但这些问题恰恰是在单体架构下很难察觉的因为流量不够大问题暴露不出来。只有系统规模达到一定程度这些隐性炸弹才会连环引爆。5.4 慢SQL排查的一个经典案例有一段时间订单查询接口的TP99明显升高但数据库的CPU和IO指标都正常。后来我们在慢日志中发现了一条意外低效的SQL按订单状态查询时由于状态字段的区分度很低优化器没有走索引做了全表扫描。排查思路是先用EXPLAIN看执行计划确认走了全表扫描后分析了索引的区分度。订单状态只有待支付、已支付、已取消、售后中几种区分度太低不适合建单列索引。解决方式是改成联合索引把订单创建时间和状态字段组合起来查询时先按时间范围过滤再叠加状态条件。这个改动让接口的TP99从200毫秒降到了60毫秒效果立竿见影。百亿级系统里一条慢SQL的影响会被放大多倍。因为在高并发场景下一条慢SQL会占用数据库连接很长时间连接池中的可用连接会迅速减少进而引发其他所有正常请求排队等待。排查慢SQL的时候一定要看执行计划而不是凭直觉加索引。回顾这套百亿级系统的整个建设历程我最大的体会是架构不是设计出来的而是被真实流量逼出来的。很多方案在理论阶段看起来很完美真正上线后才发现隐藏的边界条件。比如缓存一致性方案我们起初设计的是强一致的方案上线后发现性能完全扛不住后来才妥协成延迟双删最终一致。这种妥协不是退步而是架构师必须建立的工程思维——在特定的资源约束和业务要求下选择最合适的方案而不是追求理论上的最优解。如果这个内容对你有帮助建议你可以从自己的核心系统入手先量化当前系统的容量水位在哪里然后找出最薄弱的业务域试着做一次小范围的拆分改造。不要一上来就追求全量微服务化架构演进从来不是一步到位的而是从一次成功的拆解开始逐步走向更大的规模。
阅读完成 · 觉得有帮助?