1. 单表还扛得住的时候,我把分库分表想简单了
做淘客系统的后端,最难受的往往不是业务方今天又提了什么花式需求,而是推广订单表在单库里一天天膨胀,慢查询越来越多,DBA监控面板像心跳监护仪一样在报警。数据库分库分表这件事,网上文档一大把,可真落到“用户 ID 与时间维度双策略 Sharding”上,踩过的坑比想象中多得多。我最早也以为分库分表就是把一张大表按某个字段劈成多份,但真正设计时第一道坎就来了:按用户 ID 切分,用户侧查询很顺;按时间切分,归档和统计很顺;两个维度都不舍得丢,怎么把它们叠在同一套规则里才不打架?
当时手上的核心表是一张推广订单流水表,每条记录代表一次电商平台成交回传,关联着推广者、订单号、商品、渠道、预估佣金、结算状态。淘客业务有个非常明显的特征:数据是纯流水型的,只增不减,而且每一次成交都会触发至少一轮回传写入。推广订单一旦产生,订单状态还会变化,比如用户退款、佣金失效,这意味着同一笔订单会被 update 多次。起初单表几百万行时,所有查询都很快;等到数据爬到三千万行左右,问题开始浮出水面。
1.1 先还原一张推广订单表的真实压力
我当时面临的表字段并不复杂,核心字段大概是:推广者 ID、商品 ID、平台订单号、成交金额、佣金、订单状态、下单时间、回传时间、结算月份。为了支持推广者后台的“我的订单”“团队业绩”“月度账单”页面,索引建得很重,比如 (promoter_id, create_time)、(platform_order_id)、(order_status, settle_month)。索引多建一个,写入链路就多一份负担,订单回传高峰的时候,InnoDB 的写盘和 binlog 同步压力已经让主库 CPU 吃紧。
真正压垮单表的是一类典型的运营查询:
sql复制SELECT order_no, item_title, pay_amount, commission_amount,
order_status, create_time, settled_time
FROM t_promoter_order
WHERE promoter_id = 102381
AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 20;
这张表单行平均长度接近 2KB,三千万行数据后,单表加索引总容量已经到 60GB 以上。上面这条 SQL 虽然走了 (promoter_id, create_time) 索引,但回表次数多,单次响应经常在 1 秒以上。凌晨跑结算汇总时,全表范围扫 SUM(commission_amount) GROUP BY promoter_id,直接把主库拖到慢查询堆积。
我当时做了个容量推演:就算日活推广者只有几万人,人均每天回传几十笔订单,每天新增也有几百万行。单表到八千多万行以后,无论怎么优化索引,物理上的随机 IO 和回表成本都很难再降。
1.2 从“改索引”到“拆库拆表”的分水岭
团队里一开始有人建议“先加缓存、再改索引、上读写分离扛一阵”。缓存能挡掉一部分用户反复查看自己最近订单的请求,但订单状态变更的实时性要求很高,缓存和数据库的一致性处理复杂;读写分离只能分担读压力,不能解决单表持续膨胀后写入和更新的瓶颈。真正让我决定必须做分库分表的信号有几个:
- 深夜对账任务跑一次要 40 分钟,经常影响第二天的结算单生成;
- 批量回写订单状态时,
UPDATE ... WHERE platform_order_id = ?的锁竞争导致高峰期写入延迟抖动; - 单实例物理容量已经撑不到第二年,扩容只能是往上堆硬件,性能和成本都不划算。
既然必须拆,接下来的问题不是“拆不拆”,而是“怎么拆”。我专门把 Java 团队、DBA 和负责数据仓库的同事拉到一起,先把查询模型盘了一遍,再决定 Sharding 的维度,而不是一上来就复制网上现成的用户取模方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么“只按用户 ID”和“只按时间”都会踩坑
分片键的选型,本质上是在回答一个问题:线上每一条 SQL 最经常用什么条件来定位数据? 在淘客场景里,最高频的查询来自 C 端推广者本人,他们只关心“我的订单”“我的佣金”,所以查询条件天然带 promoter_id。到了运营侧,又是另一套逻辑:他们关心某个时间段全平台成交了多少、某个渠道的佣金支出是多少,查询条件天然带 create_time 或者结算月份。两边都有强烈的诉求,单一维度必然满足不了所有场景。
2.1 纯用户 ID 取模:用户侧好用,但运营数据拉不出来
很多人第一反应是“用 promoter_id 做哈希取模,分成几十张表”。这个方案在用户侧确实很舒服:同一个推广者的订单都落在同一张物理表里,列表查询走到单表索引就完事了,不需要跨表合并。前期开发也简单,ShardingSphere 的 MOD 算法几行配置就能跑起来。
但纯按用户 ID 分片有两个非常明显的隐患。
第一,平台侧全局计算失效。运营想查“某个品牌今天在所有推广者那边的总成交额”,SQL 里没有 promoter_id,中间件没法定位到某个具体分片,只能把请求广播到全部分片上,再在应用层合并结果。平时低频的运营报表还能忍,如果每天凌晨的结算汇总都这样跑,分片越多,整体扫描成本反而越高。
第二,大推广者会造成数据倾斜。头部推广者的单日订单量可能是普通用户的几千倍,纯哈希取模只保证用户 ID 均匀分布,不保证数据行数均匀分布。某个运气不好落了大推手的分片,无论容量还是 IO 压力都会明显高于其他分片。这种倾斜在单表时代只是让某个用户的查询慢一点,分片后却会让整台实例成为热点。
2.2 纯时间分表:归档方便,但用户查询会“裂开”
时间维度分表的典型做法是每个月一张物理表,比如 t_promoter_order_202401、t_promoter_order_202402。时间维度的好处非常直观:写入基本是追加,顺序写对机械盘和 InnoDB 都很友好;做全局统计按时间段直接定位到对应月份表;删历史数据直接 DROP TABLE,不用跑大事务 DELETE。
坏处也非常显著。推广者个人中心一打开就是“最近三个月的订单”,如果三个月对应三张物理表,SQL 要同时访问三张表再合并排序;如果用户想查去年一整年的数据,跨表数量直接翻倍。更麻烦的是,很多运营和分析任务不会带精确时间范围,只说“我要看这个推广者的所有订单”,系统只能扫所有月份表。时间分表天然是为“时间序列统计”设计的,不适合“以用户为主体的点查”。
还有个隐藏的大坑是建表运维。为了不耽误业务,月初自动建表脚本要提前在几十个库上跑一遍,漏了哪个表,业务当晚就可能在某个写入路径上报“table not exist”。初期还好,分表数量一多,建表、改表、校验表结构的一致性就成了高成本劳动。
2.3 双维度叠加的核心逻辑
踩完这两个方向的坑,我和团队最终确认了思路:不能二选一,而是要让两个维度各管一段。
用户 ID 维度放在第一层,负责把一个用户的数据圈定在一个确定的范围内,避免用户查询跨库;时间维度放在第二层,负责在圈定范围内继续按时间段做隔离,让冷热数据、归档数据、统计扫描都有明确边界。这个设计有点像一个城市先按行政区划分片区,每个片区里再按街道做台账,既不会因为跨区找人而跑遍全城,也不会因为一个片区积累太多台账导致翻找困难。
在实际物理实现上,我们用了“中间件按用户 ID 分库 + MySQL 原生 RANGE 分区按时间分区”的组合,这才是后来线上稳定运行的版本。
3. 双维组合方案:中间件按用户 ID 分库,MySQL 分区表扛时间
先说明一下,很多文章会把“分表”理解成物理表后缀加月份,我们早期也按这个思路做过原型:每个库里都按月建 t_order_202401、t_order_202402 这种物理表。原型验证到一半,我发现用它带来的运维负担不值得。
每个月要在 16 个分库上各自建一张新表,虽然中间件可以路由,但库表结构变更要推送 16 次;如果某个月建表任务失败,还得处理写入失败和监控告警。相比之下,MySQL 的 RANGE 分区在逻辑上仍然是同一张表 t_order,由 InnoDB 按分区键把数据分散到不同的物理 segment 中,中间层不用感知分区存在。清理历史数据时直接 ALTER TABLE ... DROP PARTITION,比 DELETE 扫全表高效得多,也天然解决了数据归档的问题。
3.1 库、表、分区三级结构要分开设计
在设计 Sharding 规则前,我先确定了整体拓扑:
- 单元:推广订单核心流水表;
- 分库键:
promoter_id,负责将用户圈定到一个分库; - 分区键:
create_time,负责在库内按时间做冷热隔离; - 物理实例:4 台 MySQL 实例,每台实例上创建 4 个数据库,共 16 个分库;
- 数据保留:在线库保留最近 12 个月,超过部分迁移到归档库。
为什么不把分库数量定为 8 或者 32?我是按未来三年的容量推演算出来的。按当时回传规模,单库月增数据量在 20GB 左右,两年后单库容量要到 500GB。16 个库分摊后,每库每月只增长 1GB 多,容量和备份窗口都能接受。这个数字不能拍脑袋,也不能盲目追求多加库,库多了连接数、线程池、备份运维都会跟着翻倍。
3.2 建表语句中的分区设计
每个分库里的核心表结构如下:
sql复制CREATE TABLE `t_order` (
`order_id` bigint NOT NULL COMMENT '订单ID',
`promoter_id` bigint NOT NULL COMMENT '推广者ID',
`platform_order_id` varchar(64) NOT NULL COMMENT '平台订单号',
`item_id` varchar(32) DEFAULT NULL,
`shop_id` bigint DEFAULT NULL,
`pay_amount` decimal(10,2) DEFAULT '0.00',
`commission_amount` decimal(10,2) DEFAULT '0.00',
`order_status` tinyint NOT NULL DEFAULT '0',
`create_time` datetime NOT NULL,
`settle_time` datetime DEFAULT NULL,
PRIMARY KEY (`order_id`, `create_time`),
KEY `idx_promoter_create` (`promoter_id`, `create_time`),
KEY `idx_platform_order` (`platform_order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
PARTITION BY RANGE COLUMNS(create_time) (
PARTITION p202401 VALUES LESS THAN ('2024-02-01'),
PARTITION p202402 VALUES LESS THAN ('2024-03-01'),
PARTITION p202403 VALUES LESS THAN ('2024-04-01'),
PARTITION p202404 VALUES LESS THAN ('2024-05-01'),
PARTITION p202405 VALUES LESS THAN ('2024-06-01'),
PARTITION p202406 VALUES LESS THAN ('2024-07-01'),
PARTITION p202407 VALUES LESS THAN ('2024-08-01'),
PARTITION p202408 VALUES LESS THAN ('2024-09-01'),
PARTITION p202409 VALUES LESS THAN ('2024-10-01'),
PARTITION p202410 VALUES LESS THAN ('2024-11-01'),
PARTITION p202411 VALUES LESS THAN ('2024-12-01'),
PARTITION p202412 VALUES LESS THAN ('2025-01-01'),
PARTITION p_max VALUES LESS THAN MAXVALUE
);
这里有一个必须注意的点:MySQL 分区表要求所有唯一键(包括主键)都必须包含分区键,所以主键从单纯的 order_id 改成了 (order_id, create_time)。改完之后,订单号仍然全局唯一,业务代码不需要感知这个变化。
每次快到月末,我会用定时任务提前给所有分库执行加分区语句:
