- 文档
- 教程
- 后端
【免费下载链接】JCSprout
👨🎓 Java Core Sprout : basic, concurrent, algorithm
导读
当单表数据量膨胀到千万甚至亿级,数据库读写开始成为系统瓶颈时,水平/垂直拆分是互联网架构中最直接的应对手段。本文以 MD/DB-split.md 为核心骨架,结合仓库内 docs/db/sharding-db.md 的真实分表实践、MD/ID-generator.md 的分布式 ID 方案以及相关算法源码,系统讲解水平拆分与垂直拆分的适用场景、路由规则设计、拆分后的事务一致性难题(两段提交与最终一致性)及其落地要点。读完本文,你将掌握从"判断是否该拆"到"选择拆分维度、设计 ID 方案、迁移数据、保障事务一致性"的完整方法论。
一、什么时候需要考虑数据库拆分
原文开篇给出一个清晰判断标准:
当数据库量非常大的时候,DB 已经成为系统瓶颈时就可以考虑进行水平垂直拆分了。
这句话有两层含义:数据量大与DB 成为系统瓶颈必须同时成立。也就是说,如果只是单表数据多,但读写仍然在可接受范围内,并不急于拆分——拆分本身会引入跨表查询、分布式事务、数据迁移等一系列复杂度,属于典型的"用架构复杂度换单机性能"的权衡。
在 docs/db/sharding-db.md 记录的实战案例中,生产环境的背景是:多张单表突破亿级数据,且每天保持 200W+ 行新增,部分关联查询和报表统计"一个查询功能需要跑好几分钟",同时 MySQL 所在主机内存占用高、负载居高不下,吞吐量明显下降。这正是拆分介入的典型时机。
需要强调的是,拆分并非唯一出路。该实战案例的第一步其实是运维层面的"临时方案":对用户产生的日志型数据(业务上非强相关、两三个月后不再实时查询),通过"改旧表名加_190416bak后缀、新建同名空表"的方式快速把单表数据量降下来。这个不太优雅但极其高效的过渡手段提醒我们:在动手分库分表之前,先确认这些数据是否真的还需要被实时查询,归档、冷热分离往往成本更低。
另外,该案例还暴露了一个极具代表性的"技术债":原表没有任何可排序的索引,导致无法快速筛选迁移数据,想加索引也要花数小时。这个教训在文末总结中被列为重要结论——每张表都应保留一个可用于排序查询的字段(自增 ID 或创建时间)。
二、水平拆分:按 ID 取模、按时间、按范围
水平拆分是"拆行":把一张表的数据按规则分散到多张表结构完全相同、数据不同的表中。原文给出了三种主流分片规则。
2.1 按 ID 取模(Hash 取模)
一般水平拆分是根据表中的某一字段(通常是主键 ID)取模处理,将一张表的数据拆分到多个表中。这样每张表的表结构是相同的但是数据不同。
取模拆分的核心路由公式可以概括为:
int index = hash(sharding字段) % 分表数量; // 例如分 64 张表,表名形如 busy_0 ~ busy_63 select xx from 'busy_' + index where sharding字段 = xxx;实战案例中的具体做法是:由于物联网业务中每条数据都包含设备唯一标识IMEI,且 IMEI 天然唯一、绝大多数业务都按它查询,因此直接选用 IMEI 作为 sharding 字段。更进一步,IMEI 本身就是唯一整型,直接用它做 mod 运算即可,省去了 hash 环节:
int index = imei % 分表数量;这里需要特别指出一个容易被忽略的细节(实战案例中明确强调):分表数量应取 2 的 N 次方。因为在取模分表的方式下,如果今后需要再次扩容分表,2^N 的基数可以尽量减小受影响的数据范围,扩容对存量数据的重分布影响最小。
2.2 按时间分表
原文指出:不但可以通过 ID 取模分表,还可以通过时间分表,比如每月生成一张表。
实战案例补充了时间分表的适用前提:业务上不需要查询历史数据(比如只查询近三个月的数据)时,完全可以用时间分表,按月份划分,改动简单,历史数据也好迁移——查询时只需要拼接好对应的表名即可。
反之,如果所有历史数据都可能被查询,时间分表就行不通了(除非允许遍历所有分表),这时应回到哈希分表。两种策略的取舍本质上是"查询模式"决定的。
2.3 按范围分表
原文还给出第三种思路——范围分表:
按照范围分表也是可行的:一张表只存储
0~1000W的数据,超过之后再进行分表,这样分表的优点是扩展灵活,但是存在热点数据。
范围分表的特点是:新数据集中在最新的一张表上,写入有明显热点;但扩展时只需新增表、无需移动旧数据,扩展灵活。
2.4 三种策略对比
| 分片策略 | 路由规则 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| ID 取模(哈希) | hash(id) % N | 数据分布均匀,无写入热点 | 扩容/缩容需重新分布数据 | 所有数据都可能被查询 |
| 时间分表 | 按月份等时间区间拼接表名 | 实现简单,历史数据易迁移归档 | 非时间维度查询需扫全部表 | 只查询近期数据 |
| 范围分表 | 按 ID/值区间划分 | 扩展灵活,无需迁移旧数据 | 最新表存在写入热点 | 数据可明确按区间划分 |
2.5 分表后查询的代价
原文特别提醒:分表之后查询比以前复杂,通常不建议join,一般做法是做两次查询(先在各自分表查,再在应用层聚合)。这与 MD/DB-split.md 在垂直拆分章节中给出的建议一致——多表查询依然建议使用两次查询而非 join,因为跨库/跨表 join 在分布式环境下代价极高且难以保证一致性。
实战案例进一步补充了非 sharding 字段查询带来的"全表扫描"问题:任何分片方案都无法避免利用非 sharding 字段导致的全表扫描。应对思路包括:
- 修改底层查询时检查是否走分片字段,若不是,评估是否可以调整业务;
- 引导产品重新审视"上亿数据是否真的需要分页查询、日期查询"这类需求;
- 报表统计类需求可用多线程并行查询各分表再汇总提高效率;
- 对"千万表中仅占几千上万条的特殊类型数据"(如投诉消息需逐条分页处理),建议单独建表维护,不要与大数据量数据混在一起分片,否则分页和 like 查询都会非常棘手。
三、垂直拆分:按字段拆主表与扩展表
垂直拆分是"拆列":当一张表的字段过多时,将其拆分为主表 + 扩展表。
原文给出的拆分原则是:
通常是将一张表的字段拆分为主表以及扩展表,使用频次较高的字段在一张表,其余的在一张表。
也就是说,垂直拆分的目标是让高频访问的字段尽量集中在小表上,减少单行数据的 IO 体积,提高缓存命中率和查询效率;低频的大字段(如文本内容、日志详情)放入扩展表按需加载。
垂直拆分同样不建议使用join,依然建议做两次查询:先查主表拿到业务记录,再按需到扩展表取补充字段。
从架构演进角度看,垂直拆分往往是水平拆分的前置步骤:先按业务模块把单一库拆成多个库(微服务化),再对仍然膨胀的单表做水平拆分。这正对应仓库 docs/_sidebar.md 中把 DB 拆分与分库分表、SQL 优化归入同一知识域的组织方式。
四、拆分之后最突出的问题:事务如何保证
拆分之后由一张表变为了多张表,一个库变为了多个库,原本单库事务能保证的原子性被打破了。原文点出最突出的问题——事务如何保证,并列出了两条路径:两段提交(2PC)与最终一致性。
4.1 两段提交(2PC)
两段提交是经典的分布式事务强一致性协议,其核心思想是引入一个协调者(Coordinator),将事务提交拆成两个阶段:
- 准备阶段(Prepare):协调者向所有参与者(各分库)发送 prepare 请求,各参与者执行本地事务并写 undo/redo 日志,但不提交,向协调者返回"可以提交"或"准备失败";
- 提交阶段(Commit/Abort):协调者收集所有参与者的投票——若全部返回"可以提交",则广播 commit,各参与者正式提交本地事务;若任一参与者返回失败或超时,则广播 abort,各参与者回滚本地事务。
2PC 能保证分布式事务的原子性(要么全提交、要么全回滚),但代价是同步阻塞(准备阶段参与者持有锁等待协调者决策)、协调者单点风险,以及"协调者与参与者之间网络分区导致的未知状态"。因此 2PC 通常适用于对强一致性要求高、并发不极端的跨库事务场景。
4.2 最终一致性 + 消息补偿
原文给出的另一条路线更贴合互联网业务的主流实践:
如果业务对强一致性要求不是那么高,那么最终一致性则是一种比较好的方案。
其典型落地模式是通过消息队列(MQ)做补偿回滚。原文以"A 调用 B,两个都执行成功才算最终成功"为例描述了完整流程:
- A 本地事务先成功(如订单创建);
- 通过 MQ 通知 B 执行后续事务(如扣减库存);
- 若 B 执行失败,B 通过 MQ 将失败消息回发给 A;
- A 收到消息后执行回滚(如取消订单)。
这个方案成立的前提是:A 的回滚操作必须是幂等的——因为 MQ 的消息可能重复投递,若 B 重复发送失败消息,A 重复执行回滚操作不能产生副作用(如重复退款、重复扣库存)。幂等性通常通过唯一业务号、状态机、去重表等手段保证。
该思路与仓库中分布式、消息中间件相关文档(如 docs/distributed/Distributed-Limit.md、docs/frame/kafka-product.md)讨论的"异步解耦 + 重试补偿"思想一脉相承:用最终一致性换取系统的可用性与扩展性。
五、分库分表的配套能力:分布式 ID 生成
原文在水平拆分章节中明确指出一个关键配套问题:取模分表后,新增数据时需要一张临时表来生成 ID,再根据生成的 ID 取模计算写入哪张表;同时给出更优雅的方案——使用分布式 ID 生成器。
也可以使用分布式 ID 生成器来生成 ID(原文档此处指向 MD/ID-generator.md)。
仓库中 MD/ID-generator.md(另见 docs/distributed/ID-generator.md)系统比较了四种方案:
1. 基于数据库自增(auto_increment)利用 MySQL 自增属性生成全局唯一 ID,且能保证趋势递增;但强依赖 DB,数据库挂了 ID 生成就不可用。改进方式是水平拆分:A 库递增0,2,4,6,B 库递增1,3,5,7,提高可用性且保持趋势递增;缺点是扩容困难(步长已定,新库难以加入),且任一库宕机就无法绝对递增。
2. 本地 UUID本地生成、无网络开销、效率极高;但无序、不能趋势递增,且是字符串、不适合做 MySQL 主键。
3. 本地时间毫秒时间戳 + 业务 ID 拼接,可趋势递增且本地生成效率高;致命缺点是高并发下唯一性无法保证。
4. Twitter 雪花算法(Snowflake)基于 Twitter Snowflake 算法,本质是一种划分命名空间的方案,将 ID 按机器、时间等维度标志,在分布式场景下既能全局唯一又能趋势递增,是分库分表场景下最主流的选择。
实战案例在分表时同样面临"不能再依赖单表自增主键"的问题,并给出了三类可选方案:时间戳+随机数(满足大部分业务)、UUID(生成简单但不可排序)、雪花算法(统一生成主键 ID),最终由团队按实际业务取舍。
六、实战视角:一次真实的分表上线全流程
理论之外,docs/db/sharding-db.md 记录了一次从决策到上线的完整分表实践,可作为本主题最生动的佐证。以下是其关键环节的梳理:
6.1 中间件选型
团队调研过MyCAT与sharding-jdbc(现已升级为ShardingSphere),最终出于对开发的友好性及不增加运维复杂度,决定在 JDBC 层做 sharding。由于历史原因(底层是自己封装的连接 jar 包)不便直接集成sharding-jdbc,因此基于 sharding 特点自实现了分表策略:
int index = hash(sharding字段) % 分表数量; select xx from 'busy_' + index where sharding字段 = xxx;即"算出表名 → 路由过去查询"。需要客观说明的是:该实现只修改了所有底层查询方法,每个方法内部做一次路由判断,并没有像sharding-jdbc那样完成SQL解析 → SQL路由 → 执行SQL → 合并结果的完整代理流程——这是受限于现有技术条件的快速实现,并非推荐自研替代成熟中间件。团队后续的计划正是逐步迁移到sharding-jdbc。
6.2 分表数量与业务改造
考虑到业务发展,团队将拆分的表定为64 张,配合后续大数据平台足以应对数年的增长;并再次强调分表数量取 2 的 N 次方的必要性。由于没有使用低侵入的第三方组件,每个涉及分表的业务方法都需要改造底层查询(路由到正确的表),通过全局搜索表名逐一修改,并反向推导受影响的业务记录下来用于回归测试。
6.3 数据迁移与上线验证
上线前的核心步骤是数据迁移:编写独立程序将老表数据按分片规则复制到新的 64 张表中。由于生产数据已达亿级、且老表缺少可排序索引,迁移耗时极长,最终只能与产品协商:短期(可能持续几天)内部分历史数据查询不到,且只能在凌晨迁移以避开白天数据库负载高峰。
验证阶段的取巧做法是:将原表表名加后缀,测试过程中观察前后台是否报错,即可提前发现"改漏了仍查询原表"的问题——因为一旦上线产生生产数据到新表后再修复就非常麻烦了。
6.4 总结出的关键结论
该实践最终沉淀出三条极具普适性的经验:
- 好的产品规划非常有必要:在合理的时间对数据处理(分表或归档),远比数据失控后再补救成本低;
- 每张表都需要一个可排序查询字段(自增 ID、创建时间):缺失该字段导致整个迁移耽搁了很长时间;
- 分表字段需要谨慎:要全盘考虑业务情况,尽量避免出现查询扫全表的情况。
七、延伸:从取模到一致性哈希
对于"如何把数据均匀分散到各节点、且加减节点时受影响数据最少"这一问题,仓库 MD/Consistent-Hash.md(另见 docs/algorithm/Consistent-Hash.md)给出了与取模分表互补的进阶方案。
Hash 取模(index = hash(key) % N)可以满足数据均匀分配,但容错性和扩展性较差:增加或删除节点时,所有 Key 都需要重新计算,成本很高。
一致性哈希将哈希值空间组织成0 ~ 2^32-1的环,把节点(用 IP、hostname 等唯一字段 hash)和 Key 都映射到环上,按顺时针方向将 Key 定位到最近的节点。这样:
- 容错性:某个节点宕机时,只有落在该节点与其逆时针相邻节点之间的数据被重新映射,其余数据不受影响;
- 扩展性:新增节点时,只有该节点与相邻节点之间的数据受影响;
- 虚拟节点:节点较少时会出现数据分布不均,可通过为每个节点生成多个虚拟节点(如 IP 加编号多次 hash)改善均匀性。
将一致性哈希与本文的取模分表对照理解,可以得出一个重要认知:取模分表适合分片数量基本稳定(2^N)的场景,一致性哈希适合节点频繁增减的分布式缓存/存储场景——两者的选择取决于业务对"节点弹性"的要求。同时,该文档再次印证了本主题的通用判断:"当我们在做数据库分库分表或者是分布式缓存时,不可避免的都会遇到如何将数据均匀分散到各个节点"这一问题。
八、相关索引与查询优化
分库分表解决了数据量问题,但查询效率还依赖索引设计。仓库 docs/db/MySQL-Index.md 从 B+ Tree 结构出发解释了索引原理:所有数据存放在叶子节点,非叶子节点仅存索引项与指针;查询 IO 次数由树高决定,而树高由磁盘块大小与数据项大小决定——索引字段要尽可能小,从而降低树高、减少 IO。
这恰好呼应本文实战案例中的技术债教训:老表缺乏可排序索引导致迁移查询耗时数小时。分库分表之前先保证基础索引建设,配合 docs/db/SQL-optimization.md 中的索引使用原则,才能让拆分后的系统真正跑得起来。
总结
本文以 MD/DB-split.md 为主线,完整覆盖了数据库水平垂直拆分的核心决策链:
- 拆分时机:数据量大且 DB 成为瓶颈时,才引入拆分复杂度;
- 拆分维度:水平拆分按 ID 取模 / 时间 / 范围三种规则选型,垂直拆分按字段使用频次拆主表与扩展表;
- 查询策略:跨表查询不做 join,用两次查询在应用层聚合;
- 事务保障:强一致走两段提交,弱一致走 MQ 补偿 + 幂等回滚的最终一致性;
- 配套能力:分布式 ID(雪花算法等)替代单表自增,分表数量取 2 的 N 次方;
- 落地经验:数据迁移、上线验证、可排序索引与分片字段选择是决定成败的细节。
如果希望继续深入,可在仓库中依次阅读:docs/db/sharding-db.md(真实分表实践)、docs/distributed/ID-generator.md(分布式 ID 方案)、docs/algorithm/Consistent-Hash.md(一致性哈希)、docs/db/MySQL-Index.md 与 docs/db/SQL-optimization.md(索引与 SQL 优化)。
- 文档
- 教程
- 后端
【免费下载链接】JCSprout
👨🎓 Java Core Sprout : basic, concurrent, algorithm
相关推荐
searx数据库分表策略:水平拆分与垂直拆分实践
searx数据库分表策略:水平拆分与垂直拆分实践 1. 引言:分表策略的重要性 在当今数据爆炸的时代,数据库性能成为影响应用响应速度的关键因素。对于searx这
后端搜索引擎60px 预加载:Vue无限滚动在仿抖音项目中的实现拆解
60px 预加载:Vue无限滚动在仿抖音项目中的实现拆解 给长列表做分页时,“加载下一页”按钮常带来两个问题:页面跳动、滚动位置丢失。开源项目 douyin(V
前端移动开发短视频score_sde_pytorch实战教程:从CIFAR-10到1024px高分辨率图像生成
score_sde_pytorch实战教程:从CIFAR 10到1024px高分辨率图像生成 score_sde_pytorch是一个基于PyTorch实现的分
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考