分布式数据管理这个词,第一次听到的人容易往"大数据平台"上想,其实它解决的是一个特别朴素的问题:一张表的数据量涨到单台数据库扛不住的时候,怎么让业务代码少改、甚至不改,照样读写。我在几个中等规模的业务系统里做过这件事,从最早的手写分库分表路由,到后来把路由、事务、扩容、监控抽成一套统一的数据访问层,中间踩的坑比写的代码多得多。这篇就把这套 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(同规格压测) | 4200 | 780 | 3600 |
| P99 延迟 | 18ms | 96ms | 34ms |
| 数据一致性 | 强 | 强 | 最终 |
| 故障恢复复杂度 | 低 | 高 | 中 |
看完这张表我们就定了调子:核心资金类操作走两阶段提交,但必须限流并配超时兜底;非资金类全部走最终一致。而且两阶段提交只用在真正必要的两三个接口上,不做通用能力。
3.3 最终一致的落地:本地消息表 + 定时对账
我们采用的是最土但最稳的方案——本地消息表。业务操作和消息记录写在同一个本地事务里,事务提交后由后台线程异步投递消息,消费方幂等处理。消息表就在业务库里,跟着分片走,不额外引入中间件。
具体流程是这样的:下单扣库存时,在订单库的本地事务里同时写一条t_message记录(状态 = 待投递),事务提交成功后,一个独立的投递线程扫表,把消息发到消息队列,收到确认后把状态改成"已投递";库存服务消费到消息后扣减库存,写一条消费记录去重,然后回执。中间任何一步失败,定时任务会在 30 秒后重试,最多重试 16 次,超过次数进入人工处理队列。
这套方案的短板是延迟不可控,正常 200ms 内完成,异常时可能到分钟级。所以业务上必须能接受"库存晚一点扣"。如果不能接受,那就老实走两阶段提交。
一个容易忽略的点:消息表的清理策略。我们一开始没设清理,三个月后这张表 8000 万行,扫表越来越慢。后来改成按天分区,保留 7 天,超期归档,扫表耗时从 2 秒降到 30ms。
3.4 幂等设计:去重表要建在哪个库
最终一致必须有幂等兜底。我们的做法是建一张t_consume_log,以message_id为主键,消费前先插入,插入冲突就说明已经处理过,直接返回成功。这张表必须和业务表在同一个物理库,才能放在同一个本地事务里保证原子性,否则又引入一个分布式问题。
主键冲突的处理要注意:不同数据库对唯一键冲突的报错码不一样,有的返回 1062,有的返回 23505,SDK 里要做统一识别,别用字符串匹配错误信息,那个太脆弱。我们封装了一个DuplicateKeyException,各种底层错误码统一映射过去。
4. 核心实操:从建表到读写链路完整跑通
4.1 环境与依赖准备
先说环境。我们用的是 4 个物理库实例,每个实例配 1 主 2 从,8 核 32G,SSD。应用侧是 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;注意这里的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(总行数)。
// 归并排序核心逻辑(简化示意) PriorityQueue<Row> heap = new PriorityQueue<>( Comparator.comparing(r -> r.getCreateTime(), Comparator.reverseOrder()) ); for (ResultSet rs : shardResults) { if (rs.next()) heap.offer(readRow(rs)); } List<Row> 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 前面加/* app=order-service,shard=ds_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% 再去做功能,因为出问题的永远是前者。