最近完成了一次对亿级订单数据表的改造,通过引入 ShardingJDBC 进行分表,将原本响应缓慢的系统性能提升了一个数量级。这次经历充满了挑战,但最终成果显著。
一、背景:当增长成为负担
半年前,业务进入快速增长期,用户量和订单数据呈指数级上涨。起初,系统的表现令人满意。但好景不长,当订单表数据突破600万大关后,系统开始频繁“卡顿”。
运营同事首先反馈问题:“查询上月的订单汇总,等了快2分钟还没结果,用户已经在抱怨了!”登录数据库检查,发现一条简单的 group by 查询语句就能让 CPU 使用率飙升到 90% 以上,磁盘 I/O 压力巨大。问题的根源很明显:单表数据量过大,数据库已经不堪重负。
最糟糕的情况发生在大促期间,订单量暴增直接导致系统崩溃。用户付款后无法查看订单,客服电话被打爆,老板下了死命令:“24小时内必须解决!”那几天几乎住在公司,现在回想起来仍心有余悸。
被逼到绝境,分表分库成为了唯一的选择。在 MyCat、ShardingSphere-Proxy 和 ShardingJDBC 之间权衡后,我们最终选择了 ShardingJDBC。原因在于它足够轻量,无需部署额外的中间件服务器,对现有代码的侵入性也较小,非常适合我们这种需要快速“救火”的场景。
二、分表前的关键决策
很多人一上来就盲目建表,结果中途发现方向错误,白费功夫。在动手之前,必须想清楚三个核心问题:分什么表、怎么分、分片键选什么。
2.1 确认哪些表需要分
不是所有表都值得进行分表操作。
像字典表、配置表这类数据量通常只有几千条的表,强行分表纯属多此一举。
我们首先拉取了数据库中所有表的数据大小和行数清单。分析后发现,除了订单主表,用户行为表和商品评价表的数据量也接近千万级别。而商品表、用户表等,虽然数据在增长,但尚未达到百万级,可以暂时不动。
一条实用的经验法则:当单表数据量超过600万时,查询性能通常会出现明显下降;超过1000万,基本会陷入天天卡顿的境地。因此,建议将单表数据量控制在600万以内,以保持稳定的性能。
2.2 确认分表方式
水平分表还是垂直分表?
- 垂直分表:如同将一本书按章节拆分成几本小册子。例如,将订单表拆分为基本信息表(订单号、用户ID、金额)和物流信息表(地址、物流单号)。查询基本信息时无需扫描物流字段,速度更快。
- 水平分表:如同将多本相同的书按序号分到不同书架。例如,将订单表按月拆分为
order_info_202301、order_info_202302…… 查询某个月的数据时,只需扫描对应的表。
我们的订单表字段不多,核心矛盾在于巨大的数据量,因此选择了水平分表。如果你的表字段非常多,且包含大字段(如图片、长文本),同时业务查询往往只涉及其中部分字段,那么垂直分表可能更合适。
2.3 确认分片键
分片键的选择至关重要,选错等于白忙一场。
分片键是决定一条数据落入哪张分表的字段。如果选择不当,分表后的查询性能可能比之前更差。
最初,我设想用订单号 (order_no) 作为分片键,但立刻被否决:“如果用订单号分,运营需要查询某个用户的所有订单时,岂不是要遍历所有分表?这和没分表有什么区别?”
确实如此!运营和客服高频查询的场景都是基于用户ID (user_id) 或订单创建时间 (create_time)。那么,该选哪一个呢?
- 选
create_time 按月分表:查询某个用户的历史订单需要跨越多张表。
- 选
user_id 按哈希分表:查询某一天的订单也需要跨表查询。
最终我们采取了折中方案:主要分片键采用 user_id,进行哈希分表;同时为 create_time 建立二级索引。在查询时,尽量带上 user_id。例如,运营需要查询某天的订单时,可以先通过 create_time 缩小时间范围,再结合 user_id 的分片规则,尽可能减少需要扫描的分表数量。
重要经验:选择分片键时,务必与业务方深入沟通,明确最高频的查询模式,优先保障核心业务的查询效率。切忌闭门造车,否则分表后业务方体验不佳,还需返工重构。
三、ShardingJDBC 实战
理清上述问题后,便可以开始实施。我们以 Spring Boot 项目集成 ShardingJDBC 为例,详述操作步骤。
3.1 环境准备:注意版本兼容性
版本兼容性至关重要,搭配不当会出现各种难以排查的 Bug。我们使用的环境是:ShardingSphere 5.1.1,Spring Boot 2.6.7,MySQL 8.0.28,经测试兼容性良好。
如果你的项目使用 Spring Boot 3.x,则需要选择 ShardingSphere 5.3.0 或更高版本。建议查阅官方文档的兼容性列表,不要盲目尝试。
在 pom.xml 中添加依赖:
<!-- ShardingJDBC核心依赖 -->
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>5.1.1</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.28</version>
</dependency>
<!-- 连接池(Spring Boot默认用HikariCP) -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>4.0.3</version>
</dependency>
注意:无需额外引入 Spring JDBC 依赖,ShardingJDBC 的 starter 已经包含。
3.2 分表策略配置:以订单表为例
我们将订单表 (order_info) 按 user_id 字段哈希取模,拆分为16张表,表名从 order_info_0 到 order_info_15。为什么是16张?分表过多会增加数据库连接数和管理复杂度;分表过少则每张表数据量依然庞大,性能提升有限。通常,8到32张表是一个比较合理的范围。
application.yml 配置如下:
spring:
shardingsphere:
datasource:
names: order-db
order-db:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=UTC
username: root
password: 123456
rules:
sharding:
tables:
order_info: # 逻辑表名
actual-data-nodes: order-db.order_info_${0..15} # 实际表
table-strategy:
standard:
sharding-column: user_id # 分片键
sharding-algorithm-name: order-table-inline
sharding-algorithms:
order-table-inline:
type: INLINE
props:
algorithm-expression: order_info_${user_id % 16} # 分片算法
props:
sql-show: true # 开启SQL日志,调试用
关键配置说明:
actual-data-nodes: 实际数据节点,order-db.order_info_${0..15} 表示在 order-db 数据源下的16张物理表。
table-strategy.standard: 标准分片策略,适用于单一分片键的场景。
algorithm-expression: order_info_${user_id % 16}: 分片算法表达式,通过对 user_id 取模16来决定数据落入哪张分表。
配置完成后,需要手动创建16张物理表。不建议让 ShardingJDBC 自动建表,容易产生问题。建表 SQL 与普通表无异,仅表名不同:
CREATE TABLE `order_info_0` (
`id` bigint NOT NULL AUTO_INCREMENT,
`order_no` varchar(64) NOT NULL,
`user_id` bigint NOT NULL,
`amount` decimal(10,2) NOT NULL,
`create_time` datetime NOT NULL,
`status` tinyint NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
其余15张表结构完全相同,仅需修改表名。请务必记得创建索引,否则查询性能依然无法保证。
3.3 代码层改造:侵入性极小
这是 ShardingJDBC 的一大优势——对业务代码的侵入性极低。原本使用 MyBatis 操作 order_info 表的代码,几乎无需改动。
例如,原有的查询用户订单的 Mapper 接口:
public interface OrderInfoMapper {
List<OrderInfo> selectByUserId(@Param("userId") Long userId);
}
对应的 XML 中的 SQL:
<select id="selectByUserId" resultType="OrderInfo">
SELECT * FROM order_info WHERE user_id = #{userId}
</select>
完全无需修改任何代码! ShardingJDBC 会根据配置的分片规则,自动将查询路由到正确的分表。例如,当 userId = 100 时,100 % 16 = 4,查询会自动指向 order_info_4 表。
从日志中可以看到实际执行的 SQL:
SELECT * FROM order_info_4 WHERE user_id = 100
是不是非常方便?业务逻辑基本保持不变。
但是需要注意:如果查询条件中不包含分片键 (user_id),ShardingJDBC 将不得不进行全表路由(即扫描所有16张分表),性能会非常差。因此,应尽量在业务设计上保证查询携带分片键。
四、踩坑实录与解决方案
虽然 ShardingJDBC 使用简单,但在实践中仍会遇到不少挑战。
4.1 分片键选择不当导致性能下降
如前所述,最初差点选用 order_no 作为分片键。测试时发现,运营查询某天的订单汇总耗时超过5秒,比未分表时更慢!查看日志发现,由于查询只带有 create_time 条件,未带 user_id,导致 ShardingJDBC 需要查询所有16张分表并合并结果。
解决方案:
- 与业务方沟通,优化查询模式,尽量附带用户ID范围或结合日期与用户ID进行查询。
- 为
create_time 字段建立有效的二级索引。
- 在应用层对不携带分片键的查询进行限制,例如数据量超过1000条时提示“请补充筛选条件”。
4.2 分表后的分页查询难题
分表后的分页查询是一个经典难题。例如,查询订单列表,按 create_time 倒序排列,取第1页(每页10条)。
如果直接使用 LIMIT 0, 10,ShardingJDBC 会在每张分表上都执行 LIMIT 0, 10,然后将所有结果(最多160条)在内存中合并、排序,最后取出前10条。如果每张表有100万数据,这个合并排序过程将极其缓慢,且可能出现数据重复或丢失。
解决方案:采用“游标分页”或“上次查询最大值分页”。
首次查询:
SELECT * FROM order_info
WHERE create_time <= '2026-01-01 00:00:00'
ORDER BY create_time DESC, id DESC
LIMIT 10
假设返回的最后一条数据的 create_time 是 '2025-12-31 23:59:59',id 是 10000。
那么下一次查询则基于这个“游标”:
SELECT * FROM order_info
WHERE create_time < '2025-12-31 23:59:59'
OR (create_time = '2025-12-31 23:59:59' AND id < 10000)
ORDER BY create_time DESC, id DESC
LIMIT 10
这种方式可以精确地获取下一页数据,避免了重复和缺失,性能也更好。
4.3 数据迁移的挑战
分表过程中最复杂的环节之一是数据迁移。我们需要将原有单表 order_info 中的800万条数据,在线迁移到16张分表中,且不能停止服务。
最初尝试自己编写迁移脚本,但很快就遇到了数据不一致的问题:在迁移过程中,源表的数据可能被修改,导致迁移后的分表数据不是最新状态。并且脚本运行效率低,预计需要十几个小时。
后来改用 ShardingSphere DataSync 工具,它是专门用于分库分表数据迁移的工具,支持在线迁移并保证数据一致性。
迁移步骤:
- 在 ShardingSphere 控制台创建迁移任务。
- 配置源数据源(旧单表)和目标数据源(分表集群)。
- 配置迁移表映射关系和分片规则。
- 启动迁移任务(通常先全量迁移,再持续增量同步)。
- 迁移完成后,对比数据量并验证一致性,最后切换流量。
整个迁移过程耗时约3小时,期间系统正常运行,未发生数据丢失。
重要建议:数据迁移务必使用成熟的工具,自行编写脚本风险较高。
4.4 分表引发的分布式事务问题
分表后,如果一个事务内的操作涉及多个不同的分表,就会遇到分布式事务问题。例如,用户下单时,需要同时向 order_info 表(分表)和 order_item 表(分表)插入数据。如果 order_info 插入成功但 order_item 插入失败,就会导致数据不一致。
我们最初忽略了这点,测试时发现部分订单缺少明细数据。经排查,是插入 order_item 表时发生了异常,但 order_info 表的插入已提交。
解决方案:启用 ShardingJDBC 的分布式事务支持(如 XA 事务)。
在 application.yml 中配置:
spring:
shardingsphere:
rules:
sharding:
transaction:
default-transaction-type: XA
provider-type: Atomikos
在 Service 方法上使用 @Transactional 注解:
@Service
public class OrderService {
@Transactional
public void createOrder(OrderInfo orderInfo, List<OrderItem> orderItems) {
orderInfoMapper.insert(orderInfo); // 插入订单主表
for (OrderItem orderItem : orderItems) {
orderItemMapper.insert(orderItem); // 插入订单明细表
}
}
}
这样,如果插入 order_item 表失败,整个事务会回滚,order_info 表的插入也会撤销,从而保证数据一致性。
五、进阶优化技巧
分表解决了核心的性能瓶颈,但我们可以通过其他手段让系统表现更出色。
5.1 引入缓存,降低数据库压力
将高频查询的数据缓存到 Redis 中,例如用户的最近订单列表、热门商品评价等。
例如,用户查询最近3个月的订单,可以将查询结果缓存。缓存的 Key 可以设计为 order:user:{userId}:recent,并设置合理的过期时间(如1小时)。下次同一查询请求到来时,直接返回缓存数据,无需访问数据库。
缓存更新策略:采用“更新数据库后删除缓存”(Cache-Aside)策略。当订单状态变更时,先更新数据库,然后删除或更新对应的缓存键,确保下次查询能加载到最新数据。
5.2 实现读写分离
分表后,查询压力仍然集中在一个数据库实例上。我们可以引入 MySQL 主从复制,实现读写分离:主库 (master) 负责处理写操作和强一致性读,从库 (slave) 负责处理大量的只读查询。
ShardingJDBC 原生支持读写分离,配置简便:
spring:
shardingsphere:
datasource:
names: order-db-master, order-db-slave1, order-db-slave2
order-db-master: # 主库配置
# ...省略
order-db-slave1: # 从库1
# ...省略
order-db-slave2: # 从库2
# ...省略
rules:
readwrite-splitting:
data-sources:
order-db:
type: Static
props:
write-data-source-name: order-db-master
read-data-source-names: order-db-slave1, order-db-slave2
load-balancer-name: round_robin
load-balancers:
round_robin:
type: ROUND_ROBIN
配置后,ShardingJDBC 会自动将写操作路由至主库,将读操作负载均衡到多个从库。
需要注意:MySQL 主从同步存在延迟。对于数据实时性要求极高的场景(如查询刚创建的订单),可以通过 Hint 强制指定从主库读取:
@Hint(writeRouteOnly = true)
public OrderInfo selectById(Long id) {
return orderInfoMapper.selectById(id);
}
5.3 建立完善的监控体系
分表后系统架构变得更复杂,必须建立有效的监控。我们使用 Prometheus + Grafana 监控以下指标:
- 慢查询数量与详情
- 数据库连接数和使用率
- 分片查询的响应时间分布
- 主从同步延迟时间
同时,在 ShardingJDBC 中开启 SQL 审计日志,便于事后排查问题:
spring:
shardingsphere:
rules:
audit:
auditors:
log-audit:
type: LOG
tables:
order_info:
auditors: log-audit
六、总结与经验
通过引入 ShardingJDBC 进行分表,系统性能得到了质的飞跃:订单汇总查询从原来的2分钟降低到200多毫秒;在大促等高并发场景下,系统保持了稳定,未再发生崩溃。
分表不是终点,而是一个新起点。随着业务持续发展,数据量会继续增长,未来可能还需要进一步分库或调整分片策略。性能优化是一个持续迭代的过程。
最后几点经验总结:
- 规划先行:分表前深入分析业务查询模式,正确选择分片键,这是成功的基石。
- 善用工具:在数据迁移、监控等方面,优先考虑成熟的中间件和工具,避免重复造轮子。
- 组合优化:分表配合缓存、读写分离、索引优化等手段,能产生“1+1>2”的效果。
- 敬畏数据:数据迁移和一致性处理必须谨慎,做好备份和验证。
- 保持观察:上线后持续监控系统表现,遇到问题耐心查看日志,逐步排查。
数据库分库分表是应对海量数据和高并发的经典架构手段,而 ShardingJDBC 提供了一种相对轻量、对代码侵入低的实现方式。希望这次实战经验能为你未来的架构升级提供一些参考。如果你在实施过程中遇到其他问题,欢迎在 云栈社区 的后端架构版块与其他开发者交流探讨。