分库分表这个问题,几乎每个后段开发在数据量起来之后都会遇到。我之前维护过一个订单系统,单表数据量到两千多万行的时候,明显感觉到写入变慢,某些统计SQL跑一次要好几十秒,索引优化做到头了也救不回来。当时我们团队就是拿mysql配ShardingSphere做了一次水平拆分,把一张订单表拆到多个库多张表,效果立竿见影。
这篇文章不讲虚的,直接从我的实际落地经验出发,聊聊分库分表的核心思路、为什么选ShardingSphere而不是别的方案、分片键怎么设计、配置怎么写、步骤怎么配,以及我踩过的那些坑。适合准备做数据层扩容的团队、做架构设计的技术负责人,以及正在准备面试想系统理解分库分表的朋友。看完不敢说你能直接照抄,但至少能避开大部分经典的坑。
1. 分库分表到底解决什么问题
1.1 单库单表的瓶颈在哪
很多团队一遇到性能问题就想上分库分表,但说实话,大部分系统的数据量根本没到需要分库分表的程度。先搞清楚mysql单机的天花板在哪儿,才知道要不要走这条路。
单表数据量上去之后,第一个瓶颈是索引。mysql默认的InnoDB引擎用B+树组织索引,数据量到了一定规模,树的高度会从三层变四层。理论上三层能存几千万行数据,但实际中随着随机IO、缓冲池命中率下降,性能退化比想象中来得早。第二个瓶颈是写入,高并发下热点行的锁竞争会很激烈,redo log和binlog落盘也吃IO。第三个瓶颈是连接数,mysql默认最大连接数大概在151左右,应用侧一扩容,连接池很快就把连接数打满了。最后一个容易被忽略的问题是运维,单表几十GB的时候做一次备份和恢复要很久,别说做表结构变更了。
用个生活类比:一个饭店只有一个厨房,菜单再丰富,客流量大的时候灶台不够用、出菜速度就是上不去。分库分表本质上是多开几个厨房,每个厨房只承担一部分订单。
1.2 垂直拆分与水平拆分的区别
分库分表分两种思路。
垂直拆分按业务拆,把订单、用户、商品这些不同业务的表拆到不同的库,配合微服务架构各服务连各自的库,相当于每块业务独享资源。垂直拆分还包括把宽表拆成多张窄表,冷热字段分开,可以减少单页数据量、提升缓冲池利用率。
水平拆分才是真正解决单表数据量过大的手段。同一张订单表,按照某个字段的规则分散到多张结构完全相同的表中,每张表的数据量都降下来了。分库和分表可以组合使用,比如拆成32个库、每个库16张表,一共512张物理表。我的经验是,如果单表超过千万行、日增数据量大、写入并发持续在高位,并且缓存、索引、读写分离这些常规手段已经没有明显收益了,这时候才值得考虑分库分表。
2. 方案选型:为什么是ShardingSphere
2.1 三种主流的实现方式对比
分库分表的落地方案大致分三类。
第一类是应用层自己实现路由,在DAO层用switch-case做表名拼接,这种方案看起来简单,但每个项目都要重复造轮子,跨库查询、分页、分布式事务全是坑。我见过不少老项目就是这种写法,后来想改造都无从下手。
第二类是代理层中间件,独立部署成一个服务,应用改一下数据源地址就行,就像连一个mysql实例一样。典型的如MyCat、ShardingSphere-Proxy。好处是语言无关、对应用侵入小,但运维成本高,链路多了一跳。代理层本身也可能成为新的瓶颈和单点。
第三类是SDK客户端模式,以jar包方式嵌入应用,代表就是ShardingSphere-JDBC。数据源由框架代理,SQL的解析、路由、改写、归并都在应用内完成,不需要额外部署服务。对Java团队来说最友好,性能损耗也最低。
2.2 ShardingSphere凭什么值得选
ShardingSphere是Apache顶级项目,5.x版本统一叫ShardingSphere,包含JDBC和Proxy两种形态。我选它的原因很明确:
社区活跃度是选型时最重要的指标。ShardingSphere的issue响应和版本迭代节奏都很快,而MyCat在1.6版本之后分裂出了几个分支,Bug修复慢,5.x版本的文档也不全,遇到问题基本只能靠Google。ShardingSphere的文档结构清晰,官方有中文版,示例代码也多,学起来省很多时间。
功能完整度上,ShardingSphere内置了分片、读写分离、数据脱敏、分布式事务等多种能力,配置模型从4.x的properties升级到5.x的yaml,可读性好很多。分布式事务这块,它整合了XA强一致事务和Seata柔性事务,基本覆盖了多数场景。
生态整合方面,ShardingSphere-JDBC对Spring Boot支持很到位,引入依赖、写配置就能用,业务代码几乎不用改。我当时接入的时候,数据源只需要从原先的DataSource换成ShardingSphere的配置,Mapper接口一行没改。
表格对比一下三种方式,方便你根据自己的团队情况选:
| 对比维度 | 自研路由 | 代理层(MyCat/Proxy) | ShardingSphere-JDBC |
|---|---|---|---|
| 部署方式 | 应用内 | 独立服务 | 应用内jar包 |
| 侵入性 | 极高 | 低 | 中低 |
| 性能损耗 | 低 | 中 | 低 |
| 跨语言支持 | 不支持 | 支持 | 仅Java |
| 运维成本 | 中 | 高 | 低 |
| 社区生态 | 看团队 | 一般 | 好 |
一句话总结,Java技术栈优先选ShardingSphere-JDBC,异构团队或者不想改代码的可以选ShardingSphere-Proxy,自研路由除非你时间特别充裕,否则不建议碰。
2.3 控制配置复杂度是关键
ShardingSphere功能越多,也意味着配置项越复杂。很多团队改到一半就放弃了,不是因为框架不行,而是上手就想把分片、读写分离、脱敏、分布式事务全配上,结果配置互相干扰,定位问题特别痛苦。
我建议初期只开两个能力:分片加绑定表。跑通之后再按需加读写分离,最后再考虑分布式事务。ShardingSphere本身只是个路由框架,不会替你把数据一致性全部搞定,配置适可而止,给未来的演进留出空间。
3. 库表规划与分片设计:最关键的决策
3.1 分片键决定架构天花板
分片键的选择是整个分库分表方案里最重要的决策,没有之一。分片键选错了,后面每一步都在还债。
以订单表为例,最常见的两个候选字段是user_id和order_id。如果按order_id取模分片,那么“查询某用户的所有订单”这个极其常见的业务,就需要广播到所有分片去查,再在内存里汇总。如果按user_id分片,那“按订单号查订单详情”就会变成跨库查询。
这两种方案怎么选,取决于你的核心查询路径。绝大多数电商系统的核心路径是“用户查自己的订单”,所以更适合按user_id分片。至于按order_id查询,常见做法是维护一张order_id到user_id的映射表,或者在订单号生成时内置用户ID分片信息,这样SQL里就能带出分片键,路由到精确分片。
还有一种做法是使用Snowflake雪花算法生成订单号,把workerId或序列号的一部分设计成用户ID的分桶信息,这样下单时可以通过订单号直接计算分片位置,无需查映射表。这个方案细节多,不是团队核心架构师不建议一上来就玩,映射表方案虽然多一次查询,但可控性更强。
分片键还有一个隐患就是数据倾斜。比如某个大客户订单量特别大,按user_id取模后可能集中在少数几个分片上,这些分片负载会明显高于其他分片。遇到这种情况,单纯的取模就不够用了,可以考虑用一致性哈希的边界分片,或者按照更细粒度的业务字段做复合分片。
3.2 分片算法和库表数量怎么定
ShardingSphere支持多种分片算法,最常用的是取模和哈希取模。
取模算法(MOD)直接用字段值对分片数量取模,实现最简单,但如果字段值是字符串且低位有规律,容易出现分布不均。哈希取模(HASH_MOD)会先对字段值做哈希再取模,能有效避免这个问题。我的经验是,字符串类型的分片键尽量用HASH_MOD,整型ID如果本身是雪花算法生成的,分布很均匀,用MOD就够了。
库表数量怎么定?我的建议是库和表都取2的幂次,比如2库4表、4库8表、8库16表,最高我做过32库64表。原因是取模本质上是二进制低位运算,库表数是2的幂次时,未来扩容翻倍,每个键重新映射后只变化最高位的那1位,旧数据迁移范围完全可控。
分片配置示例,假设订单表拆成2个库、每个库2张表:
rules: sharding: tables: t_order: actualDataNodes: ds$->{0..1}.t_order$->{0..1} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: database_hash_mod tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: table_hash_mod shardingAlgorithms: database_hash_mod: type: HASH_MOD props: sharding-count: 2 table_hash_mod: type: HASH_MOD props: sharding-count: 2这里要注意actualDataNodes的写法,ds$->{0..1}代表ds0和ds1两个数据源,t_order$->{0..1}代表t_order_0和t_order_1两张表,组合出来就是4个物理表节点。分片键是user_id,库和表都用哈希取模。
3.3 绑定表、广播表与读写分离怎么配合
多表关联是分库分表最容易埋坑的地方。比如订单表和订单明细表,如果一张订单落在t_order_0,订单明细却落在t_order_item_1,那join的时候就要跨分片,ShardingSphere只能把SQL拆成多条子查询再归并,结果就是笛卡尔积,性能和正确性都堪忧。
解决办法是配置绑定表(bindingTables),让相关联的表使用同一条分片规则。订单表和订单明细表都以order_id作为分片键,数据必然落在对应的分片上,关联查询就能在单分片内完成。
还有一类小表,比如地区表、字典表,数据量不大但经常要被关联查询。这类表适合做成广播表(broadcastTables),ShardingSphere会自动在每个库里都创建一份全量数据,查询时直接走本地库,完全不涉及跨分片。
读写分离也要提前规划。主从复制本身有延迟,刚写入的数据立刻去从库读可能读不到。分库分表之后读写分离的链路更长,延迟问题会被放大。我的经验是,对一致性要求高的场景强制走主库,比如订单支付回调后的状态查询;允许最终一致的场景再走从库,比如历史订单列表。ShardingSphere支持在配置文件里设置负载均衡策略,也可以通过Hint强制走主库。
全局主键这块顺带说一下,分库分表后单表的自增ID就不能用了。我推荐用雪花算法(SNOWFLAKE),ShardingSphere内置支持,生成的ID是Long型,趋势递增、全局唯一。要注意的是,雪花ID在给前端返回时,如果用的是JavaScript,Number类型会丢精度,后面我会在踩坑篇里详细说。
4. 实操:把订单表拆分落地
4.1 环境与依赖准备
我用的环境是JDK8、Spring Boot 2.7.x、ShardingSphere-JDBC 5.2.1、mysql 8.0。如果你的Spring Boot版本更新,建议用5.4.x的依赖,接口兼容性更好。
先引入maven依赖:
<dependency> <groupId>org.apache.shardingsphere</groupId> <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId> <version>5.2.1</version> </dependency> <!-- mysql驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>然后初始化物理表。注意分库分表后,每个库里都要建相同结构的表。我这里用2库4表举例子,两个库ds0和ds1,每个库里建t_order_0和t_order_1两张表,建表DDL必须完全一致:
CREATE TABLE `t_order_0` ( `id` bigint NOT NULL, `order_id` bigint NOT NULL, `user_id` bigint NOT NULL, `amount` decimal(10,2) NOT NULL, `status` int NOT NULL, `create_time` datetime NOT NULL, PRIMARY KEY (`id`), KEY `idx_user_id` (`user_id`), KEY `idx_order_id` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;每个月例行巡检的时候,一个很实用的脚本就是循环建表,把模板SQL复制改成表名,两分钟就能把全部门表建完。
4.2 配置拆解:数据源、分片规则、绑定关系
ShardingSphere-JDBC的配置核心在yaml文件里。我贴一段实际可用的配置,注释写清楚每个参数的作用:
spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.10:3306/ds0?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: change-me ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://192.168.1.11:3306/ds1?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: change-me rules: sharding: tables: t_order: actualDataNodes: ds$->{0..1}.t_order$->{0..1} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_db_hash_mod tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_table_hash_mod t_order_item: actualDataNodes: ds$->{0..1}.t_order_item$->{0..1} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_db_hash_mod tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: order_item_table_hash_mod bindingTables: - t_order,t_order_item keyGenerators: snowflake: type: SNOWFLAKE props: worker-id: 1 shardingAlgorithms: order_db_hash_mod: type: HASH_MOD props: sharding-count: 2 order_table_hash_mod: type: HASH_MOD props: sharding-count: 2 order_item_table_hash_mod: type: HASH_MOD props: sharding-count: 2 props: sql-show: true几个容易踩的细节:
bindingTables这里把t_order和t_order_item绑在一起,保证关联查询时数据落在同一个分片上。keyGenerators里配置了雪花算法的主键生成。sql-show打开之后,控制台会打印每条SQL实际路由到了哪个库哪张表,这个在开发和联调阶段帮助极大,相当于给你一双X光眼睛。生产环境记得关掉,否则日志量惊人。
jdbc-url里务必加上useSSL=false和serverTimezone=Asia/Shanghai。mysql 8默认开SSL,如果服务端没配好证书,会出现SSL连接错误,第一次接入很容易被这个坑拦住。serverTimezone不配的话,时间字段经常报错或者偏移8小时。
4.3 代码实践与验证
配置写好后,业务代码几乎不用改,这就是ShardingSphere-JDBC最大的好处。Mapper接口和普通mybatis写法一样:
@Mapper public interface OrderMapper { @Insert("INSERT INTO t_order (id, order_id, user_id, amount, status, create_time) VALUES (#{id}, #{orderId}, #{userId}, #{amount}, #{status}, #{createTime})") int insert(Order order); @Select("SELECT * FROM t_order WHERE user_id = #{userId}") List<Order> selectByUserId(Long userId); @Select("SELECT * FROM t_order WHERE id = #{id}") Order selectById(Long id); }注意,selectById这种查询如果没有带分片键user_id,ShardingSphere没法精确路由,会广播到所有分片去查。如果id本身就是雪花算法生成的,里面没编码user_id信息,那就只能广播了。查询频率高的场景,还是建议where条件里带上user_id,或者依靠映射表先查出来。
验证路由是否准确,就看sql-show的日志。插入一条user_id=123的数据,日志里会出现路由到ds0.t_order_0这样的信息,并且会打印出改写后的真实SQL。我第一次跑通的时候,看到SQL被改写到物理表名,那种感觉还挺爽的。
4.4 连接池与数据源数量
分库分表后每个数据源都是独立的HikariCP连接池,这里有个很容易被忽视的问题:连接数会成倍放大。
假设你原来一个数据源配了50个最大连接,现在拆成4个数据源,每个数据源还配50,那应用侧最大连接数就是200。如果应用有多个实例,数据库连接数轻松被打爆。我的建议是,拆分后每个数据源的max-pool-size可以减半,比如原来50,拆4个库就配20~30,同时要结合压测结果微调。mysql的最大连接数也要同步调大,而且要留出管理操作的空余。
4.5 数据迁移与上线切换
最稳妥的上线方式是双写加灰度。
双写阶段,新老两个存储同时写入,老库继续承接读流量,新库通过数据同步工具或自研脚本从老库拉取历史数据。每次写操作比对两边数据,校验一致性。这个阶段持续1到2周,确认新链路稳了之后,再把读流量逐步切过去。
全量历史数据迁移,我建议按分片键范围分批扫,比如按user_id分桶,每批拉出来按分片规则计算目标表,批量插入。一次性全量灌库的SQL在数据量大时会锁表,容易拖垮在线业务。迁移过程中要额外关注双写时的事务问题,新库写入失败不能影响老库主流程,建议异步记录失败消息,定时任务补偿。
5. 常见问题与排查实录
5.1 深分页性能陷阱
分库分表之后,limit 100000, 20这种深分页查询会变得非常慢。原因很简单,ShardingSphere会把SQL下发到每个分片,每个分片都查offset+limit条数,然后归并排序,再丢弃前面offset条。偏移越深,每个分片查的数据越多,内存消耗和响应时间都直线上升。
我实际压测过,单表查询100毫秒内的SQL,拆成4个分片后深分页直接飙到3秒以上。解决方式有两种。一是禁用深分页,业务侧只允许前几页。二是改造成游标分页(keyset pagination),比如where create_time < 上次最后一条记录的create_time order by create_time desc limit 20,利用索引直接定位,每个分片只查20条,效率高很多。
5.2 分布式事务与跨分片一致性
分库分表后,一次写操作如果同时落在两个不同的分片,本地事务就管不住了。ShardingSphere默认的行为是两阶段提交,实现层面可以选择XA协议或者配合Seata。
我在项目里遇到过的典型场景:下单时写t_order和t_order_item,两条记录通过分片路由可能落在不同库。如果不用分布式事务,第一个库写成功、第二个库写失败,订单就没有明细,查都查不到。
ShardingSphere的XA事务,用声明式注解就能开启:
@ShardingSphereTransactionType(TransactionType.XA) @Override @Transactional public void createOrder(Order order) { orderMapper.insertOrder(order); orderItemMapper.insertOrderItem(order.getItem()); }XA强一致但是性能代价大,适合核心交易链路。对于读多写少、允许最终一致的场景,我建议用Seata的AT模式,或者干脆自己写本地消息表做异步补偿。反正一句话,分库分表后别指望单靠@Transactional就打天下。
5.3 雪花ID精度丢失问题
这是排查了一下午才发现的经典问题。雪花算法生成的ID是Long型,后端没问题,但返回给前端时,如果JSON序列化没有特殊处理,JavaScript的Number类型最多安全表示53位整数,雪花ID一般在19位左右,精度就丢了。表现就是前端拿到的ID最后几位变成了0,再用这个ID做查询,查出来是空。
解决方式很简单,给ID字段加一个序列化注解:
@JsonSerialize(using = ToStringSerializer.class) private Long id;或者统一配置Jackson,把所有Long类型都转成String输出。这个坑在分库分表之后特别容易出现,因为大家都在用雪花ID,之前自增ID从来不会有这种问题。
5.4 慢SQL下推与内存归并
跑过几次统计报表就明白了,分库分表后group by、order by、聚合函数这类SQL,ShardingSphere会把原始SQL拆分下发到所有分片,然后各分片的结果聚集到应用内存里再做一次归并。如果发下到32个分片,每个分片返回几万行,应用内存瞬间就能被撑爆。
所以统计类、报表类的查询,我强烈建议不要直接在分片库上做。数据量大就同步到数仓,或者做离线预聚合,线上库只承担交易型查询。要是非做不可,就在SQL层面做好limit限制。
5.5 常见问题速查表
| 现象 | 原因 | 解决方案 |
|---|---|---|
| 插入数据时表不存在 | 分片规则未打到实际存在的表名 | 检查actualDataNodes枚举是否正确,物理表是否都建好 |
| 关联查询结果缺失 | 关联表分片键不一致 | 配置bindingTables绑定表,分片键用同一字段 |
| 应用启动报数据源初始化失败 | mysql URL缺少参数或账号权限不足 | 检查useSSL、serverTimezone参数,确认账号能否连上所有分片库 |
| 查询结果重复或缺失 | 分片键在SQL中未带出 | 确保where条件带分片键,否则广播后归并出错 |
| 前端拿到的ID不对 | Long精度丢失 | Jackson统一Long转String |
| 某几个分片负载特别高 | 数据倾斜或MOD分布不均 | 换HASH_MOD,或采用一致性哈希边界分片 |
6. 生产落地后的一些个人体会
分库分表是数据层的重武器,但不是第一选择。我个人用了几年ShardingSphere,最大的感受是它的配置虽然多,但只要把分片键想清楚了,整个方案八九不离十。如果你现在面临单表数据量暴涨的问题,我建议先做索引优化、缓存、读写分离这三板斧,压测确认瓶颈真的在数据量维度上之后,再动手分库分表。
另外有个小技巧想分享:初期只做分库、不分表,很多时候拆完库之后,单表数据量还在可控范围,甚至几年后都不需要拆表,还能少维护一层分表逻辑。我自己就吃过“为了分而分”的亏,第一次上线就把表拆得很碎,维护成本瞬间翻倍,业务价值却没增加多少。
如果你正在准备面试,面试官大概率会问到分片键怎么选、跨分片join怎么处理、分布式事务用什么方案这三连,本文的核心思路你都能直接用得上。分库分表的本质是把单机的极限变成集群的扩展,而设计决策永远比搬砖配置更重要。