从邮件里捞回丢失的评论:SQLite WAL 备份踩坑全记录
2026 年 7 月 9 日 · 数据恢复纪实
评论丢了
上一篇文章讲到 git reset --hard 覆盖了 WAL 文件导致数据库清空,靠凌晨 3 点的自动备份恢复了 45 篇文章和 167 条动态。当时以为数据全部救回来了。
直到有人反馈:「昨天晚上的一些评论没了」
一查数据库,评论表最大 ID 是 311,最后一条停在 7 月 8 日上午 11:21。但 nginx 访问日志清楚地记录着——7 月 8 日下午到晚上,还有 10 条评论 POST 请求。
也就是说,311 号到 320 号之间的 9 条评论,在备份里就不存在。备份本身就不完整。
为什么备份没有这些评论?
答案藏在 SQLite 的 WAL 模式里。
WAL 模式的工作原理
SQLite 的 WAL(Write-Ahead Logging)模式下,写入操作不会直接写主数据库文件,而是先写到 blog.db-wal 日志文件中。只有当日志文件达到一定大小(默认 1000 页 ≈ 4MB),或手动执行 PRAGMA wal_checkpoint 时,数据才会合并(checkpoint)到主库 blog.db。
写入流程:
新数据 ──→ blog.db-wal(WAL日志) ──→ checkpoint ──→ blog.db(主库)
↑
只有这一步之后
数据才进入主库
而我的备份脚本是这样写的:
# /opt/backup-blog.sh(旧版)
cp /opt/blog/server/data/blog.db "$BACKUP_DIR/blog_${DATE}.db"
只 cp 了 blog.db,没管 blog.db-wal。
那些晚上写的评论,还躺在 WAL 日志里,没来得及 checkpoint 到主库。备份脚本 cp 了一个「不完整」的主库文件,WAL 里的新数据被完美地忽略了。
然后 git reset --hard 又把 WAL 文件覆盖了。双重打击,数据彻底消失。
第一轮恢复:从收件箱的通知邮件
博客系统有一个功能:访客发评论时,自动给博主发一封邮件通知,邮件里包含评论者昵称、评论内容、来源 IP、评论的文章。
如果邮件发成功了呢?
连接 IMAP 邮箱
博主邮箱是 lcrblog@163.com,SMTP 凭证就在服务器的 .env 里。直接用 IMAP 连上去读收件箱:
import imaplib
imaplib.Commands['ID'] = ('AUTH', 'AUTHENTICATED', 'SELECTED')
mail = imaplib.IMAP4_SSL('imap.163.com', 993)
mail.login('lcrblog@163.com', '******')
# 163 邮箱需要先发 ID 命令,否则 SELECT 会报
# "Unsafe Login. Please contact kefu@188.com"
tag = mail._new_tag()
mail.send(tag + b' ID ("name" "imapclient" "version" "1.0")\r\n')
while True:
line = mail.readline()
if line.startswith(tag): break
mail.select('INBOX')
status, messages = mail.search(None, 'ALL')
坑点:163 邮箱的 IMAP 服务要求客户端先发送
ID命令标识自己,否则SELECT命令会被拒绝,返回Unsafe Login错误。Python 标准库imaplib不内置 ID 命令支持,需要手动注册并发送。
邮件内容解析
收件箱一共 11 封邮件,其中 8 封是评论通知。逐封解析后,找到了 4 封属于丢失时间段的邮件:
#312 asdf 15:50 文章45 「建议您试试草莓云机场...」(广告 spam)
#313 Vei 17:27 文章45 「文章顶部的进度条让页面有卡顿/掉帧的感觉」
#314 Mr.Lee 17:43 文章45 「暗色模式的材质bug还是比较多...」
#315 Mr.Lee 18:17 文章33 「实验这个模块看起来不错,isr敢设置5分钟?羡慕」
每封邮件里都有完整的评论内容、访客昵称、IP 地址和时间戳。配合 nginx 访问日志里的 User-Agent,拼齐了所有字段。
写回数据库
const insert = db.prepare(`
INSERT INTO comments
(visitor_name, target_type, target_id, content, ip,
created_at, user_agent, ip_location, like_count, parent_id)
VALUES
(@visitor_name, @target_type, @target_id, @content, @ip,
@created_at, @user_agent, @ip_location, 0, NULL)
`);
for (const c of recoveredComments) {
insert.run(c);
}
4 条访客评论,ID 312-315,成功写回。
但 nginx 日志显示有 9 条评论 POST。剩下的 5 条呢?
5 条评论去哪了?
逐条分析 nginx 日志后,真相浮出水面:
| 时间 | IP | 设备 | 收件箱通知 |
|---|---|---|---|
| 15:50 | 42.200.* | Windows Chrome | ✅ 有 |
| 17:27 | 111.9.* | Mac Chrome | ✅ 有 |
| 17:43 | 104.28.* | Linux Firefox | ✅ 有 |
| 18:17 | 104.28.* | Linux Firefox | ✅ 有 |
| 18:29 | 183.197.* | Pixel 7 | ❌ 无 |
| 18:33 | 183.197.* | Pixel 7 | ❌ 无 |
| 18:37 | 183.197.* | Pixel 7 | ❌ 无 |
| 18:39 | 104.28.* | Linux Firefox | ❌ 无 |
| 18:50 | 183.197.* | Windows Chrome | ❌ 无 |
没有收件箱通知的 5 条评论分两种情况:
1. 博主自己的回复(4 条)
IP 183.197.* 来自 Pixel 7 和 Windows Chrome,是博主本人的设备。代码逻辑里,博主自己发评论不会触发「新评论通知」邮件——因为 !userId 的判断直接跳过了 sendBloggerNewCommentNotify。
这是合理的:博主不需要给自己发通知。但在数据恢复场景下,这意味着收件箱里没有任何渠道记录了这些评论的内容。
2. 访客评论但邮件发送失败(1 条)
18:39 来自 104.28.*(Mr.Lee)的评论没有邮件通知。可能的原因是服务器当时已经在反复崩溃重启(PM2 日志显示重启了 47 次),邮件发送过程在崩溃中被中断了。
第一轮恢复结束,我以为这 5 条评论永远地消失在了数字世界里。还写了一篇博文记录这个遗憾。
第二轮恢复:已发送文件夹里的回复通知
博文发出去没多久,一条提醒点醒了我:
博主回复访客评论时,系统会给访客发一封回复通知邮件,邮件里同时包含博主的回复内容和被回复的原评论。这些邮件在已发送文件夹里。
对啊!我只查了收件箱(新评论通知),完全忘了还有已发送文件夹(回复通知)。代码里写得清清楚楚:
// commentsController.js
if (parentComment && parentComment.email) {
// 博主回复不通知博主自己,但会给访客发通知
if (!(userId && parentComment.user_id === userId)) {
sendCommentReplyNotify(parentComment.email, recipientName, [replyData])
}
}
博主回复访客时,userId 存在但 parentComment.user_id 为 null(访客评论),条件成立,邮件发出去了。邮件里包含 replyContent(博主回复)和 parentContent(访客原评论)两段内容。
再次连接 IMAP,这次查已发送
163 邮箱的已发送文件夹不叫 Sent,而是用一个编码后的名字:
# 163 的文件夹列表
() "/" "INBOX"
(\Sent) "/" "&XfJT0ZAB-" # ← 这就是已发送
(\Trash) "/" "&XfJSIJZk-"
(\Junk) "/" "&V4NXPpCuTvY-"
选中 &XfJT0ZAB-,70 封邮件。逐封解析,找到了 4 封回复通知邮件:
| 邮件 | 时间 | 收件人 | 回复内容 | 原评论 |
|---|---|---|---|---|
| #56 | 18:29 | chzxu******@gmail.com | 对对对!多亏你提醒我ᗜⰙᗜ... | 暗色模式的材质bug... (Mr.Lee) |
| #57 | 18:33 | 12658******@qq.com | ٩(•̤̀ᵕ•̤́๑)ᵒᵏᵎ 谢谢你的喜欢... | 界面、布局、风格都相当奈斯... (瓦匠) |
| #58 | 18:37 | v**@veitzn.top | 谢谢,你也一样! 进度条已优化... | 进度条让页面有卡顿/掉帧... (Vei) |
| #59 | 18:50 | chzxu******@gmail.com | 谢大佬指教,现在就加... | 关于RSS还有个问题反馈... (Mr.Lee) |
每封邮件都包含完整的回复内容和原评论内容。更妙的是,回复通知邮件里的 parentContent 字段就是被回复的原评论——也就是说,那封「邮件发送失败」的 Mr.Lee RSS 评论,其完整内容恰好被保存在了邮件 #59 的原评论字段里。
第 5 条评论的意外回归
邮件 #59 的原评论字段中,Mr.Lee 关于 RSS 的评论完整保存着,共 195 个字符——而邮件模板里的截断阈值是 200 字符。差 5 个字符就截断了,但它没有。
那条「邮件发送失败、收件箱里没有」的评论,就这样从一封回复通知邮件的原评论字段里完整捞了回来。
额外收获:访客邮箱
回复通知邮件的收件人地址,就是访客在评论时留下的邮箱。顺带把 3 条评论的邮箱也补上了:
#313 Vei → v**@veitzn.top
#314 Mr.Lee → chzxu******@gmail.com
#315 Mr.Lee → chzxu******@gmail.com
全部写回
// 1. 更新访客邮箱
db.prepare('UPDATE comments SET email = ? WHERE id = ?')
.run('v**@veitzn.top', 313);
// 2. 插入 Mr.Lee 的 RSS 评论(从邮件 #59 原评论字段恢复)
db.prepare(`INSERT INTO comments
(visitor_name, email, target_type, target_id, content, ip, user_agent, created_at)
VALUES (?, ?, 'article', ?, ?, ?, ?, ?)`)
.run('Mr.Lee', 'chzxu******@gmail.com', 45,
rssContent, '104.28.157.27', linuxFirefoxUA, '2026-07-08 18:39:12');
// 3. 插入 4 条博主回复
for (const r of replies) {
db.prepare(`INSERT INTO comments
(user_id, target_type, target_id, content, parent_id, ip, user_agent, created_at)
VALUES (1, 'article', 45, ?, ?, ?, ?, ?)`)
.run(r.content, r.parent_id, r.ip, r.ua, r.created_at);
}
9 条评论,全部回来了。
修复备份脚本
问题的根源是备份脚本只 cp 主库文件,不管 WAL。修复很简单——cp 之前先做一次 checkpoint:
# /opt/backup-blog.sh(修复后)
# 先 checkpoint WAL,把所有数据刷入主库
node --input-type=module -e "
import Database from 'better-sqlite3';
const db = new Database('data/blog.db');
db.pragma('wal_checkpoint(TRUNCATE)');
db.close();
"
# 再用 sqlite3 .backup 做一致性快照
sqlite3 data/blog.db ".backup '$BACKUP_DIR/blog_${DATE}.db'"
两个改进:
1. WAL checkpoint — 把 WAL 日志里的数据全部刷入主库文件,确保 blog.db 包含最新数据。
2. sqlite3 .backup 命令 — 替代 cp。.backup 是 SQLite 官方提供的一致性备份方法,它会在内部处理好 WAL/主库的一致性问题,即使不手动 checkpoint 也能得到完整快照。
经验总结
1. SQLite WAL 模式的备份必须先 checkpoint
这是一个非常容易被忽略的坑。WAL 模式下,cp blog.db 得到的是一个不完整的快照。正确做法:
# 方法一:先 checkpoint 再 cp
db.pragma('wal_checkpoint(TRUNCATE)')
cp blog.db backup.db
# 方法二:用 sqlite3 .backup(推荐)
sqlite3 blog.db ".backup 'backup.db'"
# 方法三:三个文件一起拷
cp blog.db blog.db-wal blog.db-shm backup/
2. 邮件通知是意外的数据备份渠道
当初加邮件通知功能只是为了让博主及时看到评论,没想到在数据恢复时成了救命稻草。更关键的是,收件箱和已发送文件夹是两个互补的数据源:
- 收件箱(新评论通知)→ 记录访客评论
- 已发送(回复通知)→ 同时记录博主回复 + 被回复的原评论
两个文件夹加起来,几乎覆盖了所有评论场景。唯一恢复不了的,是博主回复一条没留邮箱的访客评论——这种情况下既没有收件箱通知(博主自己发的),也没有已发送通知(访客没邮箱没法发)。
3. 永远不要只依赖单一备份
这次有自动备份(虽然不完整)、有邮件通知(虽然不覆盖全部)、有 nginx 日志(虽然只有元数据)。每一份数据源都不完整,但组合起来恢复了全部数据。多渠道冗余是数据安全的核心原则。
最后
9 条评论,全部找回。
第一轮从收件箱捞回 4 条访客评论时,我以为故事到此结束了。剩下的 5 条——4 条博主回复、1 条邮件发送失败的访客评论——我以为永远消失了。还写了篇博文记录这个遗憾。
结果一条提醒让我想起来:已发送文件夹里还有回复通知邮件。打开一看,不仅 4 条博主回复完整保存,连那条「邮件发送失败」的访客评论,也因为它被回复了,其内容被保存在了回复邮件的原评论字段里。
备份要 checkpoint,通知要全覆盖,数据安全没有银弹,只有层层防御。
而有时候,你以为丢了的数据,只是换了个地方存着。
评论 (0)