lcrworld
文章

我的动态页图片全都炸了!!

60 阅读 3714 字 约 19 分钟 2026-07-26 20:21 · 更新于 2026-07-28 19:30

被标题吸引过来的吧,嗯?👀

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

我已裂开

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 logtranscodeService.js 的历史。服务器上 7 月 25 日还在跑的旧版本(commit da2867e),和今天部署的新版本,在图片转码这段代码上有一个根本性的区别。

旧版本的逻辑:

转码生成 compressed 副本 → 原图保留在 uploads/media/ 原位。

新版本的逻辑:

转码生成 compressed 副本 → fs.unlinkSync(absPath) 删掉原图 → 把 media 表的 file_path 更新为 compressed 路径。

注释里写得明明白白:Delete original to save disk space。为了省磁盘空间,删掉原图,让压缩版顶上。

这个想法没毛病,我也知道。但问题在于,它只更新了 media 表的 file_pathmoments 表的 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 REPLACEmoments.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,谁也没想到这些缓存文件名能反推出图片路径的变迁史。有时候,日志和中间产物比代码本身更诚实。

图片回来了,动态页恢复正常。案子结了。嘿嘿

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

评论 (0)

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