找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1686

积分

0

好友

220

主题
发表于 2026-2-14 08:35:54 | 查看: 35| 回复: 0

最近完成了一次对亿级订单数据表的改造,通过引入 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_202301order_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_0order_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'id10000

那么下一次查询则基于这个“游标”:

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 工具,它是专门用于分库分表数据迁移的工具,支持在线迁移并保证数据一致性。

迁移步骤:

  1. 在 ShardingSphere 控制台创建迁移任务。
  2. 配置源数据源(旧单表)和目标数据源(分表集群)。
  3. 配置迁移表映射关系和分片规则。
  4. 启动迁移任务(通常先全量迁移,再持续增量同步)。
  5. 迁移完成后,对比数据量并验证一致性,最后切换流量。

整个迁移过程耗时约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. 规划先行:分表前深入分析业务查询模式,正确选择分片键,这是成功的基石。
  2. 善用工具:在数据迁移、监控等方面,优先考虑成熟的中间件和工具,避免重复造轮子。
  3. 组合优化:分表配合缓存、读写分离、索引优化等手段,能产生“1+1>2”的效果。
  4. 敬畏数据:数据迁移和一致性处理必须谨慎,做好备份和验证。
  5. 保持观察:上线后持续监控系统表现,遇到问题耐心查看日志,逐步排查。

数据库分库分表是应对海量数据和高并发的经典架构手段,而 ShardingJDBC 提供了一种相对轻量、对代码侵入低的实现方式。希望这次实战经验能为你未来的架构升级提供一些参考。如果你在实施过程中遇到其他问题,欢迎在 云栈社区 的后端架构版块与其他开发者交流探讨。




上一篇:原力灵机范浩强专访:具身智能成功与否,只看商业回报率
下一篇:Elasticsearch实战:海量数据聚合查询从8分钟优化到300毫秒,性能提升1600倍
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 14:19 , Processed in 0.665481 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表