title: 单库拆成 8 个分片那年,发布窗口从 20 分钟拉到 3 小时:架构演进的 5 笔隐性成本
tags: [架构演进, 分库分表, 微服务, ShardingSphere, 高可用]
category: 后端
分库分表上线后的第一个大促预演,我们的发布流程从 20 分钟膨胀到了 3 小时。不是因为部署慢,是因为要人肉核对 8 个分片的 DDL 是否都执行到位——其中一个分片因为连接池打满漏执行了一条ALTER TABLE,直到灰度流量打进去报Unknown column 'settle_status'才被发现。
那次之后我把过去四年这套系统的演进路径重新捋了一遍,从一台 8 核 16G 的机器跑 Tomcat + MySQL,到现在 60 多个服务、8 个业务库。每一步升级在架构图上都很好看,但每一步都附带一笔当时没算进去的账。这篇写的就是这些账。
阶段一:单机部署,瓶颈往往不在你以为的地方
最早那版系统很简单:一台 ECS,Tomcat 8.5 + MySQL 5.7 装在同一台机器上,日订单量三千出头。这个阶段大部分人以为的瓶颈是 CPU,实际先撑不住的是文件描述符和磁盘 IO。
我们第一次线上告警是Too many open files。Tomcat 的maxConnections默认 10000,而系统的ulimit -n是 1024。MySQL 和 Tomcat 抢同一份 fd 配额,MySQL 的 InnoDB 又要为每张表保持文件句柄(innodb_file_per_table=ON),双方一起把 1024 吃干净。
这个阶段真正该做的不是拆架构,是把机器配置和参数调对:
| 项目 | 默认值 | 我们调整后 | 说明 |
|---|---|---|---|
| ulimit -n | 1024 | 65535 | Tomcat + MySQL 共享,必须提高 |
| innodb_buffer_pool_size | 128M | 10G | 单机 16G,给 MySQL 留 60% 左右 |
| Tomcat maxThreads | 200 | 400 | 业务偏 IO 密集,线程可以多给 |
| MySQL max_connections | 151 | 500 | 配合 HikariCP 池上限 |
调完这四项,同样的机器扛住了日订单一万二。我不建议在日订单量没到五位数的时候就上微服务,这个阶段的性能问题九成是参数没调对,拆服务只会让你多几个需要调参的地方。
阶段二:读写分离,第一次踩到主从延迟
订单量上到日均三万后,MySQL 的 CPU 开始在晚高峰打到 85%。分析慢日志发现读写比例大概 8:2,于是上了一主两从。
读写分离本身不难,难的是读到旧数据。我们用的是 Spring 的AbstractRoutingDataSource,最初的实现是这样的:
public class DynamicDataSource extends AbstractRoutingDataSource { // 每个线程独立保存自己的数据源标记,避免线程间串数据 private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>(); public static void useSlave() { CONTEXT.set("slave"); } public static void useMaster() { CONTEXT.set("master"); } public static void clear() { // 必须清理,否则线程池复用线程时会带上上一个请求的标记 CONTEXT.remove(); } @Override protected Object determineCurrentLookupKey() { // Spring 每次获取连接前都会调这个方法,返回 null 时走 defaultTargetDataSource String key = CONTEXT.get(); return key == null ? "master" : key; } }逐行说一下关键点。CONTEXT用ThreadLocal存标记,因为 Tomcat 的线程池会复用线程,如果不用线程隔离,A 请求设置的 slave 标记会被 B 请求读到。clear()这个方法我们第一版忘了写,结果一个后台任务把标记设成 slave 之后没清理,同一个线程后续处理的下单请求全部路由到了从库,写操作直接报The MySQL server is running with the --read-only option。determineCurrentLookupKey()是 Spring 的钩子方法,AbstractRoutingDataSource.getConnection()每次都会调用它拿 key,再去targetDataSources这个 Map 里找对应的 DataSource——注意它是每次获取连接时调用,不是每个事务调用一次,所以在一个方法内部切换标记是不生效的(事务已经绑定了连接)。
真正的坑是主从延迟。我们的场景是:用户下单成功后立刻跳转订单详情页,详情页走从库查询,而当时主从延迟平均 80ms,晚高峰能到 600ms。用户看到的是"订单不存在"。
最后的解法不是提高从库性能,而是给写后读加强制主库标记:
@Aspect @Component public class MasterRouteAspect { // 标记了 @ForceMaster 的方法强制走主库 @Around("@annotation(com.xxx.annotation.ForceMaster)") public Object around(ProceedingJoinPoint pjp) throws Throwable { DynamicDataSource.useMaster(); try { return pjp.proceed(); } finally { // 无论成功失败都要清,防止污染线程 DynamicDataSource.clear(); } } }这个切面的价值在于把"哪些查询必须读主库"这件事显式写在代码里,而不是靠约定。我们统计过,全站 1200 多个查询方法里最终只有 37 个加了@ForceMaster,占比 3%——大部分读操作其实容忍得了几百毫秒的延迟,值得为这 3% 单独处理,不值得为它们放弃读写分离。
阶段三:服务化拆分,被低估的是跨服务事务
日订单十万级的时候我们做了第一次服务拆分,按业务域切成订单、商品、库存、支付、用户五个服务,用的是 Spring Cloud Alibaba,Nacos 做注册中心,OpenFeign 做调用。
架构图画出来很漂亮,代价在第二周就来了:原来一个本地事务能搞定的事情,现在要跨三个服务。
下单流程原本是一个@Transactional方法:扣库存、写订单、扣优惠券。拆开之后库存在库存服务、优惠券在营销服务,本地事务失效。我们第一版用的是"先扣库存再写订单,失败了调用补偿接口回滚",看起来没问题,直到某天库存服务的补偿接口自己超时了。
那天的具体数字:15 分钟内产生了 216 笔"库存已扣、订单未生成"的脏数据,人工对账花了两个工作日。
后来改成了本地消息表 + 定时补偿,核心代码是这样:
@Service public class OrderCreateService { @Transactional(rollbackFor = Exception.class) public Long createOrder(OrderCreateCmd cmd) { // 1. 写订单主表,此时状态为 INIT,对外不可见 Order order = Order.init(cmd); orderMapper.insert(order); // 2. 把"扣库存"这个动作作为一条消息写进本地消息表 // 和订单在同一个事务里,要么都成功要么都回滚 LocalMessage msg = LocalMessage.of( "STOCK_DEDUCT", JSON.toJSONString(new StockDeductCmd(order.getId(), cmd.getSkuId(), cmd.getNum())), order.getId()); localMessageMapper.insert(msg); return order.getId(); } }这里最重要的一行是localMessageMapper.insert(msg)和orderMapper.insert(order)在同一个事务里。这样就把"分布式事务"降级成了"本地事务 + 可靠投递":只要订单落库了,扣库存这条消息就一定存在;消息发送失败可以重试,重试失败可以人工介入,但不会出现"库存扣了订单没了"。
投递部分交给一个独立的扫描任务:
@Scheduled(fixedDelay = 1000) public void dispatch() { // 只捞 5 秒前创建、状态仍为 PENDING 的消息 // 留 5 秒缓冲是为了避开事务尚未提交、消息已被扫到的竞态 List<LocalMessage> list = localMessageMapper.selectPending( LocalDateTime.now().minusSeconds(5), 200); for (LocalMessage msg : list) { try { mqProducer.send(msg.getTopic(), msg.getPayload()); localMessageMapper.markSent(msg.getId()); } catch (Exception e) { // 失败不抛出,避免一条坏消息卡住整批 localMessageMapper.incrRetry(msg.getId()); log.warn("dispatch failed, msgId={}, retry={}", msg.getId(), msg.getRetryCount(), e); } } }selectPending里的 5 秒缓冲是我们踩出来的。最早写的是now(),结果定时任务扫到了一条已插入但事务还没提交的记录(MySQL 默认 RR 隔离级别下,另一个连接读不到未提交数据,但我们当时用的是 READ_COMMITTED 且中间有一次连接切换),导致消息发出去了、订单事务却回滚了,下游扣了不存在订单的库存。加 5 秒之后这个问题再没出现过。
catch块里不抛异常是另一个细节。第一版我们让异常往上抛,结果某条 payload 太大(超过 RocketMQ 4.9.x 默认的 4MB 限制)导致每次批处理都在这条上失败,后面 199 条全被堵住,堆积了 4 小时。
阶段四:分库分表,DDL 变成了工程问题
订单表到 1.8 亿行的时候,单表查询即使走索引也要 200ms 以上,ALTER TABLE更是不敢碰。我们按user_id哈希拆了 8 个库、每库 16 张表,用 ShardingSphere-JDBC 5.1.2。
分片规则配置本身两小时就搞定了,真正吃掉三个月的是这些:
| 问题 | 拆分前 | 拆分后 | 我们的处理 |
|---|---|---|---|
| 按订单号查询 | 直接查 | 不带 user_id 无法路由,全库扫 | 订单号里编码 user_id 后 4 位 |
| 分页查询 | limit offset | 需归并 8 库结果 | 改为游标分页,禁止深翻页 |
| 跨用户统计 | 一条 SQL | 无法直接聚合 | 走离线数仓,接受 T+1 |
| DDL 变更 | 1 张表 | 128 张表 | 自研脚本 + 执行结果校验 |
| 事务 | 本地事务 | 跨库不保证 | 强制同一 user_id 内操作 |
开头提到的那次发布事故就出在最后一行 DDL 上。128 张表的 DDL,靠人眼核对执行结果是不现实的。我们后来写了个校验脚本,发布前后各跑一次,比对每个分片的表结构 checksum,不一致直接卡住发布流程。这个脚本上线之后,发布窗口从 3 小时回落到 45 分钟。
订单号编码user_id这个做法值得多说两句。原来的订单号是纯雪花 ID,拆分后按订单号查询无法定位分片。我们的方案是订单号 = 雪花 ID + user_id 后 4 位(做了简单混淆),查询时先解出后 4 位,再定位到具体分片。代价是订单号从 19 位变成 23 位,前端和第三方对接方都要改;收益是按订单号查询从"扫 8 库 128 表"变成"精确路由 1 张表",P99 从 1.4 秒降到 23 毫秒。
复盘:四年演进的真实账单
把这四年的关键数字放在一起看,比架构图有说服力:
| 阶段 | 时间 | 日订单量 | 服务数 | 机器数 | P99 | 团队人数 | 一次全量发布耗时 |
|---|---|---|---|---|---|---|---|
| 单机 | 第 1 年 | 3 千 | 1 | 1 | 180ms | 4 | 8 分钟 |
| 读写分离 | 第 2 年 | 3 万 | 1 | 4 | 210ms | 7 | 15 分钟 |
| 服务化 | 第 3 年 | 12 万 | 5 | 18 | 340ms | 15 | 20 分钟 |
| 分库分表 | 第 4 年 | 45 万 | 12 | 40 | 260ms | 22 | 45 分钟 |
有两个数字我一直拿来提醒团队。一是服务化之后 P99 反而从 210ms 涨到了 340ms,因为一次下单从 1 次本地调用变成了 6 次跨网络调用,每次 RPC 加上序列化和网络往返大概 15-25ms。这个代价是拆分的固有成本,不是优化能完全消掉的。二是团队人数从 7 涨到 22 的同时,人均产出的功能点其实是下降的——沟通成本、联调成本、跨服务排查成本都是新增的。
我的取舍判断
服务拆分的触发条件不该是"数据量大",而应该是"团队协作卡住了"。数据量大有分库分表、有读写分离、有缓存,这些方案都不需要动组织结构。真正需要拆服务的信号是:一次发布要协调三个小组、一个人改代码另外五个人要回归测试、代码库大到 IDE 索引都要五分钟。我们第三年拆服务,回头看时机是对的,因为那时候已经有四个业务小组在同一个仓库里抢发布窗口。
分库分表能拖就拖。它带来的复杂度是全局性的:查询要改、分页要改、统计要改、DDL 要改、监控要改。在上分库分表之前,先看看这三条路走完了没有——冷热数据分离(把两年前的订单归档到历史表)、覆盖索引优化、把统计类查询挪到只读实例。我们真正到 1.8 亿行才拆,现在回头看甚至还能再拖半年。
架构演进不要跳步。我见过团队从单机直接上微服务 + K8s + Service Mesh,结果三个月后连服务为什么 502 都定位不了。每一步演进都会暴露一批新的运维能力缺口,跳步意味着这些缺口一次性全部暴露,团队接不住。
如果你现在日订单量在十万以内,我更推荐"单体 + 读写分离 + 好的模块边界"这个组合。把包结构按业务域切干净,Service 之间只通过接口调用,数据库表按域分组不跨域 join——这套做扎实了,未来真要拆服务的时候,拆分成本会比从一团泥里往外扯低一个数量级。
最后留个问题
假设你的订单表已经 6000 万行,业务方要求支持"按手机号查询该用户全部订单",而分片键是user_id。手机号和 user_id 是一对一的关系,但查询入口只有手机号。你会怎么设计?是维护一张phone -> user_id的映射表放在单独的库,还是做基因法把手机号信息编进 user_id,或者干脆上一套 ES 做二级索引?三种方案在一致性、运维成本、查询性能上各有什么代价?
欢迎在评论区说说你们的选择,尤其是踩过坑的方案。