先给结论:你们这个「5 亿行、唯一键 (order_no, biz_date)、90% 查询带 created_at 范围、要按月分区让 DROP PARTITION 接管删历史」的目标,最稳的中间路线不是「硬塞 created_at 进唯一键」,也不是「放弃唯一键靠应用层兜底」,而是——把 created_at 作为分区列保留、同时把唯一键扩成 (order_no, biz_date, created_at),并且把「分区裁剪 + 删历史」和「唯一性约束」解耦:分区键进唯一键只是 MySQL 的语法要求,业务语义靠 (order_no, biz_date) 天然唯一,created_at 只作为分区定位列不参与去重判断。下面按「建表 → 存量迁移 → 增量对账 → 切换/回滚」给可落地顺序和坑。
一、唯一键怎么进分区键(你卡点 1) RANGE(created_at) 月分区要求所有唯一键包含 created_at。直接改唯一键为 (order_no, biz_date, created_at) 是安全的,前提是确认「同一 (order_no, biz_date) 的 created_at 恒定」——流水表里 created_at 是写入时刻,同一笔流水不会变,满足。落地 DDL:
CREATE TABLE t_flow_new (
id BIGINT NOT NULL,
order_no VARCHAR(64) NOT NULL,
biz_date DATE NOT NULL,
created_at DATETIME NOT NULL,
... 其他业务列 ...,
UNIQUE KEY uk_biz (order_no, biz_date, created_at),
KEY idx_created_at (created_at),
PRIMARY KEY (id)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(created_at)) (
PARTITION p202510 VALUES LESS THAN (TO_DAYS('2025-11-01')),
PARTITION p202511 VALUES LESS THAN (TO_DAYS('2025-12-01')),
... 预建到 6 个月后 ...,
PARTITION pmax VALUES LESS THAN MAXVALUE
);
关键取舍:
- 用
TO_DAYS(created_at)+RANGE,而不是RANGE COLUMNS(created_at)——两者都行,但 TO_DAYS 分区在跨时区/DATE 比较上更省心,且 DROP PARTITION 语义一致。 - 预建未来 6 个月分区 + 一个 pmax 兜底,避免「写到没有分区的月份」直接报错。
- 应用层唯一性判断仍只认 (order_no, biz_date),created_at 只是「分区定位 + 满足 DDL 语法」,不要把它写进业务幂等逻辑,否则时区/秒级精度会引入假冲突。
二、存量迁移与增量同步(你卡点 2) gh-ost / pt-osc 对「转分区」这种结构性变更确实支持差(你踩过的错是正常的,分区键变更不走 ghost 的 shadow table 逻辑)。推荐 双写 + binlog 增量追平 + 定时对账 组合,而不是单靠 diff:
- 建
t_flow_new(上面 DDL),先跑存量全量:按 created_at 分桶用INSERT ... SELECT ... WHERE created_at >= ... AND created_at < ... LIMIT 200000批量灌,配合SELECT ... ORDER BY created_at,id游标翻页,避免大事务和锁。5 亿行按 20 万/批约 2500 批,测试库你已跑出 6h+,生产按源库负载限流(用sleep或LOAD DATA控速,建议峰值写入打到源库的 20–30%)。 - 增量用 Canal 订阅老表 binlog(不是定时 diff):Canal 把老表 INSERT/UPDATE/DELETE 实时转发,写进
t_flow_new。这样迁移窗口里的新写入能持续追平,比定时 diff 的空窗小一个数量级。 - 对账兜底:定时跑
SELECT order_no,biz_date FROM t_flow与t_flow_new按 (order_no, biz_date) 做 COUNT + 抽样 SUM(某金额列) 比对,差异行单独补。对账频率建议 1 分钟一次、单批 5 万行,差异率 <0.001% 才允许进入切换。
三、切换窗口与回滚(你卡点 3) 推荐「影子读校验 → 双写停老表 → rename → 连接池重连」四步,目标停机控制在 30 秒~2 分钟:
- 应用切「双写」模式:写老表 + 写新表(新表为主、老表兜底),读仍走老表。
- Canal 追平后停双写里的「写老表」,只写新表;读流量逐步灰度到新表(按租户/比例 1%→10%→100%),每档观察 10 分钟慢查询和错误率。
- rename:
RENAME TABLE t_flow TO t_flow_old, t_flow_new TO t_flow;—— 这是一条原子语句,rename 本身毫秒级,真正的停机是应用连接池重连 + 在途 SQL 重放。把 rename 放在低峰、提前SET GLOBAL innodb_lock_wait_timeout调大、并把长事务在切换前 drain 掉,可把实际感知停机压到分钟级。 - 回滚预案:保留
t_flow_old至少 7 天,rename 是可逆的(再 rename 回去即可),所以回滚 = rename 反向 + 应用回读老表。这也是为什么我坚持「先 rename 双表并存」而不是「DROP 老表」——DROP 之后回滚就没了。
四、量化收益参考(按 5 亿行、140GB、90% 带时间范围查询估算)
- 删历史:从「DELETE 20 分钟 + 碎片」变成
ALTER TABLE t_flow DROP PARTITION p202504;,秒级、无碎片、无长事务。 - 范围查询:分区裁剪后单次扫描行数从 5 亿降到「单月约 1000–3000 万」,配合 idx_created_at 命中,P95 慢查询预计从分钟级降到百毫秒级(你们 90% 查询都带 3 个月内 created_at,裁剪收益是 3 个数量级)。
- 代价:写入路径多一次分区路由(可忽略)、Canal 追平期双写放大(迁移期约 2 倍写负载)、唯一键多一列(索引略变宽,约 3–5% 空间)。
如果你最后评估下来「分区路由 + 双写」成本太高,退路是冷热分离:老表只留近 3 个月热数据(正好是你们 90% 查询的范围),历史 4.7 亿行归档到独立的 t_flow_archive(按月 RANGE 分区、低配存储)。这条路线不碰唯一键、不改应用唯一性逻辑,代价是「按 created_at 跨冷热查询」要 UNION ALL 或应用层合并——但你们 90% 查询都在热区,跨区查询占比极低,通常更划算。
需要的话我可以把 Canal 的 filter.regex 配置和双写冲突处理(老表/新表同 key 不同 version 时的取舍)再展开一份。