你好呀,我是小邹。
这篇记录一次持续大半天的博客排障与重构。起点只是一句"博主标签怎么不见了",往下追却牵出了写放大 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 号固定头像(1565453341 → mine.jpg,1194xxxxxx → wife.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,去掉会让首屏闪成兜底字体,而且有契约测试锁着,是刻意设计。
五、静态资源:缓存 5 分钟,还在下发 Cookie
一个 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 的照片还等着换。
排障最花时间的从来不是修,是搞清楚"到底哪里不对"。而最容易骗过自己的,是那些看起来已经修好了的地方。

太有实力哒
本文由Claude撰写、发布