🤖
AI审核中

从一个消失的标签,到 1478 倍的写放大

本站实践 11分钟 146浏览 2评论

你好呀,我是小邹。

这篇记录一次持续大半天的博客排障与重构。起点只是一句"博主标签怎么不见了",往下追却牵出了写放大 1478 倍的 binlog、一张 34 MB 的留言配图、跨五层嵌套的孤儿数据,以及同一个问题在代码里的四种答案。

比起罗列改了什么,我更想记下每次"以为修好了"之后又发现的东西——包括我自己判断失误的两次。

一、身份标签:从头像到前缀,再回到头像

症状

评论区的「博主」「博主夫人」标签全站消失。

根因

一次安全加固把判定从头像比对换成了 authorRole

- th:if="${comment.avatar}=='https://niu.hqxiaozou.top/img/zxf-hq/mine.jpg'"
+ th:if="${comment.authorRole == 'owner'}"

authorRole 由写入时打在 ip 字段上的 owner: 前缀推导。问题是这个前缀只有登录状态才会写,而数据库里 452 条博主留言、41 条夫人留言,前缀出现次数为 0。全部降级成访客。

「博主夫人」更彻底——那行 <a class="tk-tag tk-tag-teal"> 在同一次改动里被整段删掉了,CSS 还留着,没人用。

修法

判定顺序改成两级,并且写入时就把身份定死:提交评论时按 QQ 号固定头像(1565453341mine.jpg1194xxxxxxwife.jpg),身份从此只落在 avatar 一列上。

flowchart LR
    A[提交评论] --> B{QQ 号}
    B -->|1565453341| C[头像固定为 mine.jpg]
    B -->|1194xxxxxx| D[头像固定为 wife.jpg]
    B -->|其他| E[用访客自己的头像]
    C --> F[(写入数据库)]
    D --> F
    E --> F
    F --> G[渲染时一次字符串比较]
    G --> H[博主 / 博主夫人 / 访客]

读取端零额外成本——avatar 本来就要查。中途我曾改用邮箱判定,被自己否掉了:邮箱为了瘦读路径已经从三个查询里移除,改用它反而要重新加回来再在序列化前抹掉。

避坑点:这条规则有两份实现必须同步——Java 里的 CommentOrigin.role(ip, avatar),和 CommentsMapper.xml 里回复树的 author_role CASE 表达式。后者放在 SQL 里算,是为了避免把 varchar(512) 的 ip 回传到应用层。

二、binlog 写放大 1478 倍

发现过程

排查磁盘时注意到 /var/lib/mysql 占 2.5 G,其中 binlog 就 2.1 G,而全部业务数据加起来只有 53 MB。

一个日均几百 PV 的博客,凭什么写出 2.1 G binlog?

实测

拿一篇正文 157 KB 的文章,做一次浏览量 +1,测量 binlog 增量:

配置 一次浏览量 +1 写入 binlog
binlog_row_image=FULL(默认) 514,575 字节
binlog_row_image=MINIMAL 348 字节

原因

binlog_format=ROW + binlog_row_image=FULL 时,UPDATE 会把整行的前后镜像都写进 binlog。而 mayday_article 这张表里有 article_content(平均 14 KB、最大 157 KB)和 article_content_md

改一个 int 列,代价是把两份正文全文写进日志。

flowchart TD
    A["UPDATE mayday_article<br/>SET article_views = article_views + 1"] --> B{binlog_row_image}
    B -->|FULL| C["写入 before 镜像<br/>含 article_content 全文"]
    C --> D["写入 after 镜像<br/>含 article_content 全文"]
    D --> E["502 KB / 次"]
    B -->|MINIMAL| F["只写主键 + 变更列"]
    F --> G["348 字节 / 次"]

修法

SET PERSIST binlog_row_image = 'MINIMAL'。无从库、无 CDC 订阅,这个设置是安全的。

这不只是省磁盘——每有一个人打开一篇文章,服务器就少写 500 KB。对一台 2 核 1.7 G 的机器来说是实打实的 IO 减负。

一个反直觉的决定:我没有缩短 binlog 保留期。因为这台机器没有定时数据库备份(只在发版时顺带 dump),binlog 是目前唯一的连续恢复手段。在备份补上之前缩短它,是拿恢复能力换磁盘。

三、图片:留言板一页 42 MB

发现

文章封面早就接了七牛实时处理(imageMogr2/thumbnail/720x/format/webp),首页九张封面合计 0.62 MB,很健康。

正文和评论里手贴的图完全没人管

页面 优化前 优化后
留言板 /links 42.16 MB 33.06 MB
某长文 6.86 MB 0.91 MB

留言板上单张 5K2A7098.jpg 就是 34 MB 的相机原图,另一张手机原图 9.2 MB。

修法

在渲染时给图床图片补参数,挂在两个已有的读取入口上(评论走 normalizeStoredHtml,文章走 findByArticleUrl)。只改渲染输出,库里存的原文不动,随时可撤。

我在这里翻了车

第一版上线后,那张 34 MB 的图直接裂了

{"error":"File too large, please use pfop or workflow service"}

七牛对超过大小上限的原图拒绝处理并返回 400。参数结尾必须补 /ignore-error/1——压得动的照常压,压不动的退回原图,至少能显示。

留言板降幅小(42 → 33 MB)就是因为那张 34 MB 超限只能原样返回。要彻底解决得用七牛 pfop 异步生成缩略图,或直接换张小图。

四、加载遮罩挂在了 window.load 上

全屏加载动画的隐藏时机:

window.addEventListener('load', function () { hide(); });
fallbackTimer = window.setTimeout(hide, 8000);

window.load 要等页面上每一张图片加载完——文章页有 40 多张表情和头像,任何一张慢,已经渲染好的正文就一直被遮着。兜底还给到 8 秒。

改成 DOMContentLoaded 触发,load 只留作二次兜底,兜底降到 3 秒。字体等待那 4 秒上限没动——关键字体在 header 里已 preload,去掉会让首屏闪成兜底字体,而且有契约测试锁着,是刻意设计。

一个 CSS 文件的响应头:

cache-control: public, max-age=300, must-revalidate
set-cookie: blog_client_id=cid_...; Max-Age=31536000

两个问题。文章页要拉 24 个 CSS + 16 个 JS,每 5 分钟全部重新校验一轮;静态资源带 Set-Cookie 会让任何 CDN 和中间缓存直接放弃缓存它。

根因是拦截器注册时没带路径限制,等于挂在 /** 上:

- registry.addInterceptor(indexInterceptor);
+ registry.addInterceptor(indexInterceptor)
+         .addPathPatterns("/**")
+         .excludePathPatterns("/js/**", "/css/**", "/img/**", ...);

缓存由 max-age=300 提到 3600没有直接上 immutable:52 个 CSS/JS 引用里还有 30 个不带版本号,长缓存会导致改了样式用户看不到。

六、安全响应头一个都没有,因为 nginx 的一个坑

实测主站的 HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Referrer-Policy 全部缺失,只有 /zjh/ 路径有 HSTS。

nginx.conf 里明明写了 HSTS。

原因是 nginx 的 add_header 是就近全覆盖,而不是继承:任何 location 块只要自己写了一条 add_header,server 层的所有 add_header 就对该 location 全部失效。

flowchart TD
    A["server 块<br/>add_header HSTS"] --> B{"location 里有<br/>自己的 add_header 吗"}
    B -->|没有| C["继承 server 层<br/>HSTS 生效"]
    B -->|有| D["server 层全部失效<br/>只剩 location 自己那几条"]
    D --> E["博客主站正是这种<br/>安全头一个不剩"]

修法只能是给两个 443 server 块和 16 处已有 add_header 的 location 各补一份。CSP 暂时没加——站内大量内联脚本,硬上会白屏,要逐页梳理。

七、评论定位:四条路径三种行为

深链跳转、AI 审核后定位、回复后跳到 AI 回复、弹幕点击定位,四条路径原本是三套实现:

路径 原本行为
弹幕点击 平滑滚动(唯一正确的)
留言页深链 container.scrollTop = x 瞬间跳
文章页深链 behavior:'auto' 连跳两次
AI 审核 / 回复跳转 走平滑,但实现本身有 bug

而那个"平滑"实现自己也有问题:先 scrollIntoView 平滑滚动,再在 140/380/760ms 三个固定延时上用 behavior:"auto" 校正位置。平滑滚动通常要 300~800 ms,等于在动画中途把页面硬拽走

改成:算出目标最终该停的绝对位置(元素顶端减去 sticky 顶栏高度),一次平滑到位;校正等滚动真正停稳后再做。

我在这里又翻了一次车

判稳逻辑从第一帧就开始比对位置。而浏览器可能延迟一两帧才真正启动平滑滚动——那期间位置没变,连续三帧就被判成"已停稳"并触发校正,等于把要消除的 bug 换个地方原样复现

修正:必须先观察到实际位移,才允许用连续三帧稳定判停。

八、实测暴露的第三个问题:博主被 AI 回复了

这个博客留言只需要 QQ、昵称、内容,博主从不登录后台

于是:标签认头像,认得出博主;AI 认 owner: 前缀,认不出。实测用博主 QQ 发的留言 2592,照样收到了 AI 回复 2593。

顺手清掉了正在成形的重复——同一个"这条评论是谁发的",代码里有三套答案,我一开始还想加第四套:

判定 改前用途 改后
role(ip, avatar) 标签 唯一规则
SQL 的 author_role CASE 回复树标签 保留,标注为镜像
isOwner(ip) AI 跳过 删,改问唯一规则
固定邮箱比对 AI 认夫人 删,改问唯一规则

从四套降到一套 + 一个 SQL 镜像。

九、孤儿回复:跨五层嵌套的 29 条

后台删除评论时只按 id 删,不管子孙。删父不删子,留下的回复在前台既展不开也定位不到,只能靠 SQL 反查才发现。

我用循环删,才看清真实规模:

第 1 轮 16 条 → 第 2 轮 5 → 第 3 轮 3 → 第 4 轮 3 → 第 5 轮 2

共 29 条,跨 5 层。 一条 DELETE 只会删掉最外层 16 条,剩下 13 条继续当孤儿。

修法

服务层加兜底校验:删除前先算这批 id 会孤立哪些回复,非空直接抛异常。关键细节是——同一批里已经带上的子孙不算阻塞,那正是"先删子评论"本身,否则在列表里勾选父+子会被莫名其妙拦下。

交互上我没做成"你先去别处删完子评论再回来"——一个多层楼中楼要从叶子一条条往上删,太难用。改成点删除时直接把回复树列在确认框里,确认后按叶子在前、父在后一次提交,同一个事务执行。

flowchart TD
    A[点击删除] --> B["拉取该评论的回复树<br/>含被屏蔽的"]
    B --> C{有回复吗}
    C -->|没有| D[正常确认框]
    C -->|有| E["列出全部回复<br/>文案改为将一并删除"]
    E --> F["确认后按叶子在前<br/>父在后一次提交"]
    D --> G[服务层兜底校验]
    F --> G
    G --> H{会留下孤儿吗}
    H -->|会| I["拒绝并返回 409<br/>告知阻塞数量"]
    H -->|不会| J[同一事务内删除]

十、我判断错的两次

写下来提醒自己。

第一次:把首页图片说成 16 MB。 我用正则提取图片 URL 时写了 ...\.(jpg|png),在扩展名处就截断了,把 ?imageMogr2/... 参数丢掉,结果量的是原图,不是站点实际请求的图。首页封面其实早就优化过,合计 0.62 MB。真正没人管的是正文和评论里的图。

第二次:把 TTFB 1.9~3.4 秒算在服务器头上。 还建议去 profile 留言板。后来在服务器本机实测:

页面 应用内部 经 nginx + TLS
首页 6~173 ms 82~119 ms
留言板 16~45 ms 84~99 ms
文章页 21~51 ms 94~103 ms

站点一点都不慢,慢的是我那条链路——光 TLS 握手就吃掉 2.9 秒。

教训是同一条:测量工具本身也在被测量。在下结论之前,先确认你的尺子是准的——用另一个位置、另一种方式交叉验证一次,成本很低。

收尾

十四个提交,32 个文件,1307 行增改。全量测试从 1161 项涨到 1186 项,每次发版前跑一遍加七牛打包冒烟检查。

还没做的也记一笔:数据库仍然没有定时备份,只在发版时顺带 dump,而且全在同一块盘上;应用连数据库用的是 root,最小权限没做;那张 34 MB 的照片还等着换。

排障最花时间的从来不是修,是搞清楚"到底哪里不对"。而最容易骗过自己的,是那些看起来已经修好了的地方。

2 条评论
如果你觉得文章对你有帮助,那就请作者喝杯咖啡吧☕
微信
支付宝
  2 条评论
召田最帥boy   湖南省

太有实力哒kuse

召田最帅boy 博主   英国英格兰埃克塞特

本文由Claude撰写、发布image_emoticon88@2x