分库分表的取模算法策略,怎么避免数据倾斜?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 基础掌握度:面试官不光想知道你会不会写 hash(key) % N,更想知道你分片键该怎么选取模算法的优缺点是什么,而不是只会抄一个公式。

  2. 问题意识:能不能识别出 “数据倾斜” 这类典型的分库分表陷阱,并且知道它为什么会发生——是分片键选错?基数太小?还是业务热点?

  3. 实战经验:生产中真正遇到过倾斜的场景是怎么解的?是调整分片键,还是引入一致性哈希,或者搞了冷热分离?能不能说出具体的取舍

核心答案

先一句话定个调:取模分片本身是均匀的,但 “均匀” 只是数学上的均匀,真正的倾斜来源是分片键分布不均、热点 key、以及扩容方式不当。避免倾斜主要从四个方向入手:

方向 关键做法
分片键选择 选区分度高、分布均匀的字段(user_id、订单号)
Hash 算法优化 标准 hash 用 MD5/MurmurHash 取末几位,别用业务字段直接取模
扩容方式 分片数按 2 的幂扩容,或改用一致性哈希
热点治理 大 V / 热点商家单独分片、加随机后缀打散

下面细说。

深度解析

一、取模分片的本质与倾斜来源

取模分片的公式很朴素:

// 伪代码:分片定位
int shardIndex = hash(shardKey) % shardCount;

理论上,只要 shardKey 分布均匀、hash 函数够散,落到每个分片的数据量应该是大致相等的。但现实骨感,倾斜通常来自这三类:

数据倾斜来源
数据倾斜来源

数据倾斜来源
数据倾斜来源

上图把倾斜来源拆成了三大类,每类对应不同的应对手段:

  • 分片键分布不均:这是最常见也最致命的,比如用 性别 分片,无论怎么取模都只有 0/1 两个值,必然倾斜到某两个分片上
  • Hash 散列性不足:直接拿业务字段(比如订单号的最后两位)取模,可能因为业务规则导致某些值聚集
  • 业务热点:分片是均匀的,但某个大 V 用户单表数据量就顶别人 1000 个用户,这种是单分片内部倾斜

二、避免倾斜的具体策略

1. 选对分片键(最关键的一步)

分片键要满足两个硬条件:区分度高 + 分布均匀

场景 推荐分片键 反例
用户中心 user_id 性别、注册时间
订单系统 user_idorder_id 订单状态、支付方式
IM 消息 sender_idconversation_id 消息类型

这里有个经典坑:很多人喜欢用 create_time 做分片键,觉得按时间分方便归档。但同一个时间窗口的订单会全部集中到同一张表,写入热点严重。除非是冷热分离场景,否则别单独用时间分片。

2. Hash 算法的优化

直接 shardKey.hashCode() % N 在很多场景下不够散,更稳妥的做法是:

// 推荐做法:用 MD5 散列后取末几位
String hashSource = String.valueOf(userId);
byte[] md5 = MessageDigest.getInstance("MD5").digest(hashSource.getBytes());
// 取 MD5 结果的后几位作为分片依据
int shardIndex = Math.abs(md5[md5.length - 1]) % shardCount;

ShardingSphere 默认就支持 MD5MurmurHash 等多种散列算法,说白了就是让原本相邻或相似的分片键,经过 hash 后尽量分散开。

3. 扩容时的倾斜治理

取模分片最大的痛点是扩容,从 4 库扩到 6 库,几乎 75% 的数据都要迁移。两种主流解法:

取模分片扩容
取模分片扩容

分片翻倍扩容
分片翻倍扩容

  • 翻倍扩容:始终让分片数保持在 2 的幂(4、8、16、32),每次扩容都是翻倍,数据迁移量稳定在 50%。这是工业界用得最多的方案。
  • 一致性哈希:把整个 hash 空间想象成一个环,节点变化时只影响相邻段的数据。迁移量最小,但实现复杂,且容易在小规模场景下分布不均(需要加虚拟节点)。

4. 热点数据的特殊处理

这是取模分片无法解决的问题。比如某个大 V 的 user_id = 10086,不管你怎么取模,他自己的数据永远落在同一个分片上。如果这个用户的数据量是其他用户的 1000 倍,那这个分片就是倾斜的。

常用解法:

  • 拆表 + 加随机后缀:给热点 key 加上随机后缀(比如 10086_010086_110086_9),强制打散到 10 个分片
  • 独立分片:把大 V 用户单独路由到一组独立分库(实际就是按 uid 做白名单路由)
  • 冷热分离:活跃数据进热库,历史数据归档到冷库(HBase、TiDB 等)

热点 key 打散
热点 key 打散

5. 引入二级分片

单表数据量极大的场景,用 “分库 + 分表” 两层结构:

// 一级:按 user_id 分库
int dbIndex = userId.hashCode() % 16;
// 二级:在库内再按 create_time 分表
int tableIndex = (int)(orderId % 4);

这样既能保证用户维度聚合在同一个库,又能让单库内的订单按时间分散到多张表,避免单表数据量爆炸

面试高频追问

  1. 追问一:如果分片键选错了,已经上线了怎么办?

    • 老老实实做双写灰度迁移:新表用新分片键写入,老表继续读;异步把历史数据补齐到新表;切换流量;最后下线老表。整个过程可能持续 1-2 个月,做好回滚预案。
  2. 追问二:一致性哈希和取模分片怎么选?

    • 数据规模稳定、扩容频率低 → 取模分片(简单可控)
    • 节点经常变动、需要动态扩缩容 → 一致性哈希(迁移量小)
    • 阿里、美团的内部中间件很多用预分片 + 路由表,相当于把两种方案的优点都拿了。
  3. 追问三:分库分表后,跨分片的查询怎么做?

    • 能避免就避免(业务设计上就规避)。实在要查,就广播表(小表如字典表,每个分片都冗余一份)或者异构索引(用 ES、HBase 做二级索引)。

常见面试变体

  • “分库分表都有哪些分片策略?各自适用什么场景?”
  • “为什么一致性哈希能减少扩容时的数据迁移?”
  • “分库分表后如何处理跨库 JOIN 和分页?”
  • “如何评估分库分表的数量?分多少合适?”

记忆口诀

四字诀键、hash、倍、热

  • :分片键要选区分度高、分布均匀的
  • hash:用 MD5/MurmurHash 散列,别裸取模
  • :扩容翻倍,迁移量可控
  • :热点单独治理,别指望取模解决一切

总结

一句话:取模分片看似简单,但真正的功力在分片键的选择和热点治理上。把 “选对键、用对 hash、翻倍扩容、热点单独处理” 这四条记住,再结合实际业务场景给出取舍,面试官追问到哪一层你都能接住。