高并发系统设计:从万级到百万QPS的演进
1. 起点:一个典型的万级QPS瓶颈
某在线票务平台的订单系统,日峰值流量约2万QPS。系统采用经典的单体架构:一台Nginx前置,后端为Spring Boot应用集群(10个节点),数据库为MySQL主从复制(一主两从),缓存层使用Redis(单机哨兵模式)。
当时的核心矛盾在于:订单写入路径过长——每次下单需要校验库存、扣减库存、生成订单、发送MQ消息,全过程涉及三次数据库事务和两次Redis读写。当流量从日常的3000 QPS飙升至2万QPS时,数据库连接池率先耗尽,紧接着Redis内存达到瓶颈,最终导致订单大量超时。
关键教训:万级QPS时代,瓶颈往往不是单一组件,而是链路中的短板效应。架构演进的第一步,不是引入新组件,而是识别短板。
2. 第一次演进:读写分离与缓存分层(万级 → 十万级)
针对上述问题,一个常见的方案是读写分离+缓存重建。具体操作如下:
- 将订单查询流量全部转向只读从库,主库仅承担写入;
- 引入Redis Cluster替代单机哨兵,将缓存key按业务域拆分:库存预热、订单摘要、用户最近订单分别使用不同逻辑库;
- 采用Cache-Aside模式,读请求先查缓存,未命中则回源数据库,并异步重建缓存。
// 订单查询的缓存策略:先查缓存,未命中回源DB并重建
public OrderDTO getOrderById(Long orderId) {
String key = "order:" + orderId;
OrderDTO order = redis.get(key);
if (order != null) {
return order;
}
// 回源数据库(走从库)
OrderPO orderPO = orderReadDao.selectById(orderId);
if (orderPO == null) {
return null;
}
// 重建缓存,设置过期时间10分钟
redis.setex(key, 600, convert(orderPO));
return convert(orderPO);
}
关键点:这里必须注意两个问题——第一,缓存击穿(热点订单过期瞬间大量请求打到DB),解决方式是互斥锁或逻辑过期;第二,缓存与DB的一致性,更新订单状态时需先更新DB再删除缓存,而非更新缓存,否则并发下会产生脏数据。踩坑案例:曾因更新缓存而非删除缓存,导致库存扣减后用户仍看到旧库存。
这一阶段将系统推到了约8万QPS,但很快遇到两个新问题:从库延迟(主从复制延迟导致刚下单的订单查不到)和缓存容量爆炸(订单数据无限增长)。
3. 分库分表:十万级 → 三十万级的必经之路
当QPS达到10万以上,单库的写入能力(约5000 TPS)成为硬瓶颈。此时必须做数据库分片。以订单表为例,采用用户ID哈希分片,共分16个库,每库32张表。
-- 分片键选择:用户ID
-- 路由规则:shard_id = user_id % 16; table_id = user_id % 32
-- 示例:用户ID 10086 → 库2,表22
SELECT * FROM order_22 WHERE user_id = 10086 LIMIT 10;
关键点:分片键的选择决定了查询能力。如果按订单ID分片,则“查询某用户所有订单”会变成跨片查询,性能灾难。因此,必须根据核心查询模式确定分片键。代价是:跨片事务无法用数据库本地事务解决,需要引入分布式事务(如Seata的AT模式),且报表类查询需通过离线数仓完成。
代价与权衡:分库分表将写入能力提升至约5万TPS,QPS支撑到30万级别。但系统复杂度显著上升——SQL路由、分布式ID生成(雪花算法)、跨片聚合查询都成为日常运维负担。此阶段最大的教训是:能不拆就不拆,拆了就要守住分片键的边界。
4. 微服务化与异步化:三十万 → 百万QPS
达到30万QPS后,单体应用内部的锁竞争、JVM GC暂停、以及线程池耗尽成为新瓶颈。此时才引入微服务拆分,而非一开始就微服务化。拆分原则按业务域:订单、库存、支付、用户各自独立服务,通过RPC(Dubbo)或消息队列通信。
核心改造点在于订单链路异步化:
- 下单请求进入后,先写一条“订单创建中”状态,返回用户“处理中”;
- 同步发送MQ(RocketMQ)至库存服务、支付服务;
- 各服务消费消息后执行本地事务,通过事务消息保证最终一致性。
// 下单接口:同步只做两件事——写订单状态+发MQ
public String createOrder(OrderRequest req) {
String orderId = idGenerator.nextId();
orderDao.insert(OrderPO.builder().id(orderId).status("CREATING").build());
mqTemplate.send("order.create", new OrderMessage(orderId, req.getUserId(), req.getItems()));
return orderId;
}
关键点:这里最易踩坑的是消息重复消费——同一订单消息可能被消费两次,导致库存重复扣减。解决方案是消费端做幂等(用订单ID+操作类型做唯一索引,重复插入报错后忽略)。另一个坑是消息堆积:如果库存服务消费速度跟不上,需要增加消费者实例,但此时需保证消息队列的分区有序性(同一订单的消息发到同一分区)。
微服务化的代价:从单体到微服务,部署单元从1个变成20+个,链路追踪(SkyWalking)、配置中心(Nacos)、熔断降级(Sentinel)成为标配。这是“先单体后拆分”的理性路径:在QPS未达到瓶颈前,微服务只会增加运维成本,而不会提升性能。
5. 演进路线总结与容量评估
| 阶段 | QPS范围 | 关键改动 | 主要代价 |
|---|---|---|---|
| 单体+主从 | ≤2万 | 无 | 连接池耗尽 |
| 缓存分层+读写分离 | 2万~8万 | Redis Cluster、Cache-Aside | 缓存一致性 |
| 分库分表 | 8万~30万 | 16库×32表、分布式事务 | 跨片查询、运维复杂度 |
| 微服务+异步化 | 30万~100万 | RPC、MQ、幂等、链路追踪 | 分布式系统复杂度 |
容量评估方法:每个阶段扩容前,需做压力测试。例如,在分库分表阶段,通过JMeter模拟10万QPS,观察各分片的CPU、连接数、GC频率,以确定是否需要增加分片数。经验法则:当单库QPS超过5000或连接数超过80%时,考虑进一步分片,而非盲目加机器。
6. 核心收获与下一步行动
从万级到百万QPS,核心方法论是“识别瓶颈-局部优化-架构升级”的循环。每一步都有明确的触发条件:连接池耗尽时先加缓存,从库延迟时再考虑分片,线程池瓶颈时才拆分服务。没有一劳永逸的架构,只有适配当前流量特征的平衡。
下一步行动建议:如果系统当前处于万级QPS,优先做缓存分层和读写分离,并建立监控告警(QPS、RT、DB连接数、Redis内存)。在流量到达10万QPS前,不要提前引入分库分表和微服务——那只会增加不必要的复杂度。每一步演进后,必须验证瓶颈是否真正转移,否则可能做了无用功。
本文关键词:高并发、分库分表、缓存分层、微服务化、异步化