lcrworld
文章

一条 git reset --hard,我的数据库空了!

53 阅读 3803 字 约 20 分钟 2026-07-09 09:46 · 更新于 2026-07-09 10:32

2026 年 7 月 9 日 · 一次惊心动魄的线上事故

​‌‌​‌‌​​​‌‌​‌‌‌‌​‌‌​​​‌‌​‌‌​​​​‌​‌‌​‌‌​​​‌‌​‌​​​​‌‌​‌‌‌‌​‌‌‌​​‌‌​‌‌‌​‌​​​‌‌‌‌‌​​​‌‌​​​​‌​​‌‌‌​‌​​​‌‌​‌​​​​‌‌​‌‌​​‌‌‌‌‌​​​‌‌‌​‌​​​​‌‌‌​‌​​‌‌​‌‌​‌​‌‌‌​‌​​​​‌‌​‌‌​​‌‌​‌​‌​​​‌‌​​‌​​‌‌​​‌​​​‌‌‌​‌‌​​‌‌​‌‌​​

事故发生

今天凌晨在检查博客的 RSS 订阅功能时,顺手测了一下 API,结果看到了令人血压飙升的一幕:

// GET /api/articles?page=1&limit=2
{"rows":[],"total":0,"page":1,"limit":2}

// GET /api/moments?limit=2
{"rows":[],"total":0,"page":"2","totalPages":0}

所有数据都没了。

文章、动态、评论——全部返回空数组。赶紧登上服务器查数据库:

$ node -e "import Database from 'better-sqlite3';
const db = new Database('data/blog.db', { readonly: true });
console.log('articles:', db.prepare('SELECT COUNT(*) as c FROM articles').get().c);
console.log('moments:', db.prepare('SELECT COUNT(*) as c FROM moments').get().c);"

articles: 0
moments: 0

数据库文件还在(7.7MB),表结构也都在,但所有业务表的数据——全部清零。只有 experiments(实验功能)和 visitor_logs(访客日志)还有数据。

那一刻的内心:45 篇文章,160+动态。写了两个月,全没了?

排查过程

第一步:检查 PM2 进程状态

$ pm2 list
┌────┬──────────────┬──────────┬──────┬──────────┬──────┐
│ id │ name         │ status   │ ↺    │ uptime   │ cpu  │
├────┼──────────────┼──────────┼──────┼──────────┼──────┤
│ 1  │ blog-server  │ online   │ 47   │ 4m       │ 0%   │
└────┴──────────────┴──────────┴──────┴──────────┴──────┘

进程是 online,但重启了 47 次。错误日志里全是:

❌ Error: no such column: m.lang
❌ Error: no such column: m.lang
❌ Error: no such column: m.lang
...(重复几十次)

第二步:检查数据库文件

$ ls -la data/
-rw-r--r-- 1 root root 7737344 Jul  9 08:49 blog.db
-rw-r--r-- 1 root root   32768 Jul  9 09:31 blog.db-shm
-rw-r--r-- 1 root root 1178352 Jul  9 09:31 blog.db-wal
-rw-r--r-- 1 root root 7528448 Jul  2 12:25 blog.db.bak.20260702

WAL 文件有 1.1MB,说明可能有未合并的数据。尝试 checkpoint:

$ node -e "db.pragma('wal_checkpoint(TRUNCATE)')"
articles: 0
moments: 0

WAL 合并后数据还是空的。数据确实丢了,不是 WAL 没合并的问题。

第三步:检查备份

幸好服务器上有自动备份脚本,每天凌晨 3 点跑一次:

$ ls /opt/blog/backups/blog_*.db
blog_20260626_150403.db
blog_20260627_030001.db
...
blog_20260709_030001.db  ← 今天凌晨的备份

验证备份数据:

$ node -e "const db = new Database('../backups/blog_20260709_030001.db', {readonly:true});
console.log('articles:', db.prepare('SELECT COUNT(*) as c FROM articles').get().c);
console.log('moments:', db.prepare('SELECT COUNT(*) as c FROM moments').get().c);"

articles: 45
moments: 167
comments: 143

备份是完整的!悬着的心放下一半。

数据恢复

立即停服、替换数据库、重启:

$ pm2 stop blog-server
$ cp data/blog.db data/blog.db.bak.20260709_empty  # 保留现场
$ cp ../backups/blog_20260709_030001.db data/blog.db
$ rm -f data/blog.db-wal data/blog.db-shm  # 删除旧 WAL
$ pm2 start blog-server

验证恢复结果:

// GET /api/articles?page=1&limit=1
{"rows":[{"id":45,"title":"博客 V2.0 正式发布:我给这个小破站做了一次大手术"...}]}

// GET /api/moments?limit=1
{"rows":[{"id":209,"content":"都来测一个😊..."}]}

✅ 数据恢复成功

定位根因

数据虽然恢复了,但如果不找到根因,下次部署还会丢。于是开始逆向排查。

关键线索:git 跟踪了不该跟踪的文件

$ git ls-files | grep 'data/.*\.db'
server/data/blog.db-shm
server/data/blog.db-wal

找到了! blog.db-walblog.db-shm 竟然被 git 跟踪了!

再看 .gitignore

# .gitignore
*.db
*.sqlite
*.sqlite3
Thumbs.db

只忽略了 *.db没有忽略 *.db-wal*.db-shm

事故还原

整个事故的链条非常清晰:

1. 某次提交时,blog.db-walblog.db-shmgit add 加入了版本控制

2. 之后每次部署都执行 git reset --hard master

3. git reset --hard 会把工作区所有被跟踪的文件恢复到仓库中的版本

4. 仓库里的 blog.db-wal 是某个旧版本,覆盖了服务器上当前的 WAL 文件

5. SQLite 的 WAL 模式下,数据变更先写 WAL,再 checkpoint 到主库

6. WAL 被旧文件覆盖后,未 checkpoint 的数据丢失,主库可能也被搞乱了

7. 服务重启时数据库重新初始化,发现 WAL/SHM 不匹配,数据状态错乱

简单说就是:每次 git reset --hard 部署,都用 git 仓库里的旧 WAL 文件覆盖了服务器上正在使用的 WAL 文件,导致数据丢失。

修复

1. 从 git 移除 WAL/SHM 文件

git rm --cached server/data/blog.db-shm server/data/blog.db-wal
echo "*.db-wal" >> .gitignore
echo "*.db-shm" >> .gitignore
git add .gitignore
git commit -m "fix: 从git移除WAL/SHM文件防止git reset覆盖导致数据丢失"

2. 验证修复效果

推送后在服务器执行 git reset --hard master,然后检查数据:

// GET /api/articles?page=1&limit=1
{"rows":[{"id":45,"title":"博客 V2.0 正式发布..."}]}

// 数据没有丢失!git reset 不再覆盖数据库文件了。

经验教训

1. .gitignore 要覆盖所有数据库相关文件

SQLite 的 WAL 模式会产生三个文件:

blog.db      ← 主数据库文件
blog.db-wal  ← Write-Ahead Log(预写日志)
blog.db-shm  ← Shared Memory(共享内存索引)

.gitignore 必须三个都忽略

*.db
*.db-wal
*.db-shm

2. 永远不要把数据库文件放进 git

Git 是版本控制工具,不是备份工具。数据库文件是二进制的,每次变更都会产生一个全新的 blob,仓库会迅速膨胀。更重要的是——git reset --hard 会覆盖工作区文件,这对正在运行的数据库是致命的。

3. 自动备份是救命稻草

这次能恢复数据,全靠每天凌晨 3 点的自动备份脚本。一个简单的 crontab + cp 就能救命:

# /etc/cron.d/blog-backup
0 3 * * * root cp /opt/blog/server/data/blog.db /opt/blog/backups/blog_$(date +\%Y\%m\%d)_$(date +\%H\%M\%S).db

4. 部署时检查 git status

如果部署前看一眼 git status,会发现 blog.db-wal 出现在跟踪文件列表里。养成部署前检查的习惯,能避免很多事故。

总结

这次事故的根因很简单:一个不完整的 .gitignore 规则。但影响是灾难性的——两个月的心血差点付之一炬

好在有备份,数据完整恢复了。修复也很简单:从 git 移除 WAL/SHM 文件,补全 .gitignore

备份数据,检查 gitignore,别让 git reset 变成删库跑路

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

评论 (0)

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