从单表瓶颈到分库分表:ShardingSphere分片设计与配置实战 生产环境里订单表到了几千万行接口开始动不动慢查询备份时间越来越长这时候大多数团队都会想到分库分表。我自己维护的一个电商模拟项目就是从订单表量变到质变被拖垮最后选型落在了 ShardingSphere 上。这篇文章把分库分表的思考过程、核心概念和落地配置全部拆开讲尽量让准备做数据库扩容的同学少走弯路。1. 为什么要用ShardingSphere做分库分表1.1 单表撑不住的真实场景先说一个非常典型的场景订单表日增十几万行一年下来就是几千万行三年之后稳稳破亿。单表数据量到了一定规模MySQL 的 B 树层级会变高即使索引建得没问题随机 IO 也会明显增加。你会发现有些统计 SQL 跑一次要几十秒备份一个表要半小时主从同步延迟越来越难控制。更让人头疼的是某些开发同学喜欢在查询条件里不加分片键这类 SQL 一旦上线整个数据库连接池都会被拖住。这不是 MySQL 本身不行而是单机容量有天花板。分库分表的本质是把数据按照某个规则分散到多个数据库、多张物理表里让每个节点承担一部分读写压力。1.2 分库分表主流方案与选型对比分库分表实现方式大致分三类应用层硬编码、代理层中间件、嵌入式中间件。方案优点缺点应用层自己拆分不引入额外组件路由逻辑可控代码侵入严重扩容和改规则成本高代理层中间件独立部署对业务几乎透明支持跨语言需要维护独立集群多一跳网络开销嵌入式中间件与业务同进程性能好配置灵活无额外部署只适合Java应用规则升级需要跟着应用上线自己做硬编码听起来简单但真正做起来很坑。你需要在 DAO 层写各种switch和if查询条件稍微变一下就得改代码尤其是后来要加读写分离、数据加密、影子库这些能力时自己写的代码基本撑不住。代理层独立部署的好处是业务方不用改代码但多一层代理意味着多一次网络转发链路变长。而且代理层的并发能力和缓冲池配置也需要额外调优小团队未必扛得住。1.3 为什么我最终选了ShardingSphere选 ShardingSphere 的原因很直接它有 JDBC 和 Proxy 两种形态。JDBC 模式直接嵌在应用里性能损耗小Spring Boot 项目接入非常顺手Proxy 模式独立部署给非 Java 团队或者其他中间件使用。这样我可以先用 JDBC 模式跑通核心业务将来团队规模大了再上 Proxy。ShardingSphere 不只能做分库分表还把分布式主键、读写分离、数据加密、影子库、分布式事务这些都纳入了同一套配置体系。也就是说原来你得接好几个中间件才能完成的事情现在一个框架可以统一管理。它支持的数据库类型也多MySQL、PostgreSQL、openGauss 等都在覆盖范围内切换成本相对低。当然也要说实话ShardingSphere 的配置学习曲线并不平缓版本之间的配置格式有差异网上很多老教程还停留在 4.x直接抄到 5.x 会报错。动手之前一定要先看清当前版本的文档格式。2. 动手前的核心概念与分片设计2.1 逻辑表、物理表、分片键先懂这几个词分库分表的第一步不是配中间件而是把概念理清楚。逻辑表是你在 SQL 里写的那个表名比如t_order。物理表是真正落在数据库里的表比如t_order_0、t_order_1。ShardingSphere 的职责就是把对t_order的操作路由到对应物理表上。分片键是路由的依据也是整个分片设计里最重要的字段。SELECT * FROM t_order WHERE user_id 1024能不能高效执行完全取决于user_id是不是分片键以及分片算法能不能准确落到少数物理表上。分片键的选择标准其实就三条分布足够均匀、业务上高频查询、取值几乎不变。用订单表举例user_id通常是最好的分片键因为大部分查询都是按用户找订单。如果按order_id分片虽然订单号本身均匀但业务侧不知道订单号对应哪个用户时就没法快速定位分片只能全路由。2.2 分片算法怎么选取模、哈希、范围ShardingSphere 提供多种内置分片算法最常用的有取模、哈希取模和范围分片。算法特点适用场景MOD简单直接按字段值取模分片键本身已经比较均匀HASH_MOD先做哈希再取模打散连续值user_id、手机号这类连续但可能扎堆的字段RANGE按区间划分比如按时间或ID段经常做范围查询但容易产生热点取模算法最简单但有个问题如果你的分片键是自增的订单号老数据集中在低段新数据段需要经过哈希才能均匀否则会出现局部热点。哈希取模算法会先把分片键做一次哈希再对分片数量取模能明显改善分布均匀性。代价是同样的字段值经过两次计算路由多一点点开销不过这点开销通常可以忽略。范围分片很适合时间维度比如按月份拆表但缺点是数据量会随时间偏移老表可能不活跃新表压力很大。所以选算法之前先想清楚业务访问模式再说性能。2.3 容量规划分几个库几张表很多人第一次分库分表容易犯“拍脑袋”的毛病。我建议用一道公式估算目标数据量除以单表可承受行数得到最少物理表数。假设未来三年订单量大概 1.2 亿行单表希望压在 500 万行左右那至少需要 24 张表。为了给突发增长留空间我一般按 32 张表规划。如果每个库放 8 张表就是 4 个库。这样每个库大约承载 3000 万行压力可控。再有库不是越多越好。每个库会占用连接数、文件句柄备份和监控也要多一份成本。初期库太多团队运维根本忙不过来。我个人更建议先从 4 库 8 表或者 2 库 8 表开始等业务量真的上来了再做扩容动作。分片函数必须提前设计好。如果你用user_id分片库路由可以做成user_id % 4表路由做成(user_id / 4) % 8这样用户会均匀落在 4 个库的 8 张表里。如果库和表都用同一个%2虽然能跑通但会有一半物理表长期空着容量规划要从一开始就避开这种浪费。2.4 广播表和绑定表的设计分库分表后很多原来简单的表关系会变得复杂。像省市区、订单状态这些字典数据量很小却到处要关联查询。这时候不要分片直接让每个库都保存一份完整数据也就是广播表。ShardingSphere 里配置广播表后写操作会同步到所有库读操作只查本地库省去跨库 JOIN。绑定表则是解决分片表之间的 JOIN 问题。t_order和t_order_item都按user_id分片如果两张表的绑定关系没有声明中间件就只能拆成笛卡尔积去查性能很难看。声明绑定表后ShardingSphere 会强制它们保持相同分片键和分片策略JOIN 时只访问同一个分片效率能提升很多。一句话总结像订单主表和明细表这种天生一起查的要设计成绑定表像配置表、字典表这种全局共享的要设计成广播表。3. 环境搭建与基础配置实战3.1 引入依赖ShardingSphere-JDBC 直接嵌进应用我用的是 Spring Boot 项目直接引入 ShardingSphere 的 JDBC Starter。版本最好和你的 Spring Boot 版本对齐建议先去官方文档确认兼容矩阵。dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.4.1/version /dependency接入后你的业务代码不需要改 SQL数据源会由 ShardingSphere 统一管理。需要注意它接管的是数据源原来配置在spring.datasource下面的连接信息要全部挪到spring.shardingsphere.datasource下面。3.2 最小分片配置把订单表按 user_id 拆开先准备两个数据库ds0、ds1每库里建两张表t_order_0、t_order_1。DDL 可以这样写CREATE TABLE t_order_0 ( order_id BIGINT PRIMARY KEY, user_id BIGINT NOT NULL, amount DECIMAL(12,2) NOT NULL, status VARCHAR(20), create_time DATETIME, KEY idx_user_id (user_id) );然后配置 ShardingSphere。下面这个配置是最小可用版本故意把取模都写成%2方便理解路由逻辑spring: shardingsphere: datasource: names: ds0,ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/ds0?useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/ds1?useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 rules: sharding: tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{0..1} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db_inline table-strategy: standard: sharding-column: user_id sharding-algorithm-name: table_inline key-generate-strategy: column: order_id key-generator-name: snowflake sharding-algorithms: db_inline: type: INLINE props: algorithm-expression: ds$-{user_id % 2} table_inline: type: INLINE props: algorithm-expression: t_order_$-{user_id % 2} key-generators: snowflake: type: SNOWFLAKE props: worker-id: 1 props: sql-show: true提示这个最小配置能跑通但user_id % 2会让一半物理表空置。比如偶数用户只落在ds0.t_order_0奇数用户只落在ds1.t_order_1。生产环境建议用自定义均匀算法或者把表达式改成user_id % 4这类逻辑让每个节点都承载数据。配置里actual-data-nodes用的是$-{0..1}表示枚举范围。algorithm-expression则是根据字段值动态计算。两套语法不要搞混否则启动阶段就会报解析错误。3.3 写入先过主库读写分离配置分库分表通常会和读写分离一起上。ShardingSphere 的读写分离配置是独立规则需要把数据源拆成写库和读库两组。rules: readwrite-splitting: >Transactional public void createOrder(Order order) { orderMapper.insert(order); }对应 SQLINSERT INTO t_order (user_id, amount, status, create_time) VALUES (#{userId}, #{amount}, #{status}, #{createTime});ShardingSphere 会在执行前把生成的order_id填充进去。规模小的场景用默认雪花配置就够了唯一要注意的是多个应用实例的worker-id不能重复否则高并发下可能出现重复 ID。4. 核心操作分片查询与写入的底层逻辑4.1 一条带分片键的SQL是怎么执行的ShardingSphere 对一条 SQL 的处理分五步解析、路由、改写、执行、归并。比如执行SELECT * FROM t_order WHERE user_id 1024;先把 SQL 解析成语法树识别出逻辑表t_order和分片键条件user_id 1024。路由阶段根据user_id % 2判定应该走ds0还是ds1同时算出表名。改写阶段把逻辑表名换成物理表名。最终真正发到 MySQL 的 SQL 可能是SELECT * FROM t_order_0 WHERE user_id 1024;执行就很简单了查完结果返回给客户端不用做归并因为只打了一个节点。如果你往t_order里插入数据步骤也类似。ShardingSphere 根据分片键定位物理表自动完成路由和改写应用层完全无感知。4.2 不带分片键的查询为什么会全路由最怕的是下面这类 SQLSELECT * FROM t_order WHERE status PAID ORDER BY create_time DESC LIMIT 20;条件里没有user_idShardingSphere 不知道数据在哪张表只能广播到所有分片节点执行。这在数据量小的时候问题不大但是分了几十个节点后所有库表都要扫一遍慢查询几乎是必然的。排查问题时我习惯先看执行计划里有没有actual SQL。如果一条查询最后真实执行的 SQL 数量远超预期就说明它触发了全路由。线上系统一定要限制这类查询最好让分片键条件成为必填项或者把查询场景提前改造成常用字段冗余。4.3 绑定表和广播表的实战作用绑定表和广播表不仅要在设计阶段想清楚配置落地也很关键。绑定表规则写在分片规则下一级rules: sharding: binding-tables: - t_order,t_order_item broadcast-tables: - t_dict_region这里我故意用binding-tables把t_order和t_order_item绑定起来并且两张表都用同一个分片键user_id。这样 JOIN 查询时中间件知道它们的数据在同一个物理节点不会产生笛卡尔积。广播表则是把t_dict_region在每个库里都放一份。ShardingSphere 执行插入、更新和删除时会对所有广播表广播写入查询时随便查哪一份都行。用广播表时千万注意不能把大表设成广播表否则每个写操作都要同步到所有库代价非常大。4.4 排序、分组、分页的归并代价分片之后再写ORDER BY和LIMIT不能直接按单库思维去优化。比如SELECT * FROM t_order WHERE user_id 1024 ORDER BY create_time DESC LIMIT 10000, 20;MySQL 单库里可以高效跳过 10000 行但分片下每个节点都要先查足够多的数据再交回 ShardingSphere 统一排序最后取第 10000 行之后的 20 条。偏移量越大内存消耗越夸张性能崩得很快。深分页场景建议改成游标分页或者 keyset 分页SELECT * FROM t_order WHERE user_id 1024 AND create_time #{lastCreateTime} ORDER BY create_time DESC LIMIT 20;让下一次查询以上一次的create_time作为边界而不是跳过一个很大的偏移量。这个方法在分库分表环境下比传统LIMIT offset稳定很多。5. 常见问题与排查技巧实录5.1 分片键选错了一个典型教训我见过一个模拟项目最初为了图省事用order_id做分片键。理由是订单号自增且分布均匀听起来没问题。但业务侧所有核心查询都按user_id查用户的订单列表结果就是每条查询都触发全路由数据库一压测就崩溃。后来把分片键改成user_id同时保留order_id作为普通索引查询性能立刻恢复。如果你已经在线上踩了分片键选错的坑也不要慌尽早评估迁移方案拖得越久改造成本越高。5.2 跨库事务别把分布式事务当本地事务用分片之后如果一次业务操作要更新两个库的数据原来的本地事务就失效了。比如扣除用户余额和创建订单分布在两个库用Transactional并不能保证跨库原子性。ShardingSphere 有 XA 和 BASE 两种分布式事务方案。XA 强一致但锁时长、性能开销都比较大BASE 最终一致适合对时效要求不高的场景。我更推荐用本地消息表或者事务消息来做最终一致尽量从设计上避免跨库事务。把跨库写操作拆成多个独立步骤同库操作放一个事务跨库操作用异步消息对齐状态。这样至少不会为了强一致把整个系统的吞吐量拖垮。5.3 扩容与数据迁移不可能真正无缝取模分片的扩容比加表要麻烦得多因为老数据的路由规则已经写进物理表了。扩容不只是加几个节点还要把已有数据按新规则重新分布。我建议的迁移顺序是先准备新集群然后做全量数据同步接着开增量同步追平 binlog确认数据一致后再切换路由规则最后灰度放流量。整个过程必须有回滚预案而且一定要在切换前做数据校验。如果业务允许也可以采用“双写”方案应用写入新老两套数据库等老库完全不再需要时再下线。双写会增加业务代码复杂度不是首选。5.4 用SQL日志快速定位路由结果调试分片规则最实用的就是打开sql-show。在配置的props里加上props: sql-show: true执行一条 SQL 后控制台会打印逻辑 SQL 和真实 SQL。比如逻辑 SQL 是查t_order真实 SQL 会显示成t_order_0并且标注路由到了哪个数据源。看到这里你就知道分片规则是否如预期了。如果某个 SQL 刷出一长串不同物理表的真实 SQL说明它是全路由需要尽快优化。这个技巧排查问题非常直观我每次调分片规则都会先打开它确认无误后再关掉。5.5 常见问题速查表现象可能原因解决建议各分片数据量差距大分片键本身偏斜或算法选错换 HASH_MOD或检查分片键是否均匀不带分片键的查询很慢触发全库表路由强制在查询中带分片键或用冗余表/搜索引擎刚写入的数据查不到主从延迟导致读从库事务内读主库或改造读接口接受延迟分布式主键重复worker-id 冲突或时钟回拨检查每个实例的 worker-id 配置同步服务器时间扩容后部分数据查不到旧路由规则和新规则不兼容迁移后做数据校验灰度切换规则JOIN 查询非常慢绑定表未配置产生笛卡尔积相同分片键的表配置 binding-tables最后说一句大实话。分库分表这套东西能晚做就晚做但真要上线千万级数据之前必须把分片键、容量规划、查询场景这几点讨论清楚。我见过太多团队以为接上中间件就万事大吉最后被全路由、分布式事务和扩容问题轮番折腾。真到动手那天先把最小配置跑通再开 SQL 日志把核心查询都核对一遍最后才谈性能和监控。如果让我重新做一次我会先花两周时间把业务查询场景全部梳理出来再决定表的数量和分片算法而不是一上来就改代码。