凌晨两点,手机震动把我吵醒。打开监控,订单查询接口的平均耗时已经从50ms涨到了2s,99分位直接飙到5s。这不是第一次了,但这次我意识到,缓存和索引的优化已经到了极限。在继续加缓存和分库分表之间,我选择了后者。这篇文章记录这次改造的完整过程,包括分片键选择、数据迁移、读写分离、分布式事务,以及我踩过的三个大坑。
背景:订单系统的数据膨胀
我们的订单系统支撑着电商业务,核心表 orders 已经存了1.2亿行数据,每天新增约50万行。单表数据量过大,索引层级变深,即使走索引,随机IO的代价也越来越高。查询条件包括用户ID、商家ID、订单状态、创建时间等,但最频繁的查询是按用户查订单列表,以及后台按商家查订单。
一开始,我先尝试了常规手段:给 user_id, status, created_at 建了复合索引,把热数据缓存到Redis,甚至对历史订单做了归档。但这些措施只撑了两个月,随着数据量继续增长,接口性能再次恶化。我意识到,当单表行数过亿,索引维护成本高,缓存命中率受限于业务热点分布,单纯优化已经不够了。
核心判断:当数据量增长导致索引层级过深,且缓存无法覆盖所有查询模式时,分库分表是不得不走的路。
分片键选择:是用户ID还是商家ID?
分库分表的第一步是选分片键。我们分析了业务查询模式,发现两个核心场景:C端用户查看“我的订单”,B端商家管理订单。两者频率都很高,但C端查询更频繁,且要求响应时间更短。如果按商家ID分片,用户查询会跨多个分片,性能反而差。最终,我选择了用户ID作为分片键,同时为商家维度建索引表或冗余表来解决B端查询。
分片数量我们定了32个物理分片(4个库 × 8张表),用 user_id % 32 取模。这样每个分片的数据量约为375万行,在可控范围内。取模分片简单均匀,但后续扩容麻烦,我们考虑了一致性哈希,但初期取模足够,计划未来用迁移工具平滑扩容。
// 分片路由示例 public String routeDataSource(Long userId) { int shard = (int) (userId % 32); return "ds_" + (shard / 8) + "_t_" + (shard % 8); }数据迁移:差点丢数据的教训
改造前,我们需要把1.2亿行数据从单库迁移到32个分片。我选择了双写 + 全量迁移 + 校验的方案:
- 在迁移期间,应用层对订单的新增和修改同时写旧库和分片库(双写),保证增量数据不丢。
- 用脚本按主键范围分段读取旧库数据,按分片路由写入分片库。
- 迁移完成后,做数据校验:对比每个分片的行数、sum(amount)、以及抽样比对关键字段。
但就在校验阶段,我发现有部分分片的数据缺失。排查原因:迁移脚本中,读取旧库时用了 LIMIT offset 分页,但在迁移过程中,旧库有并发的插入操作,导致 offset 移动时部分行被跳过。这就是数据丢失的第一个坑。后来我改用基于主键的游标方式,每次取主键大于上次最大值的记录,彻底解决了这个问题。
// 正确的迁移方式:keyset 分页 String lastId = "0"; while (true) { List list = jdbcTemplate.query( "SELECT * FROM orders WHERE id > ? ORDER BY id LIMIT 1000", new Object[]{lastId}); if (list.isEmpty()) break; for (Order order : list) { insertToShard(order); // 按 userId 路由 } lastId = list.get(list.size()-1).getId(); }教训:数据迁移必须假设源库有并发写入,使用基于键的游标而非 offset 分页,否则会丢数据。
读写分离:读多写少的必然选择
分库分表后,单个分片的写压力不大,但读压力依然存在。我们的读写比大约是10:1,所以给每个分片加了一个只读副本。应用层通过ShardingSphere-JDBC配置读写分离,主库负责写,从库负责读。这里有个细节:订单查询有实时性要求,但主从延迟可能造成数据不一致。我们采用了强制路由,对实时性要求高的查询走主库,允许延迟的统计类查询走从库。
# sharding.yml 配置示例 rules: - !SHARDING tables: orders: actualDataNodes: ds_${0..3}.orders_${0..7} tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: orders_inline keyGenerators: snowflake: type: SNOWFLAKE - !READWRITE_SPLITTING dataSources: ds_0: writeDataSourceName: ds_0_master readDataSourceNames: [ds_0_slave]跨分片查询:设计上的妥协
最棘手的问题是跨分片查询。用户维度查询很完美,但后台按商家查订单时,由于商家ID与用户ID无关,查询会广播到所有分片,再聚合结果。这导致后台查询性能极差,甚至比改造前还慢。
我的解决方案是冗余商家维度的表:创建 shop_orders 表,分片键为 shop_id,通过异步任务将 orders 表的数据同步过去。这样商家查询走独立分片,性能有保证。代价是数据冗余和额外的同步逻辑,但业务上可以接受。
另一个坑是聚合查询:比如运营后台要统计某时间段的订单总数,这种查询如果跨分片,需要分布式聚合,非常耗时。我们建立了独立的统计表,通过定时任务预先计算,避免实时跨分片。
分布式事务:从强一致到最终一致
分库分表后,原本单库中的事务可能变成跨库事务。例如用户下单时,需要同时更新订单表和库存表,如果订单表在分片库,库存表在另一个库,就涉及分布式事务。我们最初尝试了Seata的AT模式,但发现性能开销较大,且对代码侵入性强。后来改为本地消息表 + 消息队列的最终一致性方案:事务只操作本库,通过MQ发送事件,由消费者更新其他分片的数据。
// 本地消息表示例 @Transactional public void createOrder(Order order) { orderDao.insert(order); messageDao.insert(new Message("order.created", order.getId())); // 同一事务,保证消息和订单同时成功 }这个方案虽然牺牲了强一致性,但换来了性能和可用性。对订单系统来说,最终一致完全够用。
效果数据与经验总结
改造完成后,订单查询接口的平均耗时从2s回落到80ms,99分位在200ms以下。写入性能也有提升,因为单表数据量减小,索引更紧凑。
这次改造让我对分库分表有了更深的认知。总结一下,适合分库分表的业务特征:
- 数据量增长快,单表行数过亿,且未来会持续增长。
- 缓存和索引优化已经触及天花板,无法满足性能要求。
- 存在清晰的分片键,且大多数查询能通过这个键路由到单个分片。
- 业务允许最终一致性,或者能够处理分布式事务的复杂性。
时机上,当数据库CPU和IO持续高位,且慢查询比例上升,常规优化失效时,就应该考虑分库分表。但别盲目跟风,如果业务查询模式不清晰,或者数据量在可控范围内,用好缓存和索引往往更省事。
分库分表不是银弹,它引入的复杂度远超想象。但如果你判断正确,它能给业务带来更长的稳定期。
如果你正在经历类似的性能瓶颈,不妨先评估业务特征再做决定。我们团队在改造过程中积累了不少经验,如果你有需要,欢迎来聊。铭锦数智在开发和小程序定制方面也有不少实战,比如我们做的“时光智行”小程序,就运用了类似的架构优化思路。