三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

单库拆成 8 个分片那年,发布窗口从 20 分钟拉到 3 小时:架构演进的 5 笔隐性成本

单库拆成 8 个分片那年,发布窗口从 20 分钟拉到 3 小时:架构演进的 5 笔隐性成本

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 -n102465535Tomcat + MySQL 共享,必须提高
innodb_buffer_pool_size128M10G单机 16G,给 MySQL 留 60% 左右
Tomcat maxThreads200400业务偏 IO 密集,线程可以多给
MySQL max_connections151500配合 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; } }

逐行说一下关键点。CONTEXTThreadLocal存标记,因为 Tomcat 的线程池会复用线程,如果不用线程隔离,A 请求设置的 slave 标记会被 B 请求读到。clear()这个方法我们第一版忘了写,结果一个后台任务把标记设成 slave 之后没清理,同一个线程后续处理的下单请求全部路由到了从库,写操作直接报The MySQL server is running with the --read-only optiondetermineCurrentLookupKey()是 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 千11180ms48 分钟
读写分离第 2 年3 万14210ms715 分钟
服务化第 3 年12 万518340ms1520 分钟
分库分表第 4 年45 万1240260ms2245 分钟

有两个数字我一直拿来提醒团队。一是服务化之后 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 做二级索引?三种方案在一致性、运维成本、查询性能上各有什么代价?

欢迎在评论区说说你们的选择,尤其是踩过坑的方案。

← 返回列表