分库分表这个问题几乎每个后段开发在数据量起来之后都会遇到。我之前维护过一个订单系统单表数据量到两千多万行的时候明显感觉到写入变慢某些统计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. 方案选型为什么是ShardingSphere2.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/ProxyShardingSphere-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作为分片键数据必然落在对应的分片上关联查询就能在单分片内完成。还有一类小表比如地区表、字典表数据量不大但经常要被关联查询。这类表适合做成广播表broadcastTablesShardingSphere会自动在每个库里都创建一份全量数据查询时直接走本地库完全不涉及跨分片。读写分离也要提前规划。主从复制本身有延迟刚写入的数据立刻去从库读可能读不到。分库分表之后读写分离的链路更长延迟问题会被放大。我的经验是对一致性要求高的场景强制走主库比如订单支付回调后的状态查询允许最终一致的场景再走从库比如历史订单列表。ShardingSphere支持在配置文件里设置负载均衡策略也可以通过Hint强制走主库。全局主键这块顺带说一下分库分表后单表的自增ID就不能用了。我推荐用雪花算法SNOWFLAKEShardingSphere内置支持生成的ID是Long型趋势递增、全局唯一。要注意的是雪花ID在给前端返回时如果用的是JavaScriptNumber类型会丢精度后面我会在踩坑篇里详细说。4. 实操把订单表拆分落地4.1 环境与依赖准备我用的环境是JDK8、Spring Boot 2.7.x、ShardingSphere-JDBC 5.2.1、mysql 8.0。如果你的Spring Boot版本更新建议用5.4.x的依赖接口兼容性更好。先引入maven依赖dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.2.1/version /dependency !-- mysql驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;每个月例行巡检的时候一个很实用的脚本就是循环建表把模板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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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里务必加上useSSLfalse和serverTimezoneAsia/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}) ListOrder selectByUserId(Long userId); Select(SELECT * FROM t_order WHERE id #{id}) Order selectById(Long id); }注意selectById这种查询如果没有带分片键user_idShardingSphere没法精确路由会广播到所有分片去查。如果id本身就是雪花算法生成的里面没编码user_id信息那就只能广播了。查询频率高的场景还是建议where条件里带上user_id或者依靠映射表先查出来。验证路由是否准确就看sql-show的日志。插入一条user_id123的数据日志里会出现路由到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下发到每个分片每个分片都查offsetlimit条数然后归并排序再丢弃前面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、聚合函数这类SQLShardingSphere会把原始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怎么处理、分布式事务用什么方案这三连本文的核心思路你都能直接用得上。分库分表的本质是把单机的极限变成集群的扩展而设计决策永远比搬砖配置更重要。