☰
分库分表与DDM实战:分片键、分布式事务与不停机扩容
2026/10/29 4:04:54 网站建设 项目流程

凌晨两点二十分,监控面板上一条曲线毫无征兆地竖了起来,某个业务库的写入延迟从 8ms 直接飙到 1200ms,连接池瞬间打满,紧接着一连串的接口超时告警刷了屏。值班的同学第一反应是慢 SQL,查了半天没找到元凶,最后发现是当天下午刚上线的一个批量任务,单表日增量从 200 万直接冲到 1800 万,索引重建把 IO 吃干抹净。这种场景我见得太多了,几乎所有团队在业务爬到某个量级之后,都会被迫面对同一个问题:单库单表已经撑不住当前的访问模型,必须上分布式数据管理系统,也就是大家常说的 DDM。

这篇文章想聊的就是分布式数据管理系统的设计与实战。它不是一个"装个中间件就完事"的活,从分片键怎么定、事务怎么兜底、扩容怎么不停机,到跨分片查询怎么妥协、上线后怎么观测,每一步决策都会在未来半年到一年里持续产生利息或者债务。适合正在评估要不要做分库分表的架构同学看,也适合已经上了 DDM 但正被各种奇怪问题折磨的一线开发看。我会把每一步的选择逻辑讲透,包括那些文档里不会写、只有真正踩过才知道的细节。

1. DDM 要解决的核心矛盾不是"数据量大"

很多人对分布式数据管理的第一印象是"单表过千万就要分",这个理解其实是偏的。数据量大只是表象,真正把系统压垮的是访问模型和数据量之间的错配。我见过单表三亿行但 QPS 只有几十的归档库,跑得稳稳当当;也见过单表八百万行、每秒上万次点查的业务库,天天在崩溃边缘试探。所以判断要不要上 DDM,得先看清楚瓶颈到底在哪一层。

1.1 单库真正撑不住的三条曲线

第一条是写入吞吐曲线。单机 MySQL 的写入能力受限于 redo log 刷盘、binlog 同步和锁竞争,在常规硬件上单实例稳定写入大概在每秒几千行的量级,超过这个水位,写入延迟会以非线性方式上升。第二条是索引深度曲线。B+ 树每增加一层,就多一次随机 IO,当单表数据量突破某个阈值,热数据无法全部驻留内存,查询延迟的 P99 会出现明显的台阶式跳变。第三条是DDL 与运维窗口曲线,表越大,加字段、建索引、做数据修复的耗时越长,业务可用的维护窗口根本不够用。

这三条曲线里,最先撞墙的往往不是第一条。我之前参与过一个订单系统,日订单量不到 300 万,写入压力其实还好,但由于运营后台要做多维度的报表查询,历史数据攒了两年多,单表 6 亿行,一个带三个条件的聚合查询能跑 40 秒,把从库的 IO 全占了,直接影响到主链路的主从同步延迟。这种情况下的解法不是简单分表,而是要把在线交易数据和离线分析数据在存储层面拆开。

判断是否上 DDM,先量化三类指标:单表行数的增长斜率、峰值 QPS 与写入 TPS、以及最慢的 Top 10 SQL 的执行耗时分布。三个指标里有两个逼近瓶颈,才值得动手。

1.2 中间件式与原生分布式数据库的分水岭

确定要上之后,第一个岔路口是方案形态。市面上大致分两类:一类是在应用和数据库之间架一层分片中间件,应用侧感知的是逻辑库,中间件负责 SQL 解析、路由改写、结果归并;另一类是原生分布式数据库,把分片、副本、事务都做进存储引擎内部。

维度中间件式方案原生分布式数据库
改造成本应用基本无感,改连接串即可需要评估 SQL 兼容性与驱动
运维复杂度底层仍是常规数据库,运维经验可复用需要新的运维体系与故障处理预案
跨分片事务需要中间件或业务层自行编排引擎内建,通常对应用透明
弹性扩缩容依赖人工迁移流程多数支持自动再均衡
适用场景已有大量存量业务、希望平滑过渡新建系统、团队愿意接受新栈

我的经验是,存量系统改造优先考虑中间件式,因为业务代码改动面小,风险可控;新建系统如果团队有精力,原生分布式数据库长期维护成本更低。但这里有个容易被忽略的点:中间件式方案会把 SQL 兼容性的坑转移到中间件层,一些带子查询、窗口函数、复杂 JOIN 的语句可能不支持或者语义有偏差,上线前必须做全量 SQL 的兼容性扫描,这个工作量往往被严重低估。

2. 分片键定错,后面每一步都在还债

如果只允许我给一条建议,那就是:分片键的选择值得花两周时间反复推演。我见过太多团队为了赶进度,拍脑袋按自增主键哈希分片,结果上线三个月后发现 90% 的查询都带商户维度,每次都要广播到所有分片,性能比不分片还差。分片键一旦上线,改造成本几乎等于重做一遍系统。

2.1 分片键的四个硬约束

选分片键的时候,我一般会拿四个条件去卡:

  • 高区分度:分片键的取值要足够分散,避免大量数据落到同一个分片。比如按省份分,一线城市的数据量可能是西部某些区域的几十倍,天然倾斜。
  • 查询必带:核心链路的查询条件里必须包含分片键,否则就会退化成全分片扫描。这一条最容易被忽视,因为业务初期的查询模式往往和一年后完全不同。
  • 不可变:分片键的值一旦写入就不应该再改,否则就要跨分片迁移数据。用手机号做用户表分片键,用户换号就是一场灾难。
  • 分布均匀且可预期:既要现在均匀,也要未来均匀。用自增 ID 取模看似均匀,但扩容时几乎要全量搬迁。

实际项目里很难找到同时满足四条的字段,通常要做取舍。订单表我一般会选商户 ID 或者买家 ID,取决于查询以哪一侧为主;如果两侧查询都很重,那就得考虑双写两份数据,用空间换查询效率,这个后面会细说。

2.2 路由算法:取模、范围、一致性哈希的真实差异

路由算法决定了数据怎么落到具体分片,也决定了扩容时的迁移量。

取模最简单,shard = hash(key) % N,分布均匀,但 N 变化时几乎所有数据都要重新分布,扩容等于全量重刷。范围路由按区间切分,比如按时间或者 ID 段,扩容方便,新数据直接进新分片,但容易产生热点,最新的分片永远压力最大。一致性哈希把节点和键映射到同一个环上,扩容只影响相邻区间的数据,迁移量小,但实现复杂,且需要处理虚拟节点来保证均衡。

我实际用得最多的是预分片 + 范围路由的组合:一开始就规划好远大于当前需求的分片数,比如现在只需要 4 个库,但按 64 个逻辑分片来规划,每个物理库承载 16 个逻辑分片。扩容时只需要把部分逻辑分片整体搬到新库,迁移粒度可控,路由表也简单。

# 逻辑分片到物理库的映射关系示意 shards: - logical: 0-15 physical: db_order_0 - logical: 16-31 physical: db_order_1 - logical: 32-47 physical: db_order_2 - logical: 48-63 physical: db_order_3 routing: algorithm: range_with_pre_split key: merchant_id logical_shard: hash(merchant_id) % 64

这套配置的好处是,扩容时只需要修改映射表,把某几个逻辑分片指向新库,然后做数据搬迁,应用层的路由逻辑完全不用动。

2.3 全局唯一 ID 与写入热点的连锁反应

分片之后自增主键就废了,必须引入全局 ID 生成方案。常见的有号段模式、雪花算法、数据库多主步长自增。号段模式从数据库批量取一段 ID 缓存在内存里,性能好且趋势递增,对 B+ 树友好,是我比较推荐的默认方案。

但这里有个坑:趋势递增的全局 ID 会让写入集中在最新的一段。如果分片键恰好也是这个 ID,那么所有新写入都会打到同一个分片,形成写入热点,其他分片闲着。解决办法是把 ID 生成和分片键解耦,或者在 ID 里混入分片信息,让生成出来的 ID 天然分散。

// 号段模式的核心逻辑,双缓冲避免取号阻塞 public class SegmentIdGenerator { private volatile Segment current; private volatile Segment next; private final AtomicBoolean loading = new AtomicBoolean(false); public long nextId() { long id = current.next(); if (current.remaining() < current.threshold()) { // 异步预加载下一段,不阻塞当前请求 if (loading.compareAndSet(false, true)) { executor.submit(() -> { next = fetchFromDb(); loading.set(false); }); } } if (current.exhausted()) { synchronized (this) { if (current.exhausted()) { current = next; } } } return id; } }

这段代码的关键在于双缓冲 + 异步预加载。当当前号段剩余量低于阈值时就触发下一段的加载,正常请求永远走内存,不会因为取号而阻塞。踩过的坑是:如果数据库短暂不可用,预加载失败,必须要有重试和降级策略,否则号段耗尽后整个写入链路直接挂掉。我们当时的做法是本地预留一段应急号段,数据库恢复前先用它顶着。

3. 分布式事务:先问业务能不能容忍中间态

分片之后,一次跨两个分片的写操作就不再是本地事务了。这时候团队最常见的反应是要强一致,上两阶段提交。我一般会先反问一句:这个业务的失败中间态,用户能感知到吗,能容忍多久?答案往往决定了方案选型,而不是反过来。

3.1 四类方案的适用边界对照

方案一致性性能开销实现复杂度典型场景
两阶段提交强一致高,锁持有时间长中资金扣减、库存锁定
TCC最终一致中高,需写三个方法跨服务的长流程业务
本地消息表最终一致低低下单后发通知、加积分
Saga最终一致低中,需设计补偿多步骤的编排型流程

我的实际选择顺序通常是:能用本地消息表就不用 TCC,能用 TCC 就不用两阶段提交。原因很实在,两阶段提交在分片场景下协调者会成为单点,一旦协调者挂掉,参与者会长时间持锁,整个链路雪崩。只有在资金这类绝对不能出现中间态的场景,才值得付出这个代价。

3.2 本地消息表的落地细节

本地消息表是我在绝大多数场景下的首选,结构简单、可靠性高。核心思路是把"业务写库"和"消息落库"放在同一个本地事务里,保证原子性,然后由独立的投递任务扫描消息表推送下游。

CREATE TABLE t_local_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT '业务唯一键,用于幂等', topic VARCHAR(64) NOT NULL, payload TEXT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待投递 1已投递 2投递失败', retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_status_retry (status, next_retry_at) ) ENGINE=InnoDB;

投递任务按status = 0 AND next_retry_at <= now()扫描,扫到就推送,成功后改状态。重试必须用指数退避,否则下游故障时会把消息表扫穿。我一般设成 1s、5s、30s、2min、10min 这样递增,超过 8 次进入人工介入队列。

3.3 幂等和补偿才是真正决定成败的地方

分布式事务的方案本身其实不难,难的是下游的幂等。消息重投是必然会发生的,网络抖动、消费者重启、超时重试都会导致重复消费。所以每个消费方都必须实现幂等,最可靠的方式是用业务唯一键建唯一索引,让数据库来兜底。

-- 消费方幂等表,靠唯一索引保证只处理一次 INSERT INTO t_consume_record (biz_id, consumer, created_at) VALUES (?, 'order_point_consumer', NOW()); -- 唯一键冲突说明已处理过,直接返回成功,不要报错

至于补偿,我的原则是补偿逻辑必须比正向逻辑更简单。如果补偿需要重新计算一大堆状态,那说明这个方案设计得有问题。Saga 模式里最容易翻车的就是补偿不彻底,比如扣了库存但没释放优惠券,最后对账时发现账不平。所以每设计一步补偿,我都会问:这一步失败了,前面所有步骤的逆向操作是不是都能确定地执行?

4. 不停机扩容:一次完整的迁移链路复盘

扩容是 DDM 绕不过去的坎,也是最容易出事故的环节。我参与过一次从 4 库扩到 8 库的迁移,整个过程持续了六周,真正搬数据只用了两天,剩下时间全在验证和灰度上。这个比例我觉得是合理的,急着切流的基本都会翻车。

4.1 扩容前的容量估算

扩容不是拍脑袋加机器,得先算清楚三个数:当前数据总量和增长斜率、单分片的目标水位、迁移期间的双倍写入压力。我一般把单分片的数据量控制在 5000 万行以内,单库磁盘使用率不超过 60%,留出足够的 buffer 应对突发增长和 DDL 操作。

假设当前 4 个库共 4 亿行,月增长 3000 万行,规划扩到 8 库。那么每个新库初始承接约 5000 万行,按每月 375 万行的增量,大概能撑 13 个月。这个时间窗口要留足,因为下一次扩容的准备周期通常要一到两个月。

4.2 双写、校验、灰度切流的五步法

我用的迁移流程大致是五步:

  1. 开启双写。新老库同时写,老库为准,新库异步写入,失败只记录不阻塞。这一步持续至少一周,用来暴露新库的表结构、索引、字符集等差异问题。
  2. 存量数据搬迁。用分批工具按主键范围扫全表,每批 5000 行,控制速率避免打满 IO。搬迁过程中老库仍在写入,所以要用 binlog 或者时间戳做增量追平。
  3. 全量数据校验。这一步最耗时间但绝不能省。我用的方法是抽样比对加全量行数、校验和对齐,抽样比例不低于 5%,重点覆盖边界数据和最近七天的热数据。
  4. 灰度切读。按商户维度或者用户 ID 哈希,先切 1% 的流量读新库,观察错误率和延迟,逐级放大到 10%、50%、100%。
  5. 切写并保留回滚窗口。读切完稳定运行一周后再切写,切写后老库保持双写状态至少两周,作为回滚保险。

4.3 迁移中最容易翻车的三个点

第一个是自增主键冲突。如果新老库都用自增 ID,双写时同一个逻辑行可能在两边拿到不同的 ID,导致后续更新找不到记录。解决办法是双写期间新库的自增步长做偏移,或者直接用业务主键做唯一约束。

第二个是字符集和排序规则不一致。老库是 utf8,新库建成了 utf8mb4,看着兼容,但索引长度和比较行为会有微妙差异,某些查询的结果顺序会变。这个坑我们排查了整整两天。

第三个是迁移期间的长事务。搬迁工具如果一条语句处理太长时间,会拖慢老库的主从同步。必须严格限制单批次的数据量和执行时间,我一般设成单批不超过 5000 行、单条 SQL 执行不超过 200ms。

迁移期间务必冻结所有 DDL 操作。加一个字段就可能让搬迁工具的字段映射出错,而且这种错误往往在切流后才暴露。

5. 跨分片查询:能力边界要提前告诉业务方

这是 DDM 上线后最集中的矛盾来源。业务方习惯了单库时代随便写 JOIN、随便分页,分片之后这些能力都会打折。我的做法是在项目启动时就输出一份明确的"能力清单",把支持和不支持的操作白纸黑字写清楚,避免后期扯皮。

5.1 广播查询的代价有多大

不带分片键的查询会被路由到所有分片,中间件把结果归并后返回。假设有 64 个分片,一次查询就变成 64 次数据库往返,再加上归并排序的开销,延迟可能是单库的几十倍。而且这种查询会同时占用 64 个连接,并发一高,连接池立刻见底。

我在生产环境见过一次事故:某运营同学写了个不带分片键的模糊查询,还加了ORDER BY create_time DESC LIMIT 10,中间件为了拿到全局有序的前 10 条,必须从每个分片各取 10 条再归并,64 个分片并发查询,直接把数据库的连接数打满,主链路全部超时。后来我们在中间件层加了规则:不带分片键的查询必须显式声明走广播通道,并限制并发数和超时时间。

5.2 分页、排序、聚合的妥协方案

操作类型单库做法分片后的处理方式
深分页LIMIT 100000, 20改为基于游标的分页,用上一页最后一条的排序键做条件
全局排序ORDER BY直接排各分片取前 N 条归并,N 不能太大
COUNT直接统计各分片求和,大表要考虑近似值或预聚合
GROUP BY直接分组各分片局部聚合后再二次聚合
多表 JOIN直接关联尽量改写为两次查询在应用层拼装

深分页是重灾区。LIMIT 100000, 20在单库上就已经很慢了,分片之后每个分片都要扫 10 万行,总体开销是单库的 N 倍。正确做法是改成游标分页:记住上一页最后一条记录的排序键值,下一页查询用WHERE sort_key > last_value ORDER BY sort_key LIMIT 20。这样每个分片只需要回表少量数据,性能稳定。

-- 不推荐:深分页,分片越多越慢 SELECT * FROM t_order WHERE status = 1 ORDER BY create_time DESC LIMIT 100000, 20; -- 推荐:游标分页,各分片独立高效 SELECT * FROM t_order WHERE status = 1 AND create_time < '2024-06-01 12:00:00' ORDER BY create_time DESC LIMIT 20;

5.3 用冗余表和异构索引换查询自由

有些查询模式实在绕不开,比如订单表按商户分片,但用户端要按买家查。这种情况下我一般会建一张冗余表,按买家 ID 分片,数据从主表同步过来,只存用户端需要的字段。写入时双写,读取时各走各的分片键。

代价是存储翻倍、写入放大,但换来的是查询效率的质变。我的判断标准是:如果某个查询模式的调用量占比超过 15%,且无法通过改写绕开,那就值得建冗余表。低于这个比例,用异步导出的宽表或者搜索索引来兜底更划算。

6. 上线之后的观测体系:六个必须盯住的信号

系统上线只是开始,真正的挑战在后面。分布式环境下,故障的传播路径比单库复杂得多,一个分片的慢查询可能演变成全局的超时雪崩。所以观测体系必须提前建好,不能等出问题再补。

6.1 必须埋的指标

我一般会盯这六类指标:分片级别的 QPS 和延迟分布(看是否有热点分片)、连接池的活跃数和等待数(判断是否打满)、慢 SQL 的分片归属(定位是哪个分片的问题)、跨分片查询的占比(评估广播查询的规模)、事务回滚率和补偿触发次数(衡量一致性方案的稳定性)、主从延迟的分片分布(读写分离的正确性依赖这个)。

其中分片级别的延迟分布特别重要。如果只看全局平均延迟,某个分片慢十倍可能被其他正常分片平均掉,问题被掩盖。我做过的监控面板会把每个分片的 P99 单独画线,一旦某个分片偏离其他分片超过 30%,立刻告警。

6.2 连接池与线程模型的坑

分片之后连接数会成倍增长,64 个分片乘以每个分片 20 个连接,就是 1280 个连接。这个量级对数据库来说是不小的负担,而且大部分连接可能长期空闲。

我的做法是:中间件层用较小的连接池配合异步 IO,业务层连接池按实际并发设置,不要盲目调大。曾经有个项目为了"保险起见"把连接池设成 200,结果高峰期数据库的最大连接数被打满,新建连接全部失败,反而比配小一点更糟。

6.3 故障预案与演练

分布式系统的故障模式比单库多得多:某个分片不可用、路由表不一致、中间件节点脑裂、全局 ID 生成器不可用。每一种都要有明确的预案,而且必须演练过。

我参与的一次演练里,故意把一个分片的主库切掉,观察系统的降级行为。结果发现中间件在重试三次后直接返回错误,而这个错误码业务方没有处理,导致前端页面白屏。这个问题的根因不是技术,而是预案没有覆盖到业务侧的降级逻辑。后来我们把每个分片的故障降级策略写进了接口文档,业务方按分片维度做了兜底展示。

演练要在业务低峰期做,并且提前通知所有相关方。我见过一次没打招呼的故障演练,触发了运营的应急预案,惊动了一堆人,非常尴尬。

分布式数据管理这件事,技术方案的选型其实只占三成,剩下七成是流程、验证和沟通。我做过几个项目下来,最大的体会是:不要追求一步到位,先把分片键和事务边界这两件事定清楚,其他问题都可以边跑边修。分片键选对了,后面 80% 的性能问题都不会发生;事务边界定清楚了,数据不一致的排查成本会低一个数量级。至于那些跨分片查询、扩容迁移、监控告警,都是在正确的地基上慢慢补齐的工程活。真正把项目做砸的,从来不是技术难度,而是为了赶进度跳过了验证环节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询