我的动态页图片全都炸了!!
被标题吸引过来的吧,嗯?👀
我已裂开
2026 年 7 月 26 日,周日,晚上。
我打开自己的博客想看看最近发的动态,点进动态页,屏幕上整齐地躺着一排裂图图标。一个、两个、三个……往下翻,全是裂的。
第一反应是 CDN 挂了。刷新,清缓存,换网络,图标依然坚挺地裂着。打开开发者工具看 Network,每张图片的请求都返回同一个干净利落的 404:
{"message":"图片不存在"}
我愣了一下。这个错误信息我熟,是后端 imageProxy 接口写的。它说图片不存在,那就是图片真的不存在。
但问题是,这些动态我发了几个月了,一直好好的,怎么今天集体消失了?
现场勘查
SSH 登上服务器,直奔文件系统。
uploads/media 目录下,原本应该躺着一堆 .jpg .jpeg .png 原图。我 ls 了一下,顶层只剩一个孤零零的文件。再 ls compressed/,满满当当 1469 个文件安安静静地待在那儿。
原图没了,压缩版还在。
接着翻数据库。moments 表的 media_urls 字段存的是动态的图片 URL,我查了一条:
[{"type":"photo","url":"/uploads/media/1777979411130-4sxla5.jpeg"}]
这个 URL 指向 uploads/media/1777979411130-4sxla5.jpeg。我回头去文件系统找这个文件——不存在。
案子到这里,轮廓已经清楚了:动态表里存的 URL 指向原图,原图被删了,所以 404。
可压缩版还好端端地在 compressed/ 目录里躺着呢。media 表里这些图片的 file_path 也早就更新成了压缩版路径。问题是,moments 表没人通知。
谁给我家偷了,也不告我一声!?
排查嫌疑人
原图是谁删的?
服务器上有好几个清理脚本在跑。cleanup_media.cjs 每小时整点执行,cleanup_small_files.cjs 删小于 1KB 的文件,cleanup_malicious_uploads.cjs 删文件名以 upload 开头的恶意上传。
我一个个翻它们的日志。cleanup_media.cjs 的日志整整齐齐写着:
Cleanup complete: 0 orphaned files (0.00MB freed), 0 old chunk dirs removed
一行一行,全是 0 ♀。另外两个脚本也排除了——我的动态图片一条都不沾它们的删除条件。
嫌疑洗不清,但也定不了罪🤔。文件确实没了,可没有一个脚本承认是自己干的。
缓存里的线索
线索卡住的时候,我盯着 thumbnails/moments 缓存目录看了很久。这个目录存着 imageProxy 生成的缩略图缓存,文件名是原 URL 路径转义来的。
6 月 21 日到 23 日生成的缓存,文件名长这样:
_uploads_media_1777979411130_4sxla5_jpeg_w400.jpg
这说明那时候原图路径是有效的,imageProxy 能读到原图、生成缓存。
而我今天修复后新生成的缓存:
_uploads_media_compressed_39_1777979411131_jpg_w400.jpg
路径前缀变了,从 uploads_media_xxx 变成了 uploads_media_compressed_xxx。
原图是在某个时间点之后才消失的。uploads/media 目录的 Modify 时间戳是今天 11:43。也就是说,今天上午十一点多,原图集体蒸发。
关键证据
既然文件层面找不到凶手,我决定回到代码本身。
git log 翻 transcodeService.js 的历史。服务器上 7 月 25 日还在跑的旧版本(commit da2867e),和今天部署的新版本,在图片转码这段代码上有一个根本性的区别。
旧版本的逻辑:
转码生成 compressed 副本 → 原图保留在 uploads/media/ 原位。
新版本的逻辑:
转码生成 compressed 副本 → fs.unlinkSync(absPath) 删掉原图 → 把 media 表的 file_path 更新为 compressed 路径。
注释里写得明明白白:Delete original to save disk space。为了省磁盘空间,删掉原图,让压缩版顶上。
这个想法没毛病,我也知道。但问题在于,它只更新了 media 表的 file_path,moments 表的 media_urls 没人管。
断链的瞬间,就在这里。
真相
把整条数据流捋一遍。
前端上传图片时,后端把原图路径返回给前端,前端把它存进 moments.media_urls,动态就这么发了出去。上传完成后,后台 setImmediate 异步触发转码。转码干三件事:生成压缩版、删原图、改 media.file_path。
可 moments.media_urls 还静静地躺在数据库里,指着那个已经不存在的旧路径。
而 imageProxy 的 thumb 接口逻辑是:先 fs.existsSync(srcPath) 检查原图在不在,不在直接返回 404,连后面已经生成好的缓存都不会去看。
if (!fs.existsSync(srcPath)) {
return res.status(404).json({ message: '图片不存在' });
}
// 缓存命中检查在后面,原图没了根本执行不到
if (fs.existsSync(cachePath)) { return res.sendFile(cachePath); }
所以旧版本之所以一直没事,是因为它压根不删原图,moments.media_urls 指向的路径永远有效。新版本一上线,删原图的逻辑激活,断链效应立刻显现。
至于为什么是"所有"图片同时 404:新上传的图片转码后原图消失,这是新逻辑直接造成的;而那些几个月前一直靠原图苟着的旧图片,也在今天上午的一次清理中原图被删,于是新旧图片在同一时间集体爆发。
修复
两步走战略。
第一步,救历史数据。
我写了个迁移脚本,扫描 compressed 目录里所有文件,提取文件名里的时间戳,和 moments.media_urls 里 URL 的时间戳做比对。这里有个关键规律:上传时 multer 用 Date.now() 命名原图,转码时 baseName = ${mediaId}-${Date.now()} 命名压缩版,两次 Date.now() 之间只差几毫秒(转码是上传后立即触发的)。
所以只要时间戳差值在 ±2 秒内(图片)、±10 秒内(视频,转码耗时长),就可以认定是同一个文件。
跑下来,410 个 URL 里 405 个匹配成功,更新了 146 条动态。剩下 5 个救不回来——3 个是前端 bug 产生的 blob: 伪 URL,2 个在 media 表里根本没有对应记录。这些是早就埋下的脏数据,跟这次事故无关。
第二步,堵根因。
在 transcodeService.js 删原图之后,加了一个 syncMomentMediaUrls() 函数,用 SQL REPLACE 把 moments.media_urls 里引用旧路径的地方全部替换成新路径。
function syncMomentMediaUrls(oldRelPath, newRelPath) {
if (!oldRelPath || !newRelPath || oldRelPath === newRelPath) return;
const oldUrl = '/' + oldRelPath;
const newUrl = '/' + newRelPath;
const result = db().prepare(
`UPDATE moments SET media_urls = REPLACE(media_urls, ?, ?) WHERE media_urls LIKE ?`
).run(oldUrl, newUrl, '%' + oldUrl + '%');
}
这样以后转码删原图,动态表的 URL 会自动跟着更新。数据库先备份了一份 blog.db.bak,部署、重启、curl 验证,HTTP 200。图片回来了。
复盘
这次事故最值得记一笔的,是"数据流一致性"这件事。
系统里同一个资源——一张图片——的路径信息,散落在两张表里:media 表存一份,moments 表存一份。这是典型的数据冗余。冗余本身不是问题,问题是两张表没有联动机制,一张更新了另一张不知道。
旧版本靠"不删原图"回避了这个问题——只要原图一直在,指向它的 URL 就永远有效。这是一种隐式依赖。代码注释里没写,文档里没记,全靠"原图恰好没被删"这个事实撑着。一旦某个版本决定清理原图,炸弹就引爆了。
写代码的时候,"暂时不会出问题"和"永远不会出问题"是两回事。前者只是引信还没点着。
另外 imageProxy 的接口设计也有改进空间。原图不存在就 404,这逻辑本身没毛病,但完全可以加一层降级——原图没了,去 compressed 目录找找压缩版,找到了照样能出图。这次我偷懒没加这层,直接修了数据。下次有空补上。
最后说一句,thumbnails/moments 那个缓存目录的文件名,成了破案的关键证人。它老老实实记录了几个月来每个请求的原 URL,谁也没想到这些缓存文件名能反推出图片路径的变迁史。有时候,日志和中间产物比代码本身更诚实。
图片回来了,动态页恢复正常。案子结了。嘿嘿
评论 (0)