从分库分表到在线扩容:自建DDM系统的设计与实操 分布式数据管理这个词第一次听到的人容易往大数据平台上想其实它解决的是一个特别朴素的问题一张表的数据量涨到单台数据库扛不住的时候怎么让业务代码少改、甚至不改照样读写。我在几个中等规模的业务系统里做过这件事从最早的手写分库分表路由到后来把路由、事务、扩容、监控抽成一套统一的数据访问层中间踩的坑比写的代码多得多。这篇就把这套 DDM 系统的设计思路和实操过程摊开讲包括分片键怎么选、路由算法为什么最后没用一致性哈希、跨分片事务怎么取舍、扩容时数据怎么迁、以及上线后最容易被忽略的几个排查点。适合正在被单库容量卡住的后端同学也适合想了解分布式数据管理底层逻辑的初中级开发看完至少能少走两三个月的弯路。1. 为什么自建DDM把需求拆到能落地为止1.1 三个绕不开的痛点决定了系统的形状做任何中间层第一步都不是画架构图而是把痛点列清楚。我们当时面对的情况很典型订单主表 4 亿行、明细表 12 亿行单实例磁盘占用逼近 2TB慢查询从每天几条变成每天上千条主从延迟在高峰期能到 8 秒以上。这是第一个痛点叫单库容量与写入天花板靠加索引、加缓存、归档历史数据只能续命续不了两年。第二个痛点是跨库操作的复杂度外溢。一旦手工拆成 8 个库业务代码里到处是if (userId % 8 3)这样的判断新来的同学改一个查询要翻三个工具类测试环境还没法完整复现。这种复杂度如果不在数据访问层收敛最后一定演变成谁都不敢动的祖传代码。第三个痛点是扩容时的停机焦虑。早期我们用的是取模分片从 4 库扩到 8 库等于全量数据重分布必须停写窗口。业务方给的窗口只有凌晨 40 分钟实际迁移跑了 6 个小时那次事故之后我们就下定决心分片方案必须把可在线扩容当成硬指标。提醒一句如果你现在的数据量还在千万级以内QPS 不到 2000先老老实实做索引优化和冷热分离别急着上分布式。引入 DDM 的运维成本和排障成本是要用人力去换的。1.2 中间件、SDK、数据库原生能力三条路怎么选市面上大方向就三种。代理型中间件业务把它当单库连它在网络层解析协议转发好处是对业务零侵入、语言无关坏处是多一跳网络、SQL 兼容性要自己填坑复杂查询容易踩到不支持的语法。客户端 SDK 型以 jar 包形式嵌在应用里直接改 SQL 再发出去性能损耗小、功能可以做得更细代价是版本升级要推动所有应用跟着升多语言支持基本没有。数据库原生分区改动最小但分片键固定、跨分片查询能力弱扩容依然是大问题。我们最后选的是SDK 轻量代理混合Java 主链路走 SDK压测下来比代理模式少了约 0.6ms 的 P99 延迟少量非 Java 的报表服务走代理接受那点多一跳的开销。这个组合的判断依据只有一条——主链路延迟敏感边缘链路人力敏感。方案业务侵入单次调用额外延迟跨语言复杂SQL支持升级成本代理型无0.5~1.5ms好中低SDK型中换驱动0.05~0.2ms差高高原生分区无近乎为零好低中表里这几个数字是我们在同规格机器、同等并发下压出来的量级参考不是绝对值但相对关系比较稳定选型时可以直接拿来当锚点。1.3 先划边界这套系统明确不做什么这一点特别重要很多自研项目死在什么都想做。我们给 DDM 划了四条不做的事不做跨分片的强一致事务只做最终一致加补偿不支持跨分片的 JOIN需要关联的字段做冗余或者走离线宽表不做跨分片的深度分页超过 1000 页的翻页直接引导用户改筛选条件不做运行时的分片键变更分片键一旦定下改动等价于重建。把不做什么写进设计文档比写做什么更能保护项目。后来有业务方要求支持按时间范围跨全部 shard 聚合我们指着文档拒绝了改成了异步导出。如果当时松口这套系统大概率会变成一个四不像的查询引擎。2. 整体架构与核心模块拆解2.1 四层结构接入、路由、执行、元数据整套系统我按职责切成四层。接入层负责伪装成标准数据库驱动接管连接池、事务边界、超时控制业务方看到的还是一个普通的 DataSource。路由层是核心做 SQL 解析、分片键提取、路由计算、SQL 改写、结果归并。执行层管物理连接、并发控制、失败重试、熔断降级。元数据层存逻辑表到物理表的映射、分片规则、读写权重、灰度开关。分层的价值在于变更隔离。举个实际例子我们后来要给某张表加一个新的分片算法从哈希改成范围只动了路由层的算法插件和元数据配置执行层和接入层一行没改。如果当初写成一个大泥球这种变更就得整体回归。路由层内部又拆成三个阶段解析 - 计算 - 改写。解析阶段用词法分析拿到表名、字段、where 条件计算阶段找出分片键的值算出目标分片集合改写阶段把逻辑表名替换成物理表名把limit 10 offset 100这类分页语句按分片重写。这三步任何一步出错表现都是结果不对但原因天差地别所以每一步都要有独立的开关和日志。2.2 元数据中心配置存哪、怎么下发、怎么防错元数据看起来只是几行配置实际是整个系统的单点。我们的做法是把配置存到配置中心采用版本号 灰度分组的方式下发每次变更生成一个递增版本先推给 1% 的实例观察 5 分钟错误率和延迟没异常再全量。客户端本地还留一份快照配置中心挂了就用快照启动保证不会因为配置服务抖动导致全站起不来。元数据的具体内容分三类。第一类是逻辑表定义逻辑表名、分片键字段、分片算法、分片数量、物理库列表。第二类是读写拓扑主库地址、从库地址列表、权重、是否强制走主。第三类是运行时开关是否开启 SQL 改写、是否允许全路由、慢 SQL 采样率、灰度实例列表。这里有个血泪教训。早期我们把分片数量写死在客户端代码里配置中心只下发库地址。有一次运维调整库数量配置和代码对不上路由算出来的物理表名根本不存在报了一堆table not found。后来改成分片数和算法全部由元数据驱动客户端启动时校验分片数 × 每库表数 物理表总数不匹配直接启动失败把错误暴露在启动阶段而不是运行阶段。# 逻辑表元数据示例通用 YAML 结构 tables: - logicTable: t_order shardKey: user_id algorithm: hash_mod shardCount: 16 tablePerShard: 4 databases: [ds_0, ds_1, ds_2, ds_3] columns: order_no: { type: varchar, length: 32 } user_id: { type: bigint } amount: { type: decimal, precision: 18, scale: 2 }2.3 分片键怎么选三个判断标准和一次真实翻车分片键选错后面所有优化都是徒劳。我总结三个判断标准高基数取值要足够分散性别、状态这种字段绝对不行、查询必带80% 以上的核心查询要能带上它、写入均匀不能有明显的热点。我们第一次选的是order_no理由是订单号唯一、基数高。跑了三个月出问题用户查我的订单列表时条件是user_id完全用不上分片键每次都要广播到全部 16 个分片再归并P99 从 80ms 涨到 600ms。这就是典型的分片键和查询模式不匹配。改回user_id之后又出现第二个问题少数大客户单日订单量能到几万全落在同一个分片上形成热点。解决办法是二次分片——user_id取哈希定库order_no后四位取模定表把同一用户的数据在库内再打散到 4 张表。代价是按用户查订单列表要归并 4 张表但因为库内查询快实测 P99 只增加了 12ms换来的是写入热点被打散值。2.4 路由算法对比为什么最终放弃了取模和一致性哈希路由算法主流三种我们全部试过。取模哈希shard hash(key) % N。优点是均匀、实现简单、计算零成本。致命缺点是 N 变化时几乎全部数据要迁移从 4 扩到 8理论上 75% 的数据位置会变。我们早期就是栽在这。一致性哈希把节点和键都映射到环形空间扩缩容只影响相邻区间。听起来很美实际用起来有两个坑一是需要虚拟节点才能均匀虚拟节点数一般要开到物理节点的 100~200 倍元数据管理变复杂二是范围查询彻底没戏同一个用户区间的数据散落在环上各处按时间范围扫描等于全路由。预分片 范围一开始就分成固定数量的槽位比如 1024 个每个物理库领若干槽位。扩容时只需迁移槽位不动哈希算法。范围查询也能落到连续槽位上。我们最终采用的是预分片哈希先hash(user_id) % 1024得到一个槽位号再查槽位到物理库的映射表。库数量从 4 扩到 8 时只需要把 512 个槽位从老库搬到新库迁移量是 50%而且可以一批一批搬。槽位映射表存在元数据中心客户端缓存变更时推送。这里的关键取舍是牺牲一点计算开销换扩容的确定性和范围查询的可能性。一次哈希加一次映射表查找实测耗时在微秒级相对于一次数据库往返可以忽略。3. 数据一致性与分布式事务的落地取舍3.1 先分清三种场景别一上来就上分布式事务很多人一提分库就想到分布式事务其实大部分操作根本不需要。我们把写操作分成三类。第一类单分片写比如创建订单分片键就是 user_id一条 SQL 落一个库天然本地事务占我们全部写操作的 92%。第二类同库多表写比如订单主表和明细表分片键一致落在同一个物理库用本地事务就够了。第三类跨分片写比如转账、扣库存 建订单这类才需要额外机制处理占比不到 8%。这个比例的判断很重要它决定了你愿意为跨分片事务付多少代价。如果跨分片占比超过 30%说明分片键设计有问题应该回头改模型而不是硬上两阶段提交。3.2 两阶段提交的真实代价一次压测数据说话我们在测试环境完整跑过两阶段提交。同一个跨库转账操作本地事务 TPS 能到 4200两阶段提交只有 780下降 81%。原因有三多了一次网络往返、需要持有锁到第二阶段结束、协调者本身是单点。更麻烦的是故障场景协调者在第二阶段提交前挂掉参与者全部处于已准备未提交状态持有行锁其他事务进来直接超时几分钟内就能把连接池打满。指标本地事务两阶段提交最终一致消息TPS同规格压测42007803600P99 延迟18ms96ms34ms数据一致性强强最终故障恢复复杂度低高中看完这张表我们就定了调子核心资金类操作走两阶段提交但必须限流并配超时兜底非资金类全部走最终一致。而且两阶段提交只用在真正必要的两三个接口上不做通用能力。3.3 最终一致的落地本地消息表 定时对账我们采用的是最土但最稳的方案——本地消息表。业务操作和消息记录写在同一个本地事务里事务提交后由后台线程异步投递消息消费方幂等处理。消息表就在业务库里跟着分片走不额外引入中间件。具体流程是这样的下单扣库存时在订单库的本地事务里同时写一条t_message记录状态 待投递事务提交成功后一个独立的投递线程扫表把消息发到消息队列收到确认后把状态改成已投递库存服务消费到消息后扣减库存写一条消费记录去重然后回执。中间任何一步失败定时任务会在 30 秒后重试最多重试 16 次超过次数进入人工处理队列。这套方案的短板是延迟不可控正常 200ms 内完成异常时可能到分钟级。所以业务上必须能接受库存晚一点扣。如果不能接受那就老实走两阶段提交。一个容易忽略的点消息表的清理策略。我们一开始没设清理三个月后这张表 8000 万行扫表越来越慢。后来改成按天分区保留 7 天超期归档扫表耗时从 2 秒降到 30ms。3.4 幂等设计去重表要建在哪个库最终一致必须有幂等兜底。我们的做法是建一张t_consume_log以message_id为主键消费前先插入插入冲突就说明已经处理过直接返回成功。这张表必须和业务表在同一个物理库才能放在同一个本地事务里保证原子性否则又引入一个分布式问题。主键冲突的处理要注意不同数据库对唯一键冲突的报错码不一样有的返回 1062有的返回 23505SDK 里要做统一识别别用字符串匹配错误信息那个太脆弱。我们封装了一个DuplicateKeyException各种底层错误码统一映射过去。4. 核心实操从建表到读写链路完整跑通4.1 环境与依赖准备先说环境。我们用的是 4 个物理库实例每个实例配 1 主 2 从8 核 32GSSD。应用侧是 4 台 8 核 16G 的机器跑 Java 服务。客户端 SDK 版本必须和元数据格式版本对齐这一条写进了启动检查。依赖上只有两个硬要求一个是 SQL 解析器我们用了开源的词法分析库自己写语法树一个是配置中心的客户端。连接池用的 HikariCP每个物理库的连接数按物理核数 × 2 有效磁盘数估算8 核 SSD 大概 20 个4 个库就是 80 个连接再加上从库读连接单实例总连接数控制在 150 以内别让应用把数据库连接打满。# 应用侧数据源配置通用结构 ddm: app-name: order-service metadata: server: config-center:8848 namespace: ddm-prod failover-snapshot: /data/ddm/snapshot.json datasource: pool: max-size: 20 min-idle: 5 connection-timeout-ms: 3000 idle-timeout-ms: 600000 route: allow-full-route: false # 禁止无条件全路由 full-route-threshold: 8 # 分片数超过8时必须带分片键 slow-sql-ms: 2004.2 分片规则配置与物理表初始化配置的核心是逻辑表 - 分片算法 - 物理位置这条链。我们约定物理表命名规则是t_order_${dbIndex}_${tableIndex}比如t_order_0_2表示第 0 个库的第 2 张表。这个命名规则必须严格因为路由层要靠它拼表名拼错了就是运行时报错。物理表的初始化我们写了个脚本按元数据自动生成 DDL。以 16 分片、每库 4 表为例一共 64 张物理表脚本循环生成每张表的 DDL 除了表名后缀完全一致。这里要注意自增主键不能用因为多张表各自自增会冲突必须换成全局 ID 生成器。-- 物理表模板实际生成 64 张表名按规则替换 CREATE TABLE t_order_0_0 ( id BIGINT NOT NULL COMMENT 全局ID, order_no VARCHAR(32) NOT NULL COMMENT 业务单号, user_id BIGINT NOT NULL COMMENT 分片键, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME(3) NOT NULL, update_time DATETIME(3) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_time (user_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这里的uk_order_no是库内唯一不是全局唯一。如果业务要求订单号全局唯一得靠全局 ID 生成器保证或者额外建一张全局索引表。我们用的是前者。4.3 一次查询的完整执行链路拿一个具体查询走一遍看清楚每一步在干什么。业务代码写的是SELECT order_no, amount, status FROM t_order WHERE user_id 10086 AND create_time 2024-05-01 ORDER BY create_time DESC LIMIT 20;第一步解析。词法分析拿到表名t_order、字段列表、where 条件里的user_id 10086和create_time ...、排序字段、limit 20。第二步计算路由。查元数据t_order的分片键是user_id算法是hash(user_id) % 1024得到槽位再查槽位映射表得到物理库ds_2。库内再按hash(user_id) % 4定表得到t_order_2_1。因为分片键是等值条件路由结果是单分片不需要归并。第三步改写。把逻辑表名替换成物理表名SELECT order_no, amount, status FROM t_order_2_1 WHERE user_id 10086 AND create_time 2024-05-01 ORDER BY create_time DESC LIMIT 20;第四步执行。从连接池取一个ds_2的从库连接读操作走从库执行返回结果。如果 where 条件里没有user_id呢路由层会发现无法定位这时候有两种策略当allow-full-route false且分片数超过阈值时直接抛异常强制开发改查询否则广播到全部 64 张表用线程池并发执行再把结果按create_time归并排序最后取前 20 条。归并排序这里有个细节不能把所有结果全捞到内存再排要用堆归并每个分片维护一个游标用优先队列取最小的内存占用是 O(分片数 × limit)而不是 O(总行数)。// 归并排序核心逻辑简化示意 PriorityQueueRow heap new PriorityQueue( Comparator.comparing(r - r.getCreateTime(), Comparator.reverseOrder()) ); for (ResultSet rs : shardResults) { if (rs.next()) heap.offer(readRow(rs)); } ListRow topN new ArrayList(20); while (topN.size() 20 !heap.isEmpty()) { topN.add(heap.poll()); // 从该行所属的分片结果集补一行进来 }这个limit的改写很容易出错。如果写成limit 20直接下发到每个分片每个分片返回 20 条归并后取前 20结果是正确的。但如果业务写的是limit 100 offset 200就必须改写成limit 300下发归并后再丢弃前 200 条否则结果会错。这个坑我们踩过一次线上出现了翻到第 3 页数据重复的问题排查了半天才发现是 offset 没处理。4.4 读写分离的权重配置与延迟兜底读请求默认走从库但主从延迟会导致刚写完查不到。我们的处理是写后 1 秒内强制走主库在同一个线程上下文里写操作完成后打一个标记后续 1 秒内的读请求强制路由到主库。这个方案简单有效代价是写后立即读会占用主库连接所以只对写后读场景生效普通查询不受影响。从库权重按机器规格配我们 2 个从库是 1:1。如果某个从库延迟超过阈值比如 3 秒自动降权到 0等延迟恢复再逐步加回来每次加 10%避免瞬间打满。配置项取值说明读主库延迟窗口1000ms写后该窗口内读走主从库延迟降权阈值3000ms超过则权重归零权重恢复步长10% / 30秒渐进恢复防雪崩主库连接占比上限30%超过则拒绝新的强制走主请求最后一行是关键保护。如果业务出现大批量写后读主库连接会被瞬间占满所以必须设上限超过就降级为走从库接受短暂的数据不一致总比整个库不可用强。4.5 扩容实操从 4 库到 8 库的完整步骤扩容是这套系统最考验设计的环节。因为有预分片槽位1024 个从 4 库扩到 8 库只需要把每库的一半槽位迁到新库理论迁移量 50%。第一步扩容前评估。算出待迁移的数据量单库 500GB迁一半是 250GB按每秒 30MB 的迁移速度估算约 2.4 小时。这个速度要提前压测别拍脑袋。第二步建立新库。8 个新实例建好建表脚本跑完确认表结构、索引、字符集完全一致。这一步用脚本比对不能靠肉眼。第三步开启双写。修改元数据让写请求同时写老库和新库新库对应槽位。双写期间以老库为准新库写失败记录日志不影响主流程但要监控失败率。第四步全量迁移历史数据。用迁移工具按槽位分批搬每批 1 万行搬完校验行数和 checksum。校验用抽样 全量计数结合全量 checksum 太慢我们只对关键字段做抽样比对抽样比例 1%。第五步增量追平。双写期间新产生的数据如果在新库写失败靠 binlog 订阅补偿。等延迟降到 1 秒以内进入下一步。第六步切换读流量。这是最危险的一步。先切 1% 的读流量到新库观察错误率和延迟 10 分钟没问题再按 10%、50%、100% 逐步切。每步都要有回滚开关一键切回老库。第七步停双写、下线老库槽位。观察 24 小时无异常后停掉双写把老库里对应的槽位标记为只读一周后归档删除。阶段预计耗时关键监控指标回滚方式建库建表30min表结构一致性直接删除双写持续双写失败率 0.1%关闭开关全量迁移2.5h迁移速率、校验差异重跑增量追平1~3h同步延迟 1s停止追平读流量切换2h错误率、P99一键切回停双写下线24h 观察老库无写入恢复双写整个过程业务无感知但操作窗口我们留了整整两天因为任何一步出问题都要时间回滚宁可慢不可乱。5. 常见问题与排查技巧实录5.1 分片键缺失导致的全路由雪崩这是线上最常见的故障。表现是某条 SQL 突然变慢数据库 CPU 打满然后整个链路雪崩。根因通常是新来的同学写了个不带分片键的查询或者用了in但里面是别的字段。排查方法打开路由层日志看route_result字段如果显示FULL_ROUTE且分片数大于 8基本可以定位。我们的做法是上线前静态扫描CI 阶段跑一遍 SQL 检查凡是查询逻辑表但 where 条件不含分片键的直接构建失败。这条规则救了我们至少五次。有个例外要放行后台管理系统的导出功能确实需要全量扫描这类请求走独立的只读库并且限流到 5 QPS绝不允许走主链路。5.2 分布式事务的悬挂与空回滚两阶段提交在异常场景下有两个经典问题。空回滚协调者在发送 prepare 之前就挂了参与者没收到 prepare但后来收到了 rollback此时它什么都没做回滚会失败。解决方法是回滚时先检查本地事务记录不存在就直接返回成功。悬挂参与者先收到 rollback因为网络乱序执行完回滚然后才收到迟到的 prepare这时候如果正常执行 prepare就会留下一个永远无法提交的事务。解决方法是在参与者本地记录事务状态收到 prepare 时先查如果已经回滚过就拒绝。这两个问题我们的处理是统一建一张t_transaction_log以全局事务 ID 为主键记录 prepare、commit、rollback 三个状态所有分支操作前先查状态。表按全局事务 ID 哈希分片和业务库在同一个实例减少跨库查询。5.3 全局 ID 生成的性能与时钟回拨全局 ID 我们用的是雪花算法变体41 位时间戳 10 位机器号 12 位序列号单机每秒可生成 400 万左右。踩过的坑有三个。时钟回拨机器 NTP 同步时时间往回跳生成器会报错。处理方式是检测到回拨小于 5ms 就等待大于 5ms 直接抛异常拒绝服务宁可短暂不可用也不能生成重复 ID。机器号冲突早期手工分配两台机器配了同一个号上线后主键冲突。后来改成启动时向注册中心申请申请不到就启动失败。序列号耗尽同一毫秒内超过 4096 个请求序列号用完生成器会等到下一毫秒这时候延迟会尖刺。压测时我们发现了这个问题QPS 4000 以上 P99 会从 3ms 跳到 12ms。解决办法是每个线程预取一批 ID 缓存在 ThreadLocal 里用完了再批量取把竞争从每毫秒降到每 1000 个 ID。5.4 连接池耗尽与慢查询的连锁反应有一次线上告警应用侧连接池活跃连接 100%所有请求超时。排查发现是一条跨分片查询广播到 64 张表每张表都慢缺索引64 个查询并发出去瞬间把连接池占满。这里的教训是全路由查询必须限流。我们后来加了两道闸一是全局信号量全路由请求最多同时 4 个二是单次查询超时 3 秒超时直接返回部分结果并标记不完整。这两道闸上线后同类故障再没出现过。排查顺序我整理成了一张表遇到问题按顺序过一遍基本能定位。现象优先排查常见根因处理动作某接口 P99 突增路由日志全路由、缺索引加分片键或索引连接池打满活跃连接分布慢查询占住连接kill 慢 SQL、加超时主从延迟拉大主库写入量大事务、批量写拆批、控事务大小数据查不到写后读时间差主从延迟强制走主窗口主键冲突ID 生成器日志时钟回拨、机器号冲突重启校验、重新分配扩容后部分数据丢槽位映射表映射未全量推送强制刷新客户端缓存5.5 几个文档里不会写的小技巧技巧一在 SQL 里加注释标记来源。我们的 SDK 会自动在改写后的 SQL 前面加/* apporder-service,shardds_2 */这样在数据库的慢查询日志里一眼就能看出是哪个应用、哪个分片排查效率提升非常明显。技巧二保留一份影子路由。每次路由计算出一个结果同时用旧算法再算一遍两者对比不一致就记日志。这个机制在算法切换期间帮我们发现了三个边界 case比如user_id为 0 和负数时的哈希行为。技巧三灰度按用户 ID 取模。新版本 SDK 上线时按user_id % 100 5切 5% 流量到新版本比按机器灰度更均匀因为分片和用户的对应关系不变数据不会串。技巧四给每个物理库配一个体检 SQL。上线前跑一次select count(*) from 每张表记录基线之后每天对比某张表行数异常比如突然少了一半能第一时间发现比等着业务报错强。6. 监控、压测与上线节奏6.1 指标体系只盯四个黄金信号不够常规的延迟、流量、错误、饱和度四个信号是基础但分布式数据管理有几个特有指标必须单独盯。路由分布每个分片的请求量占比正常应该接近均匀某个分片超过 30% 就说明有热点。全路由比例全路由请求占总请求的比例我们控制在 0.5% 以内超过就告警。双写差异迁移期间老库和新库的写入条数差正常应该为 0。归并内存跨分片查询归并时占用的堆内存超过 200MB 要预警。采集方式上我们用客户端埋点 数据库侧采集双管齐下。客户端埋点拿得到路由结果和改写后的 SQL数据库侧拿得到真实的执行计划和锁等待。两边用 trace id 关联出问题时能从一条慢 SQL 反查到具体是哪个应用、哪次路由。6.2 压测怎么做才有意义压测最容易犯的错是只压 QPS 不压数据量。我们的做法是先用生产数据的 1/10 建压测库4000 万行然后按三种模式压模式一纯写。模拟下单分片键随机分布测写入 TPS 和 ID 生成器瓶颈。结果发现 4000 QPS 时 ID 生成器成为瓶颈于是加了 ThreadLocal 预取。模式二纯读。模拟查询80% 带分片键、20% 不带测路由和归并的开销。这个模式下 P99 从 80ms 涨到 340ms主要开销在归并排序后来优化了堆归并的初始容量降到 180ms。模式三混合 故障注入。一边跑 7:3 的读写混合一边随机 kill 一个从库、模拟网络延迟 500ms、让配置中心返回超时。这个模式最接近真实帮我们发现了两个问题从库挂掉后连接池没有及时剔除节点导致 30% 请求超时配置中心超时后客户端没有走本地快照直接启动失败。压测时长我们至少跑 2 小时因为很多问题比如连接泄漏、内存增长要一小时后才显现。6.3 灰度上线与回滚预案上线节奏我们分了四步。第一步单机灰度只在一台机器上装新版本 SDK跑 24 小时看错误率和延迟有没有异常。第二步小流量灰度按用户 ID 取模切 5%观察 48 小时。第三步半数灰度切 50%重点看数据库侧的连接数和慢查询有没有变化。第四步全量。每一步都必须有明确的回滚触发条件比如错误率超过 0.5% 持续 3 分钟、P99 超过基线 2 倍持续 5 分钟。回滚不是改配置重启而是一个开关秒级生效。这个开关我们在压测时就反复演练过确保真出问题时手不抖。回滚也有个坑如果新版本已经写了新格式的数据比如新加了一个字段回滚到老版本可能读不了。所以任何带数据格式变更的版本必须先做向后兼容新字段允许为空老版本读到时忽略。这条规则我们写进了发布 checklist每次发版必查。我在几个项目里反复验证下来分布式数据管理这件事技术方案本身没有太多秘密难的是边界意识和演练习惯。分片键、路由算法、事务模型这些东西文档上都能查到但真正决定系统能不能稳的是你有没有在需求进来时敢于说这个不支持有没有在变更前把回滚开关按住不放有没有把每一个应该不会出问题的地方都压测一遍。我们那套系统跑了两年多最惊险的一次故障不是架构缺陷而是一个新同学在配置中心里多删了一行槽位映射导致 1/16 的数据瞬间查不到。后来我们给配置变更加上了双人复核和影响面预览才算把这类人为风险摁住。如果让我给正在做类似系统的同行一句建议那就是把 70% 的精力花在元数据管理、灰度机制和监控告警上剩下的 30% 再去做功能因为出问题的永远是前者。