站点跑上三年以后,后台最先撑不住的往往不是数据库,而是那几个数据表:操作日志、访问统计、邮件队列表、搜索词记录。这些东西的特点是只增不减,单条几十字节,一天几千上万条,看着不起眼,两年下来能把一个 40G 的盘吃掉一半。更要命的是,其中真正被查过的行,可能连千分之一都不到。
这篇讲我自己这套「冷数据下沉」的做法:怎么把一年前的日志从 MySQL 搬到对象存储,怎么保证搬过去的东西还能查得回来,以及搬完之后 Nginx、PHP 那边要跟着改什么。它不是「学会用 S3 SDK」那种教程,而是一套能直接照着落地、出错也能退回来的流程。
一、先想清楚:什么数据值得降级
降级不是删数据。删除是不可逆的,降级只是把「热」变成「冷」——查询变慢一点,但数据还在。判断一条数据该不该降级,我一般看三个条件,全部满足才动手:
- 访问频率随时间衰减。近 30 天查询过的行可能占 5%,一年前的行几乎没人碰。如果你的表是「按 id 顺序读」的业务表(比如文章正文),这条不成立,别搬。
- 数据本身是事件流,不是状态。日志天生适合下沉,因为它记录的是「发生过什么」,写入后不再变更。反过来,用户表、订单表是状态表,随时会被更新,下沉到对象存储等于给自己埋雷。
- 拿得回来。有人要查三个月前的一次登录记录时,你能在五分钟内把那一批数据取回来,或者直接从冷存储上查。做不到这一点,就不要下沉。
我站上最终选中的是三张表:typecho_operation_log(后台操作日志)、mail_queue_done(已发送的邮件记录)、visit_raw(原始访问明细,统计结果另存一张汇总表)。三张表合计 1200 万行,占库体积的 78%,但日常查询只碰最近 7 天。这就是典型的可下沉数据。
二、对象存储的一生:先把生命周期规则配好再往里写
很多人用对象存储的方式是「传上去就完事」,结果第二年发现账单比 VPS 还贵。原因是没有配生命周期。对象存储的成本结构是这样的:标准存储最贵,低频访问次之,归档更便宜,深度归档最便宜,但取回要等几小时并且按 GB 收费。
我的做法是按「有没有可能被查」分三档,写入时就分好前缀:
logs/oplog/2026/11/ → 标准存储,30 天后自动转低频
logs/mail/2026/11/ → 低频访问,180 天后转归档
logs/visit/2026/11/ → 低频访问,90 天后直接删(原始明细保留三个月就够)
主流对象存储都支持按前缀配生命周期规则,以 S3 兼容接口为例,用 aws cli 一条命令就能配:
aws s3api put-bucket-lifecycle-configuration \
--bucket my-log-bucket \
--lifecycle-configuration file://lifecycle.json其中 lifecycle.json 的关键部分是 Rules 数组,每条规则包含 Filter(按前缀过滤)和 Transitions(在多少天后转什么存储类)。这里有个细节很容易踩:规则的匹配是按前缀字符串,不是按目录。写 logs/oplog/ 会匹配所有以它开头的对象,包括 logs/oplog/2026/11/summary 这种你其实想长期保留的小文件。所以我在每个前缀下再分一层 raw/ 和 index/,规则只匹配 raw/。
第二条容易踩的坑是存档类存储的最小存储时长。归档存储在多数厂商那里有 90 天或 180 天的最短计费期,你在第 30 天删掉它,照样按 90 天收费。所以「90 天后直接删」这条规则只能用在低频访问类上,不能用在归档类上。我第一版规则就写错了,一个月后发现账单里有一堆「提前删除费」,虽然钱不多,但它说明规则和你以为的不一样。
三、搬数据:先导成文件,再传,别用逐行 INSERT 的思路搬
把 MySQL 的旧数据搬到对象存储,最笨也最稳的办法是「导出成文件」。不要写一个脚本逐行 SELECT 再逐行上传——那样慢、容易断、断了不知道断在哪。
我的流程是分片导出,一片一个文件,一片对应一天的数据:
#!/bin/bash
# 按天导出并压缩
DAY="$1"
mysql -uroot -p"$MYSQL_PWD" zz1984 -N -B -e "
SELECT id, uid, action, target, ip, created_at, extra
FROM typecho_operation_log
WHERE DATE(created_at) = '$DAY'
ORDER BY id" \
| gzip -6 > "/tmp/oplog_${DAY}.tsv.gz"
gzip -t "/tmp/oplog_${DAY}.tsv.gz" || exit 1
aws s3 cp "/tmp/oplog_${DAY}.tsv.gz" \
"s3://my-log-bucket/logs/oplog/raw/${DAY:0:4}/${DAY:5:2}/oplog_${DAY}.tsv.gz" \
--storage-class STANDARD几个关键点:
第一,导出时必须带 -N -B。 -N 去掉列名行,-B 用制表符分隔并转义特殊字符。不加 -B 的话,字段里含制表符或换行时会把一行拆成两行,回导时错位,而且不会报错。
第二,一定要先 gzip -t 再上传。 这条命令只做一件事:校验压缩包完整性。磁盘满、SQL 中途报错、管道被 kill,都会产生一个「看起来有大小、其实截断」的文件。我吃过一次亏:传上去 300 多个文件,恢复时才发现其中 40 个是残缺的,而那批的原始数据已经被我们从库里删掉了。
第三,上传路径里带日期,不带的后果是永远不知道该删哪个。 我把对象 key 设计成 logs/oplog/raw/YYYY/MM/oplog_YYYY-MM-DD.tsv.gz,这样生命周期规则、按天取回、批量清理三件事都能靠前缀一次搞定。
第四,不要在上传成功后立刻删库。 正确顺序是:上传 → 校验对象大小和 ETag → 等 24 小时(期间有任何异常都能喊停)→ 按主键区间分批 DELETE。删除时一次只删一个批次的 id 区间,每批 2000 行,批与批之间 sleep 200 毫秒,避免长事务和主从延迟。
四、回取:让冷数据「查得到」,而不是「存得下」
数据搬走以后的第一个月,一定会有人来问「三个月前那次登录是从哪个 IP 来的」。如果你的回答是「要恢复一下,明天给你」,这套东西就做失败了。所以我做了一个很土但很有效的索引方案。
每次上传成功后,同时写一个每周汇总的索引文件到 logs/oplog/index/YYYY-Www.tsv,内容是「id 区间、日期、对应对象 key、行数」:
BEGIN_ID END_ID DAY OBJECT_KEY ROWS
10231400 10235812 2026-05-03 logs/oplog/raw/2026/05/oplog_2026-05-03.tsv.gz 4412
10235813 10240201 2026-05-04 logs/oplog/raw/2026/05/oplog_2026-05-04.tsv.gz 4388查历史的时候,先用主库上一张很小的 oplog_range 表(只存 id 区间和日期,几万行)定位到是哪天,再下载那一个文件:
aws s3 cp "s3://my-log-bucket/logs/oplog/raw/2026/05/oplog_2026-05-03.tsv.gz" - \
| gunzip -c | awk -F'\t' '$2==10086 && $5 ~ /^1\.2\.3\./'一条命令,几秒钟出结果,不需要把数据导回 MySQL。这是整个方案里我最满意的一点:冷数据的可用性取决于取回路径有多短,而不是存储有多便宜。 一个文件 2MB 左右,下载加解压不到两秒,比开一个临时表再导入快得多。
如果确实需要把一段时间的冷数据放回库里做联合查询,就按日期区间把多个文件拼起来,用 LOAD DATA LOCAL INFILE 导进一张带日期后缀的临时表,查完就 DROP。不要往主表里 INSERT——那会瞬间把表撑回来,前面的工作白做,而且会破坏自增 id 的连续性判断。
五、迁移期间 Nginx 和 PHP 那边要跟着改的三处
冷数据下沉从来不只是 DBA 的事。库变小了,但和应用层相关的设置如果不跟着调,你会得到「库瘦了但站点没变快」的结果。
第一处:备份策略。 以前全库备份,现在应该拆成「结构 + 近期数据」全量、「冷数据」单独备份(其实对象存储的多副本本身就是备份)。我把 mysqldump 的参数改成排除这几张大表的 --ignore-table,备份时间从 40 分钟降到 6 分钟,恢复演练也从「不敢做」变成「每周做一次」。
第二处:统计类页面的查询要改写。 后台那个「本月访问趋势」以前直接 SELECT DATE(created_at), COUNT(*) FROM visit_raw WHERE ...,数据搬走以后它会突然少一段。正确做法是让统计只查汇总表 visit_daily,原始明细表只用于「下钻到具体 IP」这种低频需求。改完之后顺便解决了另一个老问题:那条 GROUP BY 查询以前会吃掉 3 秒,现在 40 毫秒。
第三处:对象存储的凭证不能写进代码库。 这是最容易出事的一处。我的做法是在服务器上给一个只对 logs/ 前缀有读写权限的子账号,凭证放 /etc/aws/logpush.env(权限 600,属主 root),cron 脚本里 source 进来。这样即使代码仓库泄露,对方也删不掉 bucket 里的其他东西,更不能读到别的数据。
# /etc/cron.d/oplog-archive
30 3 * * * root . /etc/aws/logpush.env && /opt/bin/oplog_archive.sh >> /var/log/oplog_archive.log 2>&1六、三个我踩过的坑,写出来省你一遍
坑一:时区。 MySQL 里 created_at 是 DATETIME,存的是服务器本地时间(UTC+8)。导出脚本用 date 生成日期,而 cron 继承的环境时区可能是 UTC。结果凌晨 0 点到 8 点的数据被算到了前一天,导出的文件里缺了 8 小时。修法是在脚本开头固定 TZ=Asia/Shanghai,并且在 SQL 里显式写 DATE(CONVERT_TZ(created_at,'+08:00','+08:00'))——看着多余,但它是给未来改时区的人留的路标。
坑二:DELETE 之后表文件不缩小。 删掉 78% 的行,du 看 ibd 文件几乎没变。这是 InnoDB 的正常行为,空间被标记为可复用但没还给操作系统。如果确实要还,只能重建表(ALTER TABLE ... ENGINE=InnoDB 或 OPTIMIZE TABLE),而重建会锁表、会翻倍占盘。我的选择是不还——留着给后续写入复用,反而减少了页分裂。判断标准是 DATA_FREE 是不是长期不降,如果是,那才考虑重建。
坑三:对象存储的「最终一致性」已经不存在,但缓存还在。 现在的对象存储基本都是强一致读,但 CDN 和 SDK 的本地缓存不一定。我遇到过「刚上传完就 HEAD 返回 404」,重试第二次就好了。所以校验逻辑里要有重试,不要第一遍 404 就判定上传失败并把本地文件删掉。同理,删除对象后立刻 LIST 也可能还看得到,等几秒再看。
七、这套流程的实际收益
跑满三个月的数字:库体积从 52G 降到 11G,全库备份时间从 40 分钟到 6 分钟,后台统计页从 3 秒到 40 毫秒,对象存储月账单大约 2.3 美元(含低频和归档)。最重要的不是省钱,而是「这个库敢动了」——以前升级 MySQL 大版本、做在线改表都要掂量半天,现在都是常规操作。
如果你也准备做,建议的顺序是:先挑一张最小的、最没人在意的表(我第一张选的是邮件发送记录),完整跑一遍导出、上传、校验、回取的流程,把脚本和索引表都跑通,再动第二张。不要一次把三张表全搬,那样出了问题你不知道是哪一步。
八、小结
冷数据下沉这件事,技术含量其实不高,难的是三件事的平衡:省钱、能查、可回滚。省钱靠生命周期规则,能查靠一张 id 区间索引表加一条 gunzip 管道,可回滚靠「上传后等 24 小时才删库」和「先 gzip -t 再上传」这两个硬规矩。把这三条守住,剩下的都是熟练工。真正会让方案失败的,从来不是对象存储本身,而是某一天有人为了腾空间随手 DELETE 了一把却忘了导出。