益诗误删文件复盘
九月十九日下午,我在服务器上整理文件,敲了一条删除命令,删错了一个目录。益诗上线四个月,所有用户上传的图片都放在那里。包括 571 个人的头像,124 张诗歌封面,50 个诗集封面,79 张 AI 海报。这篇是复盘。我把它完整写下来,因为里面有几步值得记住,也有两个我自己犯的错误值得你绕开。一、先清点:什么还在,什么没了先要做的事是把边界划清楚。数据库完好无损。用户 1770 个,诗歌 15332 首(未删除部分),评论 2368 条,通知 34896 条,还有点赞收藏、关注关系、诗集、漂流瓶、成长记录——全部安好。这些都在 MySQL 里,误删的目录跟它无关。后端程序也活着。yishi.jar 在另一个目录,进程已经连续跑了 48 天。systemd 单元文件在 /etc/systemd/system/,不在 wwwroot 下面,所以数据库密码、Redis 密码、JWT 密钥、通义千问的 API Key、MinIO 凭据、QQ 互联的 AppID 和 AppKey,一个都没丢。这一点后来省了我很多事。丢的是这些:位置内容/www/wwwroot/poem-client/用户端 H5 静态文件/www/wwwroot/poem-admin/管理端静态文件/www/wwwroot/yishi-server/logs/日志目录/www/wwwroot/yishi-server/minio/minioMinIO 可执行文件本身/www/wwwroot/yishi-server/minio/data/对象存储数据,全部图片Nginx 配置在宝塔的 vhost 目录里,不在 wwwroot 下,也活着。二、让站点先活过来前端最容易补,本地构建产物都在。H5 有 82 个资源文件,管理端 85 个,打包压缩后 scp 上去解包,改属主权限就好。后端 jar 完全不用重传。我比对了两边的 MD5:线上 f160342af2d74778653aab791676de8f 本地 f160342af2d74778653aab791676de8f一致,说明线上跑的就是我本地这份构建,省掉一次重新打包和上传。APK 也在本地,yishi-0.11.0.apk,20,340,809 字节,放进 /apk/ 目录。Nginx 没有单独的 /apk/ location,走的是 SPA 兜底的静态服务,能正常下载。真正麻烦的是 MinIO。 它的可执行文件被删了,而且 —— dl.min.io 这个官方下载域已经停用,所有路径都返回 410 Gone:https://dl.min.io/server/minio/release/linux-amd64/minio → 410 https://dl.min.io/server/minio/release/linux-amd64/archive/... → 410 https://dl.min.io/server/minio/release/linux-amd64/minio.asc → 410GitHub Releases 也是 404。最后在官方中文源拿到了:curl -sL --retry 3 -o minio https://dl.minio.org.cn/server/minio/release/linux-amd64/minio版本 RELEASE.2025-07-23T15-54-02Z,110 MB,仍然支持 --console-address,systemd 单元一个字不用改。装回去之后还有一层坑。数据目录当时只剩一个 .minio.sys,MinIO 启动时报的是:Unable to load healing tracker: unformatted drive found, re-initializing.. Error: Read failed. Insufficient number of drives online它认不出这个盘,于是 mc mb 建桶直接报 Resource requested is unwritable。重启一次,让它把空盘重新格式化,然后建桶、设公共读:mc mb --ignore-existing local/yishi mc anonymous set download local/yishi到这一步站点是活的,但 824 张图片真的没了。磁盘上、备份里、回收站里都没有。三、意外的出路那天下午我想通一件事:这些图片在我的服务器上没了,但它们在你手机里。/img/ 这个路径的响应头是这样的:expires: Sun, 19 Sep 2027 09:39:02 GMT cache-control: max-age=31536000 cache-control: public, immutable一年有效期,immutable 意味着浏览器可以直接复用,不必回服务器校验。MinIO 的文件名是 UUID,永不变更,当初这么配就是为了让图片秒开。这个配置让图片在别人手机上留了一份完整的字节副本,而且源站返回 404 也不会触发重新拉取。同源页面上的 JavaScript 可以把它读出来,再传回服务器。因为写回的是完全相同的 object key,数据库里那 824 条 URL 一个字都不用改。我写了一个临时页面干这件事,跑完就撤了。技术路线值得记下来。第一次尝试,失败了最初我用两种方式去读缓存:fetch(url, {cache: 'force-cache'}),以及一个游离的 new Image() 对象。结果 nginx 访问日志里是一整片 404 —— 每一次请求都打到了服务器。浏览器渲染图片时用的是另一条缓存通道。这一版只捞回 93 张,而且命中大多来自别的原因。换成真实渲染第二版换了思路:把图片真实地 <img> 渲染进 DOM,再从已经渲染出来的元素上取像素。// 图片既然能显示,就一定能从它身上取到像素 ctx.drawImage(img, 0, 0, cw, ch); const blob = await new Promise(res => canvas.toBlob(res, type, 0.94));因为要经过 canvas 重新编码,还得配一条降级链:toBlob 编码不出来就退回 toDataURL,标准尺寸失败就把图缩到 60%、35% 再试。这一版数字从 93 涨到 272。然后页面崩了头像组有 571 张。第二版一次性把整组缩略图都挂进 DOM,移动端浏览器的内存直接爆掉了。第三版改成固定槽位:只保留 N 个 <img> 元素循环复用(默认 3 个,可调 2/3/4),每张判完状态立刻 removeAttribute('src') 释放,再换下一张。// DOM 节点数和内存占用恒定,跟图片总数无关 function release(slot) { try { slot.img.removeAttribute('src'); } catch (e) { slot.img.src = ""; } }跑 50 张和跑 571 张,占用完全一样。命中的图用 88×88 的小画布裁切收进网格,上限 400 张,单张约 31KB。顺带还发现一件事:/img/ 的响应带 vary: Origin,而站内「生成海报」功能是用 crossOrigin="anonymous" 读图的 —— 那是另一条缓存条目。所以补了一条 crossorigin 备用路径。四、三分之一的墙最终停在 272 / 824,约 33%。为什么不是更多?我拿一个指标去验证:每张图片最后一次被服务器成功取到的时间。已经找回来的头像:98 张里 97 张的最后一次成功加载在九月仍然缺失的诗歌封面:44 张停在六月,25 张停在五月,只有 18 张落在九月规律很清楚了。immutable 和 expires 管的是新鲜度:浏览器愿意直接复用,不必回服务器校验。至于留存,移动浏览器和 iOS Safari 依然会按最近最少使用(LRU)清理长期不碰的条目。头像出现在每一个页面上,被反复读取,缓存条目一直是热的。诗歌封面只在特定卡片上出现,而 15333 首诗里只有 127 首带封面 —— 出现频率太低,条目早就凉了。我做了一个决定性的验证:把用户当天实际打开过的每一首诗的封面逐一对照。结果是 封面仍缺失的数量为 0。也就是说,他们能在手机上看到的那些诗歌图片,正是已经接回来的那 47 张。剩下那些,确实不在任何设备上留下过副本。这条路走到头了。五、然后我犯了第二个错误我没有立刻收手,转而去试磁盘取证 —— 图片被删掉才两个多小时,服务器还有 16G 空闲空间,我猜数据块可能还躺在原地。前半段是有收获的。我摸清了 MinIO 在磁盘上的真实布局:每个对象是一个以对象名为名的目录,里面有:xl.meta,约 350 字节,含 etag 和 content-typeDDir-<uuid>/part.1,实际数据,约 656KB 一片这意味着目录名本身就是文件名,理论上可以整目录找回,比缓存那条路完整得多。然后我跑了这条命令:dd if=/dev/vda3 bs=8M | LC_ALL=C grep -aob -F -f patterns.txtpatterns.txt 有 555 个模式 —— 552 个缺失文件名加上几个图片魔数。目标是遍历 40GB 磁盘找残留。这是那次事故里最糟的一个决定。它把生产盘的 I/O 占满,内存也被吃掉,最终拖垮了整台服务器,我只能在阿里云控制台强制重启。正确的做法是在离线快照上做,或者用 debugfs 定点读取 inode。任何长耗时的扫描都应该加 nice -n 19 和 ionice -c3,并且预先设好超时。判断数据能否恢复,优先用轻量证据:看一个 inode 的 extent 记录、做小范围采样,都足够下结论。代价是我为了多救几张图片,让整个站点又断了一次。六、重启之后服务器重启后所有服务都自动起来了:yishi、minio、nginx、mysql、redis、cron 全部 active。桶内 272 个对象完好,数据库行数与重启前一致。我做了收尾:删除临时恢复页面、接收程序、分组清单、nginx 反代配置、8099 端口还原 nginx 配置并删除部署时产生的备份文件移除 mc 的 local 别名清理 /tmp 和本地脚本目录复查确认站点根目录只剩 index.html / assets / static / apk站内公告也改写了一版,去掉了所有引导用户参与的步骤 —— 找回通道关了,不该再让任何人点进去。七、值得记住的几件事在生产目录里删东西之前,先确认它有没有备份。 这次的全部损失,源头就是这一条。我有数据库的每日备份,但对象存储目录不在任何备份计划里。immutable 管的是新鲜度。 这个响应头让浏览器愿意直接复用、不回源校验。条目能留多久,由最近最少使用策略决定 —— 低频访问的资源,缓存待不了太久。顺手配的缓存策略,在灾难时会变成救命的东西。 那 272 张图片能回来,靠的就是当初为了让页面快一点而加的 immutable。当时做这个决定,只想到性能这一层。读浏览器缓存要用渲染。 fetch(cache: 'force-cache') 和游离的 new Image() 都读不到渲染通道那份缓存,实测每一次请求都会打到服务器。长耗时的扫描要放在离线快照上做。 第二个错误比第一个更不可原谅,因为它是我在已经出事之后,又主动制造的一次故障。八、接下来已经加上了对象存储目录的异地备份,跟数据库放在同一套计划任务里。找不回的 552 张图片,位置会留空。头像随时可以重新上传,新图会被正常保存。那 272 张能回来,是一台台设备上那些看不见的副本替我保住的。写这篇文章的时候我在想,如果当初没有顺手开那个缓存头,现在的数字会是 0。写于 2026-09-19,服务器重启之后。