ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

ShardingSphere订单系统分库分表实战:从分片键到扩容迁移

ShardingSphere订单系统分库分表实战:从分片键到扩容迁移 做分库分表这件事我是拖到实在没办法才动的。单库单表数据量冲上千万、亿级之后慢SQL、锁竞争、备份耗时、连接数打满这些事会接踵而来。而ShardingSphere是目前把分库分表落地得最顺手的中间件之一这篇文章会围绕一个订单系统把从分片键选型、算法配置、项目代码到生产问题排查的完整过程写清楚都是我在实际项目里验证过的方案和踩过的坑。如果你是数据量还没到瓶颈、纯想了解技术选型这套拆解同样值得读完毕竟分库分表最怕的不是不会用而是用得时机不对、拆得方案不对后面想回头都难。1. 为什么我的项目需要分库分表一个真实的演进过程很多团队一开始并不会主动想拆库甚至觉得这是技术债。我在的订单项目也一样一年多时间订单总量到了四千多万单表查询开始明显变慢加上后台要跑各种统计业务侧又不断加字段单库单表的路基本到头了。1.1 单库单表撑不住的那一刻四千多万的数据量单表即使建立了合理的索引B树深度撑到三层、四层随机查询的代价其实还能接受。真正扛不住的问题出在几个地方写冲突和锁竞争严重。下单高峰时段同一个热点用户、同一个商品维度的行锁竞争业务接口的P99延迟一路飙升。备份和恢复时间越来越离谱。一张超过几十GB的大表每次全量备份要按小时计算恢复演练基本没法做。数据归档困难。想把一年前的数据挪到历史表单库单表做起来要么锁表要么影响线上。索引和统计信息失效。大表频繁更新后执行计划经常出现不稳定同样的查询时快时慢。这些信号凑齐之后分库分表就是必需品而不是炫技。拆分的直接目标有两个一是把单表数据量控制下来保证索引和查询稳定二是把写入压力分散到多个库减少单点锁和连接压力。1.2 垂直拆分与水平拆分的取舍拆分的思路大体分两类垂直拆分和水平拆分。两者并不是互斥关系实际生产中往往是先垂直后水平。垂直拆分是按业务域拆库比如订单库、用户库、支付库各管各的。垂直拆分的收益是模块职责清晰服务之间互不干扰但局限也很明显——它解决不了单表数据量持续膨胀的问题订单表该有八千万还是八千万。水平拆分才是本文的核心。它的做法是把同一张表的数据按某种规则分散到多个库、多张表里。以订单表为例可以按用户ID取模分成2个库每个库再分成4张表整体形成8张结构完全一样的表数据按分片键均匀散落。我建议你在动手前先把概念理清楚维度垂直拆分水平拆分拆分对象按业务域拆表/拆库按数据行拆表/拆库解决的问题业务耦合、单库连接压力单表数据量过大、写入瓶颈实施难度相对简单主要是应用改造涉及路由、扩容、数据一致性典型场景微服务化、业务模块解耦千万级以上的订单、消息、流水真实项目里我见过不少团队一开始只做垂直拆分结果发现订单库还是太大于是又重新做水平拆分。所以方案设计阶段建议把未来两年的数据增量一并估算进去避免重复改造。1.3 为什么最终选了 ShardingSphere主流的中间件方案有ShardingSphere和MyCat两类。ShardingSphere-JDBC以jar包方式运行在应用侧相当于给应用注入了一个增强的数据源ShardingSphere-Proxy则是独立部署一个代理服务用MySQL协议对外提供连接。MyCat偏向Proxy模型但多年的社区迭代和生态活跃度其实不如ShardingSphere。我最终选ShardingSphere理由很直接和Spring Boot集成非常顺滑配置文件写好就能用对已有代码的侵入性小。分片策略、分布式ID、读写分离、数据加密这些功能是完整的不用自己在外面拼凑。支持标准JDBC接口MyBatis、Spring Data JPA都能无缝对接。5.x版本的内核做了重写SQL解析和改写能力比4.x时代强不少。特别说明一下这篇文章的示例用的是ShardingSphere 5.x版本配置结构和老版本差异很大如果你搜到的是4.x资料请对版本保持足够的警觉。2. 分片前的准备工作确定维度与分片算法很多项目翻车不是中间件用错了而是分片键选错了。分片键决定了一条SQL会被路由到哪个库、哪张表如果选得不好后面的查询复杂度会成倍上升。2.1 分片键怎么选三个硬性条件我在订单项目里选择分片键时坚持三个条件业务高频使用。分片键必须在绝大多数查询语句中作为条件出现。用户端查我的订单条件必然带user_id所以user_id就是第一分片键。数据分布足够均匀。分片键的取值离散程度要高不能让某个值的数据量占掉半边天。例如按用户ID取模活跃用户和沉默用户的ID在哈希空间上分布比较均匀整体是可控的。无更新或极少更新。分片键一旦在业务中被更新就会面临数据搬家、路由失效的问题。所以user_id这种稳定字段比手机号、邮箱这类可变字段更适合。订单表我采用了双分片键的辅助设计主分片键是user_id用于定位到库和表同时order_id作为分布式主键用于单条订单查询时的精准定位。这里有个关键经验如果你只按order_id路由而没有user_id那这条SQL在不知道从哪个分片找数据的情况下只能全库全表路由性能直接崩掉。所以在设计表结构时一定把user_id冗余到所有订单相关表中并且所有查询尽量带上它。2.2 分片算法怎么定取模、哈希还是时间分片ShardingSphere支持多种分片算法常用的有以下几种INLINE取模。写法类似t_order_$-{user_id % 4}理解成本最低适合数据量平稳增长、分片数稳定的场景。HASH_MOD。先将分片键做哈希再取模适合字符串类型的业务字段比如手机号、订单编号。RANGE时间范围。比如按月份拆表适合流水、日志、审计类数据。这种方案的好处是扩容简单按时间加表就行缺点是可能产生热点表。CLASS_BASED自定义算法。当内置算法解决不了业务规则时自己实现分片算法类。我的订单场景用的是INLINE取模。计算公式如下库路由user_id % 2结果0进ds01进ds1表路由user_id % 4结果0到3分别进t_order_0到t_order_3举个例子user_id 10001时库是10001 % 2 1表是10001 % 4 1数据落在ds1的t_order_1里。这个计算过程ShardingSphere会在SQL执行前自动完成但你要自己能算得出来否则排查问题时两眼一抹黑。2.3 分布式ID必须提前换掉自增主键分库分表之后数据库自增主键就整体失效了因为每个分片各自维护一套自增必然产生冲突。我的项目直接用ShardingSphere内置的雪花算法SNOWFLAKE生成order_id。雪花算法生成的ID是一个64位的Long整型由时间戳、机器号、序列号组成趋势递增、全局唯一。在ShardingSphere里配置非常省事rules: sharding: key-generators: snowflake: type: SNOWFLAKE props: worker-id: 1表的配置里再指定主键生成策略key-generate-strategy: column: order_id key-generator-name: snowflake这样插入时只需要设置业务字段order_id会自动生成并回填到实体对象中。有一点要注意雪花算法强依赖机器时钟如果部署环境的NTP时钟同步出问题可能会出现ID重复。生产环境务必做好时钟校验这也是我踩过一次的坑。3. 项目实战订单系统分库分表完整配置前面是理论铺垫从这节开始进入真正的项目实操。以下配置和应用代码都在订单项目中验证过你可以直接作为脚手架参考。3.1 版本选型与工程依赖项目基础是Spring Boot 2.7JDK 8。ShardingSphere选择5.3.2版本这个版本相对稳定API和配置结构清晰。引入依赖时注意一个坑ShardingSphere 5.x对应的starter是shardingsphere-jdbc-core-spring-boot-starter不是老版本的sharding-jdbc-spring-boot-starter两个名字别搞混。dependency groupIdorg.apache.shardingsphere/groupId artifactIdshardingsphere-jdbc-core-spring-boot-starter/artifactId version5.3.2/version /dependency持久层框架用的是MyBatis Plus。因为ShardingSphere对外暴露的是标准DataSource接口所以MyBatis这套根本感知不到后端有多库多表应用代码写起来和单库单表时几乎一样。3.2 数据源与分片规则配置详解两个物理库分别叫order_db_0、order_db_1每个库里预建4张订单表t_order_0到t_order_3。表结构完全一致DDL需要在每个库里各执行一遍。Spring Boot的application.yml配置如下spring: shardingsphere: datasource: names: ds0, ds1 ds0: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://10.1.1.10:3306/order_db_0?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: xxx maximum-pool-size: 10 minimum-idle: 2 ds1: type: com.zaxxer.hikari.HikariDataSource driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://10.1.1.11:3306/order_db_1?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: xxx maximum-pool-size: 10 minimum-idle: 2 rules: sharding: tables: t_order: actual-data-nodes: ds$-{0..1}.t_order_$-{0..3} 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 t_order_item: actual-data-nodes: ds$-{0..1}.t_order_item_$-{0..3} database-strategy: standard: sharding-column: user_id sharding-algorithm-name: db_inline table-strategy: standard: sharding-column: user_id sharding-algorithm-name: item_table_inline binding-tables: - t_order, t_order_item sharding-algorithms: db_inline: type: INLINE props: algorithm-expression: ds$-{user_id % 2} table_inline: type: INLINE props: algorithm-expression: t_order_$-{user_id % 4} item_table_inline: type: INLINE props: algorithm-expression: t_order_item_$-{user_id % 4} props: sql-show: true一点一点拆解关键部分actual-data-nodes定义了表实际分布在哪些数据源和物理表中$-{0..1}、$-{0..3}是ShardingSphere的内置枚举表达式表示区间展开。database-strategy和table-strategy分别负责分库和分表路由。algorithm-expression里的表达式是Groovy语法user_id % 2直接对参数值计算。sql-show: true会在日志里输出改写后的真实SQL开发排查时非常有用生产环境建议关闭。3.3 绑定表与广播表的正确姿势订单表往往还要关联订单明细表。如果两张表都分片连接查询时如果没有约束会产生笛卡尔积路由也就是每一个库表组合都会执行一次join性能灾难。解决方案是配置绑定表让t_order和t_order_item使用相同的分片键和相同的分片算法。这样关联查询时比如t_order o JOIN t_order_item i ON o.order_id i.order_idShardingSphere会根据o表的路由结果直接把i表定位到同一个分片避免无效连接。广播表则相反它代表全库复制的小表比如订单状态字典、配送方式字典。这类表在每个分片库都放一份完整数据查询时直接在当前库读取不会跨库。rules: sharding: broadcast-tables: - t_dict注意广播表适合低频更新、数据量小的字典类数据千万别把大表配成广播表否则每个库都存一份巨大的冗余维护成本极高。3.4 核心业务代码插入与查询的全链路数据源和规则配置好之后业务代码的写法和平时几乎一致。插入订单加明细整个链路的核心在ShardingSphere的路由改写过程。我用的Mapper示例Mapper public interface OrderMapper { Insert(INSERT INTO t_order(order_id, user_id, order_amount, status, create_time) VALUES (#{orderId}, #{userId}, #{orderAmount}, #{status}, #{createTime})) int insert(OrderEntity order); Select(SELECT * FROM t_order WHERE user_id #{userId} AND order_id #{orderId}) OrderEntity selectByIdAndUserId(Param(userId) Long userId, Param(orderId) Long orderId); }插入时如果没有显式给order_id赋值ShardingSphere的key-generator会自动生成并回填。比如userId10001时ShardingSphere内部先计算路由user_id % 2 1目标库ds1user_id % 4 1目标表t_order_1日志里的sql-show会打印类似这样的改写结果INSERT INTO ds1.t_order_1(order_id, user_id, order_amount, status, create_time) VALUES (987654321, 10001, 1999, 1, 2024-06-01 12:00:00)查询订单详情时SQL里同时带上了user_id和order_id路由就能精确定位效率很高。这里提醒一句千万别在Mapper里写不带user_id的单条件查询比如WHERE order_id #{orderId}。这条SQL虽然能在单表时代正确工作在分库分表后必然触发全库全表路由谁能坚持谁后悔。4. 分页、排序与跨分片查询怎么办分库分表后最麻烦的往往不是简单查询而是跨分片的分页排序。这类问题不做限制后台管理页面可能直接把数据库拖垮。4.1 业务场景分层用户端与后台端的分野我把业务查询拆成了两类分别设计不同策略一种是用户端查询条件里一定带user_id比如“我的订单列表”。这种查询天然被分片键约束只需要路由到特定分片再在本地分页排序性能可控。另一种是后台管理端查询条件可能是下单时间、订单状态、商品名称就是不带user_id。这种查询必须路由到全部分片再把结果汇总排序风险最大。用户端接口没什么好讲的按正常写法就行。后台端才是真正的技术难点我在项目里给后台列表单独设计了一套方案而不是让运营同学直接查业务库。4.2 跨分片分页的原理与优化思路当一条SQL无法根据分片键裁剪路由范围时ShardingSphere会把SQL改写后发送到所有分片执行然后对各个分片的结果集做归并。比如SELECT * FROM t_order WHERE create_time BETWEEN 2024-05-01 AND 2024-05-31 ORDER BY order_amount DESC LIMIT 10, 10这条SQL会路由到全部8张分表每张表各自查10条最后ShardingSphere在内存中汇总排序截取第10到20条。这里有个搜索引擎和数据库都会遇到的经典问题如果偏移量很大比如LIMIT 100000, 20每个分片都要把前100020条捞出来再归并内存和时间开销都非常吓人。我实践下来的优化手段有三条限制深度分页。后台列表最多翻到第100页超过就要求运营人员加筛选条件从产品层面消掉深度分页需求这是性价比最高的手段。游标分页代替偏移分页。用上一页的最后一条订单金额和创建时间作为下一页的查询条件让查询每次都只取固定窗口不随页码加深而变慢。引入汇总索引存储。把后台所需的查询字段同步到Elasticsearch或者ClickHouse让后台列表查索引存储不碰业务分片库。这实际上也是我最后真正落地的方案。如果你不想引入新组件还能在数据库层采取月份分表的策略把时间范围条件也作为分片依据从而把后台查询裁剪到少数几个分片。4.3 读写分离在分库场景中的落地分库处理写压力读压力的问题则需要读写分离来解决。ShardingSphere支持在分片规则之下配置每个分片的读库。我当时的配置思路是每个物理主库外挂一主一从主库负责写从库分担读。配置结构如下spring: shardingsphere: datasource: names: ds0_write, ds0_read, ds1_write, ds1_read rules: readwrite-splitting: >HintManager hintManager HintManager.getInstance(); hintManager.setWriteRouteOnly(); try { orderMapper.selectByOrderId(orderId); } finally { hintManager.close(); }主从延迟是这个方案里最大的变量延迟超过业务容忍阈值时建议监控主从延迟时间并触发降级把读流量全部切到主库。5. 生产环境常见问题与排查实录分库分表的报错往往千奇百怪但归因之后大多是几个固定套路。我把项目上线以来遇到的高频问题整理成一份速查表再展开讲几个典型的翻车现场。5.1 高频异常及解决速查表现象原因解决方法找不到分片目标表actual-data-nodes表达式写错核对逻辑表名与实际表名检查$-{0..3}区间SQL提示无法路由SQL里没有分片键改造SQL带上分片键或使用Hint强制路由join查询结果重复绑定表未配置在binding-tables中声明关联表插入数据报主键冲突应用配置了自增或者worker-id冲突改由ShardingSphere生成雪花ID并检查各节点worker-id唯一查询莫名全库路由表达式里的列名和库表列名不一致检查sharding-column和SQL条件里的列名完全一致连接数耗尽实例过多或连接池配置过大控制maximum-pool-size按分片数估算总连接数这几类问题里最常见、最隐蔽的是分片列名不匹配。比如sharding-column配置成了大写列名SQL里写的是小写ShardingSphere识别不了直接把SQL当成无分片键处理。5.2 分片不生效的典型翻车现场有次线上后台查询订单列表SQL里明明带了user_id执行计划却还是全库全表路由。我查了半天最后发现表里根本没有user_id这一列查询条件里写的是order表的别名而ShardingSphere是根据逻辑列名去匹配分片键的列名对不上就退化为全路由。还有一个翻车案例是绑定表没配全。订单表和订单明细表明明配置了绑定表但某个报表SQL又加了第三张分片表做join结果只有前两张表被绑定路由第三张表全部路由一遍查询耗时从几十毫秒变成几十秒。排查方式很简单把sql-show打开看改写SQL凡是出现多组不同分片的SQL就说明绑定关系没生效。建议所有分片表的分片键列都统一命名为相同名称比如都用user_id这样配置最省心连接查询也最容易匹配。不要出现这张表用user_id、那张表用buyer_id的分歧那是给自己埋雷。5.3 连接数与慢SQL的性能监控分库分表乍一看每个库连接数不大但应用实例一多很容易把数据库连接数打满。我算过一个公式总连接数等于应用实例数乘以每实例连接池大小再乘以分片库数。如果20个实例、每实例连接池最大20、2个分片库就是800个数据库连接。如果数据库配置的max_connections是1000留下系统和其他服务余量后已经非常危险。所以我建议每实例连接池的maximum-pool-size不要拍脑袋乱配5到10是常态最大不超过20。连接池不是越大越好大连接池只会放大单实例故障时的雪崩效应。慢SQL监控这件事在分库分表环境里比单库时代更重要。同一个慢SQL会并发打到多个分片影响会被放大数倍。我不仅收集应用侧的执行耗时还会在数据库端开启慢查询日志然后按逻辑表名维度聚合分析。一旦某张分片表出现热点数据索引优化必须马上跟进。6. 扩容与数据迁移提前规划好出路分库分表一旦做了扩容就是躲不掉的话题。很多人以为拆完就万事大吉结果数据量翻倍后才发现之前定的2库4表不够用了这才意识到扩容比初次拆分还要痛苦。6.1 从2库4表扩到4库8表会遇到什么以我的订单表为例初始设计是user_id % 2分库、user_id % 4分表。当单表数据量再次逼近阈值时我面临两个选项保持逻辑分片规则不变只扩大每个物理库的容量例如换更大的磁盘、更强的CPU。调整分片算法比如改为user_id % 4分库、user_id % 8分表让数据更分散。第二种方案听上去更“彻底”但代价非常大。因为取模基数的变化几乎所有存量数据都要重新计算目标分片然后搬运。以前user_id 10001在ds1的t_order_1改成% 4和% 8之后它可能被路由到ds3的t_order_7实际迁移比例接近七成。这就是为什么分片键的取模基数要提前规划的原因之一。如果你预估未来数据量可能翻四倍那初始就一步到位拆成4库8表而不是2库4表。分片数量太少会提前触发扩容分片数量太多又浪费资源和运维成本这个平衡点要靠数据增长模型来推算。6.2 停机迁移与在线迁移的选择扩容时的数据迁移行业里常见方案无非两种停机迁移和在线迁移。停机能接受的情况下我推荐最朴素的做法提前写好导出工具把所有分片数据导出成文件再按新路由规则计算目标分片逐个导入新库然后做总量核对和抽样校验。在线迁移则要复杂得多大体思路是应用层同步双写新老两套分片同时写入。用数据同步工具把历史数据从老分片迁移到新分片。校验完成后将应用读流量灰度切到新分片观察一段时间。确认稳定后关掉老分片写入完成割接。这种方案对双写一致性的要求极高事务边界稍微处理不当就会出现数据漏写或重复。所以我的实践建议是如果业务允许停机维护尽量停机迁移把复杂度降到最低如果一定要在线迁移优先考虑引入Canal这类binlog订阅同步工具而不是手动在业务代码里双写。扩容和数据迁移这件事没有一劳永逸的银弹。真正可靠的办法是在设计阶段就留足余量在运维阶段提前演练迁移流程把最坏情况下的回滚方案也一并验证好。我在实际项目里反复确认过一件事分库分表绝对不是把配置写上、数据拆开就结束了。它牵涉到缓存设计、查询路由、分布式事务、数据迁移、监控告警等一系列配套改造。如果你正准备上手建议从最简单的2库分表起步先把链路跑通再逐步扩展。中间件是工具业务量才是决定方案的那把尺子。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表