订单系统MySQL大表归档实战:从3亿行到6000万行的性能优化
2026/9/16 2:07:15 网站建设 项目流程

去年Q3,我们订单系统的热表orders已经涨到接近3亿行。业务量并没有爆发式增长,但数据像滚雪球一样半年翻一倍,终于在大促压测时露了馅:下单详情接口P99从40ms飙到1.2s,报表任务从10分钟拖到40分钟,连备份从2小时膨胀到近6小时。当时团队讨论过加缓存、上读写分离、换分布式中间件好几条路,最后真正落地并稳定运行到现在的,是一套订单系统历史数据归档方案。这篇不聊PPT,就讲我当时怎么划边界、怎么改分区表、怎么把归档链路跑起来,适合手里正捏着一张几亿行订单表、想控制成本又不打算伤筋动骨重构的同学。

1. 订单表膨胀的三本账:性能、成本、稳定性

1.1 查询变慢只是表象:索引与内存的恶性循环

动手之前得先搞清楚,订单表为什么越用越慢。很多人第一反应是“加索引”,但真正的问题往往在索引本身。orders表3亿行之后,上面挂着idx_user_id、idx_status、idx_created_at这些常规索引,单看都合理,可B+树的层级会随着数据量变大而加深,索引页的缓存命中率也会往下掉。

MySQL的InnoDB把热数据放在buffer pool里,但内存是有限的。当orders表连同它的索引吃掉大量buffer pool之后,其他表的缓存会被挤出去,于是出现一个奇怪现象:明明只查一张小配置表,速度也跟着变慢。这是订单表膨胀带来的“连带伤害”。

我习惯用抽屉来类比:一个抽屉塞满旧文件,你找一份新合同也得先翻过一堆过期单据。分区和归档的本质,就是把抽屉按月份隔开,把过期单据挪到仓库,日常办公只需要面对最近几个抽屉。

具体算一笔粗账:一张5000万行的订单表,每行平均1KB,光数据就是50GB,加上二级索引,存储占用可能超过80GB。到了3亿行,数据加索引直奔500GB。这时候常见的内存配置根本装不下热索引,磁盘IO自然成为瓶颈——查询慢不是SQL写得差,是底层存储访问路径变长了。

1.2 备份恢复和DDL窗口被拖垮

膨胀的影响不止在线查询。做过备份运维的都知道,全量备份基线和binlog体量会随着大表一起膨胀。以前每天凌晨2点的物理备份,4点前能跑完;3亿行之后,6点可能还在扫页。RTO跟着越拉越长——真到了需要恢复数据那天,多等一小时都是事故。

麻烦的还有DDL。业务每隔一段时间总要加字段,虽然MySQL 8.0的INSTANT算法让很多加字段操作可以秒回,但像重建索引、修改列类型这类动作,在大表上执行时间会特别长。就算走Online DDL,表长时间变更时也可能因为元数据锁等待、IO负载而影响线上请求。数据量小了,这些事都好商量;数据量大了,想找个变更窗口都困难。

1.3 统计信息失真让执行计划“飘”起来

第三种成本最隐蔽。订单表持续增长,优化器的统计信息更新频率跟不上数据变化,某些查询就会基于过期统计信息选错执行计划。同一个订单查询接口,昨天走idx_created_at只扫几万行,今天突然切换成idx_user_id拉出几百万行,DBA一脸懵,排查半天发现是统计信息太久没更新。

数据归档以后,在线表的行数会稳定在一个量级,统计信息更接近真实分布,这类执行计划漂移会少很多。所以归档方案真正解决的,不只是释放磁盘空间,而是把整张表的运行基座变轻:内存命中率恢复、DDL窗口缩短、备份RTO降低、执行计划回归稳定。这些收益在设计阶段不好量化,上线后监控大屏上都能看到。

2. 归档边界怎么定:先回答四个问题再动手

一上来就写脚本搬数是最危险的。归档边界定错,要么把还热的业务数据误判成冷数据,要么旧数据永远归档不干净。我在动手前逼团队先回答四个问题。

2.1 冷热标准要同时看年龄和状态

最土的写法是“超过3年就归档”。但仔细想,一张3年前的订单如果正处于售后处理中,归档后主流程查不到,客服工单直接卡死。反过来,昨天刚创建的订单如果状态是“已关闭”,确实算冷数据,但用户短期内仍可能反复查看。所以我不会只看时间或只看状态,而是用组合条件判断。

推荐定义一个待归档判断规则:

  • 主单状态处于终态:已完成、已关闭、已退款,同时创建时间早于归档阈值(比如2年)
  • 取消或支付失败这类简短状态的订单,可以适当放宽到1年
  • 长期未支付、卡在中间态的异常订单,不主动归档,单独走清理流程

这套逻辑的核心是:终态且足够老的数据,用户再次访问的概率会持续走低,但它又有合规保存要求,适合进归档库。

2.2 保留周期需要业务、财务、法务一起拍板

经常有开发同学问:“归档后数据是不是可以删了?”我的回答是,归档不等于删除。订单数据往往涉及发票、退款、纠纷凭证,财务和法务会有明确的保留年限要求,哪怕一笔订单再久远,也不能直接物理DELETE。归档库要当成一个独立数据层来建,而不是垃圾桶。

保留周期最好由业务负责人和财务、法务一起确认。常见分法是:

  • 在线主库:保留最近2年数据,保证日常高频查询和写入体验
  • 归档库:保留全量历史数据,至少覆盖合规要求年限(比如5年甚至更久)
  • 确实过期且无关联售后、无凭证关联的数据,再单独走确认后的清理流程

2.3 子表的归档顺序决定成败

订单系统不是只有一张orders表。我见过一个订单中心,关联表有order_items、payments、refunds、invoice、after_sales超过五张。归档必须保持血缘关系,如果先把父订单搬走,子表还在主库,而子表又没有冗余订单号,后面想按订单号追溯就非常困难。

我的做法是:归档任务以订单维度为单位,同一订单的所有关联子表在一个批次内一起搬,而不是按表维度遍历。因为按表遍历会出现“orders已搬完、order_items还没搬”的中间状态,校验和回滚都很难受。实际伪代码大致是:

  1. 按筛选条件找出待归档的订单ID集合
  2. 查询这些订单的order_items、payments、refunds等所有子表记录
  3. 按父订单维度把所有数据写入归档库,并打上批次号
  4. 主库删除时按订单ID删除其所有子记录和主记录,控制每条DELETE影响的行数

2.4 保证“归档后依然能查”

这是最容易被忽略的需求。原先订单查询功能直接查主表,归档之后,用户或客服搜一个一年前的订单,接口返回空,立刻会收到一堆投诉。不能只搬数据,不搬查询能力。

我们当时做了一个统一的历史订单查询入口,在线订单查询接口加了fallback逻辑:在线库没查到,再查归档库。更稳妥的做法是做一个聚合查询网关,接口先查缓存,再到在线主表,最后到归档库。归档库的查询不走全表扫描,而是按订单号或用户ID加时间范围走二级索引。

这一层做得好,归档上线对业务完全透明;做不好,方案再漂亮也上不了线。

3. 分区表是归档的地基:老表平滑改造实录

3.1 为什么不是DELETE而是“挪分区”

很多同学会问:“直接写DELETE FROM orders WHERE created_at < ...不行吗?”理论上可以,但实践中问题很大。

第一,大批量DELETE在InnoDB里并不是释放空间,它只是把行标记为删除,物理空间要靠purge线程异步回收,数据文件短时间内不会变小。第二,大事务删除会造成undo膨胀、binlog暴涨,主从同步延迟被瞬间拉高,我记得有个凌晨跑的DELETE任务,把从库延迟拖到30分钟以上。第三,DELETE期间表上的锁竞争和IO压力会直接影响在线业务。

分区表的思路则完全不同。按月份把orders表切成多个物理分区,归档时直接对某个分区做迁移或摘除,而不是逐行DELETE。操作的逻辑单位从“一行记录”变成“一个分区”。就像收拾房子,你不会把旧文件一张张抽出来,而是把装旧文件的抽屉整个搬去仓库。

操作上,在MySQL里可以先用SELECT把分区数据导入归档库,再通过ALTER TABLE ... TRUNCATE PARTITION或DROP PARTITION清空分区,比起逐行DELETE高效得多。

3.2 无分区老表升级为分区表的具体路径

如果订单表已经很臃肿,想一条ALTER直接加分区是走不通的。MySQL不允许直接给普通表“加上分区”,至少需要重建。我们用的是影子表切换:

  1. 创建一张orders_new新表,引擎、字符集和老表一致,按created_at做RANGE分区
  2. 老表加触发器或者借助在线变更工具,把增量变更同步到orders_new
  3. 分批次把老表历史数据导入orders_new,注意控制导入速度,别一次性梭哈
  4. 追平后做一致性校验,在低峰期切换表名:RENAME TABLE orders TO orders_old, orders_new TO orders
  5. orders_old保留一段时间,观察线上无异常后再清理

这套流程确实麻烦,但最稳。很多迁移事故都是想“一条SQL搞定”,结果在锁表或变更过程中翻车。

3.3 分区粒度与唯一索引的取舍——踩坑点

分区粒度上,订单表我基本都用按月RANGE分区。按年太粗,一个分区还是几千万行,归档不够干净;按天又太多,MySQL单表分区数量有限制,实际到几百个就不好维护了。按月分区,两年数据大概24个分区,兼顾维护成本和查询裁剪效果。

真正踩坑的是唯一索引。MySQL分区表有个硬性规定:每个唯一索引必须包含所有分区列。比如按created_at分区,又想给order_no建UNIQUE KEY(order_no),MySQL会直接报错。订单号这种业务上全局唯一的字段不能加唯一索引,确实难受。我当时给了两个解法:

  • 如果应用层能保证幂等,把order_no唯一索引降级为普通索引,写入前用业务幂等机制拦截重复
  • 如果必须保留唯一约束,就改成(order_no, created_at)联合唯一,同一订单号只会在一个创建时间点产生,实际效果等同于全局唯一,同时满足分区表约束

4. 归档链路落地:小批次、幂等、可校验

4.1 两段式归档:先复制后删除

我设计的归档链路不是“主库删一行、归档库写一行”,而是两段式:

第一阶段,从主库读取待归档数据,批量写入归档库;第二阶段,主库删除已归档数据。第一段失败了,主库数据完好无损;第二段即使失败,也只是主库多留了些已归档数据,任务重跑时按幂等规则跳过。

每个批次大致是:

  1. 按订单ID区间或时间范围捞一批数据
  2. 写入归档库时带上batch_id和归档时间
  3. 写入完成后比对条数和关键字段汇总值
  4. 校验通过,再执行主库删除,删除同样分批进行

4.2 任务调度与断点续跑的细节

线上归档不能在高分期猛跑,一般选凌晨低峰。任务本身必须支持断点续跑,不能一挂就从头来。我实现了一张归档进度表:

CREATE TABLE archive_task_progress ( task_id BIGINT PRIMARY KEY, archive_date DATE NOT NULL, last_order_id BIGINT NOT NULL, batch_count INT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL );

任务启动时读取上次的last_order_id,继续往后扫,不管脚本重启还是数据库重启都能接着跑。每批行数控制在500到2000之间,宁小勿大;两个批次之间sleep 50-100ms,给主从库一点喘息时间。归档不是比赛跑得快,而是把峰值压力摊平。

4.3 校验口径怎么设计

归档校验是决定你敢不敢删除主库数据的核心。我常用的口径有三个:

  • 条数校验:归档库中该批次的orders行数等于主库中同批次的待归档行数
  • 金额校验:归档库SUM(total_amount)等于主库SUM(total_amount)
  • 明细抽样:随机抽几个订单,把主库和归档库的全字段哈希值做比对

校验放在“写归档库”和“删除主库数据”之间。只要校验通过,删除阶段中途失败也没关系,重跑时能通过batch_id识别出哪些订单已归档,不再重复写入。幂等靠的是归档库里的唯一键(batch_id, order_id)或业务订单号做防重,而不是每次全量比对。

5. 真实踩坑记录:这些细节让归档方案翻车

5.1 created_at与updated_at选错,归档永远跑不完

我见过一个归档脚本,最初用updated_at做时间边界。表面看逻辑没问题,结果半年后发现,很多老订单因为售后、改价、补发等操作不断被更新,updated_at永远是最新时间,导致它们一直不满足归档条件。真正应该依据的是订单生成时间created_at:一张订单是否够老,由它第一次出现的时间决定,跟最近有没有被改动没关系。售后规则要处理,那就单独用关联表状态去拦截,而不是靠updated_at来兜底。

5.2 售后单没处理完就把订单搬走了

这点在边界设计时提过,但实际执行很容易漏。光看主订单状态是“已完成”还不够,因为可能正好存在一条新增的售后申请。我当时在归档SQL里对每个待归档订单做存在性检查:

  • 是否存在未关闭的售后记录
  • 是否存在未完成的退款单
  • 是否存在未回传的发票或对账凭证

只要命中一条,这个订单就从本次归档候选中剔除,留到下一轮处理,绝不允许跨过关联状态强行归档。

5.3 归档订单的状态回写问题

归档后最大的产品投诉是“为什么这个订单不能改状态”。比如财务要给一个两年前的订单补开发票,需要一个标记;客服要把历史问题订单标为特殊处理。数据搬走后,原来的更新接口直接失效。

合理的方案是:为归档订单提供单独的状态变更通道,或者在归档库只读副本之外,把状态修改字段单独记录到变更日志表。不要指望业务方绕开系统去改数据库,那只会造成新的数据不一致。

5.4 报表口径“缩水”背后的历史数据合并

很多报表直接查在线订单表统计GMV。归档后在线表只保留近两年数据,月报一旦触达历史区间,数字就对不上。幸好这个问题能在报表层解决:上线归档前,把所有历史月份的统计数据固化成月度汇总表;线上报表统计直接读汇总表,不再全量扫明细。这样即使明细搬走了,汇总指标一条不少。

如果还需要保留按用户维度查历史订单的能力,归档库就要建user_id加created_at的二级索引,同时也要控制归档库的存储成本,别把在线库的索引原封不动复制一份,那是浪费。

5.5 唯一索引在分区表里的限制

这个在前面3.3提过,但值得再强调:如果order_no在建表时是UNIQUE KEY,按created_at分区后大概率报错,提示唯一索引必须包含分区列。这不是SQL写错,而是MySQL对分区表的硬性限制。提前评估唯一索引的替代方案,别等上线当天才回过神来。

6. 上线节奏与回滚预案:先想好怎么撤退

6.1 灰度归档三步走

归档是最适合灰度放开的操作之一,我当时的节奏是:

第一步,先用半年前且无售后的订单跑一个批次,观察归档耗时、主从延迟、接口P99变化; 第二步,扩大到一年前、两年前的订单,连续跑三天,盯紧告警; 第三步,确认稳定后,才把完整保留周期的归档正式纳入定时任务。

千万别一上来就把两年数据一次性搬完,那属于给自己挖坑。

6.2 一键暂停与回滚通道

归档任务必须有一键暂停的能力。我习惯在配置中心放一个开关变量,归档脚本启动前先检查开关,关闭则直接退出。一旦发现对业务有影响,立刻断开任务,比改代码快得多。

回滚方案分两档:

  • 软回滚:如果归档后发现查询异常,但在线数据已经物理删除,可以从归档库反向导回。前提是归档库主键设计和主库保持一致,避免回插时ID冲突
  • 硬回滚:从备份中恢复,时间成本高,只作为兜底

正式归档前我还会做一次完整备份,并验证备份可恢复性。光有备份文件、平时不演练,真到回滚那天会发现自己根本不会用。

6.3 归档上线后盯什么指标

归档不是上线就结束,至少观察一个完整周期:

  • 在线订单表行数和存储占用,确认没有异常反弹
  • 主从延迟,确保删数任务没有拖垮同步
  • 订单详情接口和列表接口的P99,确认历史订单查询走归档库的逻辑稳定
  • 归档任务失败率、校验失败数、告警次数

我习惯把这些指标放到单独面板,任务运行期间实时看。连续几个周期都是绿色,基本就可以放心让它自己跑了。

这半年多跑下来,订单体量看着没涨多少,但orders在线表从3亿多行降到稳定的6000万行左右,磁盘占用少了接近80%,接口P99回落,备份窗口也回到可控范围。业务侧没有感知,因为历史查询和汇总报表已经全部迁移到归档层。如果你也要把这套方案搬到自己系统,我的体会很直接:七分在设计,三分在写脚本,先把“哪些数据该走、哪些数据必须留、归档后怎么查”这三个问题回答上来,方案基本就稳了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询