lcrworld
文章

从邮件里捞回丢失的评论:SQLite WAL 备份踩坑全记录

124 阅读 5859 字 约 30 分钟 2026-07-09 10:11 · 更新于 2026-07-11 20:17

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:5042.200.*Windows Chrome✅ 有
17:27111.9.*Mac Chrome✅ 有
17:43104.28.*Linux Firefox✅ 有
18:17104.28.*Linux Firefox✅ 有
18:29183.197.*Pixel 7❌ 无
18:33183.197.*Pixel 7❌ 无
18:37183.197.*Pixel 7❌ 无
18:39104.28.*Linux Firefox❌ 无
18:50183.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 封回复通知邮件:

邮件 时间 收件人 回复内容 原评论
#5618:29chzxu******@gmail.com对对对!多亏你提醒我ᗜⰙᗜ...暗色模式的材质bug... (Mr.Lee)
#5718:3312658******@qq.com٩(•̤̀ᵕ•̤́๑)ᵒᵏᵎ 谢谢你的喜欢...界面、布局、风格都相当奈斯... (瓦匠)
#5818:37v**@veitzn.top谢谢,你也一样! 进度条已优化...进度条让页面有卡顿/掉帧... (Vei)
#5918:50chzxu******@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,通知要全覆盖,数据安全没有银弹,只有层层防御

而有时候,你以为丢了的数据,只是换了个地方存着。


相关文章:《一条 git reset --hard,我的数据库空了:一次生产事故的完整复盘》

本作品采用 CC BY-NC-SA 4.0 许可协议

评论 (0)

支持 Markdown · Ctrl+Enter 发送
加载评论中...