深蓝黑背景品牌封面:居中大标题两行「单表数据量太大,分库分表怎么拆」,副标题「垂直拆分、水平拆分与拆分后的新问题」;下方装饰——一张大表格方块先被竖向切成「常用列」与「大字段」两块(垂直拆分),再被横向切成编号 0 到 3 的四条分片(水平拆分);顶部标注「经典题解析 · 数据库」与「Day 09 · 09-27」 图 1:本文封面,点明「垂直拆分、水平拆分」两条主线。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(见文末来源说明),与任何公司的当前招聘无关。「单表数据量太大怎么办」是数据库方向的架构题,面试官想听的不是背诵「分库分表」四个字,而是你清不清楚拆开的两种方向、以及拆开之后必然冒出来的新问题。

先给核心结论:拆法只有两种——垂直拆分和水平拆分;真正的分水岭不在「怎么拆」,而在「拆完怎么活」:分布式 ID、跨片查询、分布式事务、扩容迁移,这四个新问题必须在拆之前就有预案。

第一步,先问一句:真的非拆不可吗。

拆分是最后手段,不是第一反应。慢查询先查执行计划、加索引、改写法;单表行数多但查询都走索引、响应正常,就没有拆的紧迫性。拆分的代价是永久性的架构复杂度,答题时先把这句说了,反而显得有工程判断力。

第二步,垂直拆分:按业务与字段竖着切。

两个粒度。一是按字段拆表:把大字段(正文、附件)和冷热字段从主表剥离,主表只留高频访问的列,单行变窄,单页能装更多行。二是按业务拆库:订单、用户、库存各自成库,配合服务化拆分。垂直拆分结构清晰、改造平滑,但它解决不了根本问题——行数还在涨,单表容量天花板迟早再撞上。

第三步,水平拆分:按行横着切。

把同一张表的行按路由规则分散到 N 个分片。路由规则主要有两类:取模分片,按 userId 对分片数取模,数据分布均匀,但扩容时模数一变,绝大多数 key 的分片归属都要变,迁移量大;范围分片,按 ID 或时间区间划分,扩容只需追加新区间,但容易产生热点——最新区间永远最热。路由键的选择直接决定系统的生死:选错了,跨片查询会成为常态。

对比加问题清单图:左上垂直拆分——按业务与字段拆:大字段、冷热字段分到不同表,微服务按业务域拆库;好处是结构清晰,上限是行数还在涨。左下水平拆分——按行拆:按取模或范围路由到 N 个分片;好处是容量线性扩展,代价是路由规则一旦定死就很难改。右侧列出拆分后必然要面对的四个新问题:分布式 ID(数据库自增 ID 不能用了)、跨片查询与分页(要汇总多个分片再排序)、分布式事务(一次写入跨多个库)、扩容迁移(翻倍迁移或一致性哈希)。底部口诀:能加索引不拆表,先垂直后水平,拆之前先想清楚路由键 图 2:两种拆分的取舍,以及拆分后必然面对的四个新问题。

第四步,用代码把「扩容代价」算给面试官看。

取模分片的迁移比例可以当场算出来。下面这段代码用 Node.js 22.15.0 直接运行(node shard.js):

'use strict';

// 取模分片路由:userId 对分片数取模
function modShard(userId, shardCount) {
  return userId % shardCount;
}

// 演示扩容代价:分片数从 4 扩到 5,统计需要迁移的 key 比例
function migrationRatio(keys, from, to) {
  let moved = 0;
  for (const k of keys) if (modShard(k, from) !== modShard(k, to)) moved++;
  return moved / keys.length;
}

// 范围分片路由:按 ID 区间划分
const ranges = [
  { min: 0, max: 1_000_000, shard: 0 },
  { min: 1_000_000, max: 2_000_000, shard: 1 },
  { min: 2_000_000, max: 3_000_000, shard: 2 },
];
function rangeShard(userId) {
  const hit = ranges.find((r) => userId >= r.min && userId < r.max);
  return hit ? hit.shard : -1;
}

const keys = Array.from({ length: 10_000 }, (_, i) => i + 1);
console.log('取模分片 4 -> 5 扩容,需要迁移的 key 比例:',
  (migrationRatio(keys, 4, 5) * 100).toFixed(1) + '%');

for (const id of [42, 999_999, 1_000_000, 2_500_001]) {
  console.log(`userId=${id} -> 取模(4 片): 分片 ${modShard(id, 4)},范围分片: 分片 ${rangeShard(id)}`);
}

实测输出:

取模分片 4 -> 5 扩容,需要迁移的 key 比例: 80.0%
userId=42 -> 取模(4 片): 分片 2,范围分片: 分片 0
userId=999999 -> 取模(4 片): 分片 3,范围分片: 分片 0
userId=1000000 -> 取模(4 片): 分片 0,范围分片: 分片 1
userId=2500001 -> 取模(4 片): 分片 1,范围分片: 分片 2

4 片扩到 5 片,80% 的 key 要搬家——这就是为什么扩容方案(翻倍迁移、一致性哈希)必须在拆分第一天就想好。

易错点清单。

一,上来就谈拆分,不提「先优化索引与 SQL」这个前提。二,垂直拆分当成终极方案,意识不到行数增长没被解决。三,只说取模分片,讲不出扩容时 key 大规模迁移的代价。四,路由键凭直觉选,不从查询模式出发——高频查询带什么条件,路由键就该是什么。五,对拆分后的四个新问题(分布式 ID、跨片查询、分布式事务、扩容迁移)没有预案,被追问即穿帮。

回到面试现场,建议的答题节奏:先给「能加索引不拆表」的前提,再讲垂直与水平两个方向及各自上限,然后用路由规则对比引出扩容代价,最后主动列出拆分后的四个新问题和应对思路。AI 业务的会话记录、样本库、日志表增长极快,这套拆解思路几乎原样适用。

如果面试官追问「拆分之后要做一个全局排行榜,数据散在八个分片里,怎么查」,你会给出哪几种方案?欢迎写出你的思路。

参考资料

  • Node.js v22.x 官方 API 文档(文中代码运行环境为 Node.js 22.15.0,Array.from、Number.prototype.toFixed 等行为以此为准) — https://nodejs.org/docs/latest-v22.x/api/