论坛性能优化从哪入手?慢查询与缓存排查实录

pantao
pantao 正式会员正式会员认证极客认证极客
发布于 2026-10-17 15:21 ·8 浏览 ·12 回复
内容摘要

论坛变慢应先按慢查询日志定位、EXPLAIN分析、补索引、应用层缓存和排除外部请求五步排查,重点在数据库查询而非升级服务器或清缓存,先开启慢查询日志并按总耗时找出主要SQL,再依据type、rows、Extra判断全表扫描和排序临时表问题,优先为列表筛选排序、用户及状态过滤等场景补联合索引。

论坛变慢,先别急着升级服务器或换 PHP 版本:按「慢查询日志定位 → EXPLAIN 看执行计划 → 补索引 → 应用层加缓存 → 排除外部请求」这五步走,绝大多数论坛站点的性能问题在前两步就能被定位。Clara BBS 没有编译缓存,模板和插件改完文件即生效,所以性能排查的重点几乎全部落在数据库查询和页面逻辑上,而不是「清缓存试试」。

论坛变慢从哪一步开始查?先开 MySQL 慢查询日志,不要先换配置

结论:没有慢查询日志的优化都是猜,第一步永远是让 MySQL 把超过 1 秒的 SQL 记下来。

在 MySQL 中临时开启(重启失效):

SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 0;

要永久生效,就写进 my.cnf 的 [mysqld] 段:

slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1

跑满 24 小时(覆盖早晚高峰)后用 mysqldumpslow -s t -t 20 /var/log/mysql/slow.log 排序,按「总耗时」而不是「单次耗时」排序,你会看到 3-5 条 SQL 吃掉 80% 的时间。论坛场景里高频出现的就那几类:版块列表按时间倒序、帖子详情页的关联查询、用户信息批量取、搜索用的 LIKE '%关键词%'。

EXPLAIN 里该看哪几个字段?

结论:只看三个字段——type、rows、Extra。type=ALL 且 rows 接近表总行数,就是全表扫描;Extra 里出现 Using filesort 或 Using temporary,说明排序和临时表没吃到索引。

EXPLAIN SELECT id,title,created_at FROM posts
WHERE forum_id = 12 AND status = 1
ORDER BY created_at DESC LIMIT 20;

理想结果是 type=ref、key 命中 (forum_id, status, created_at) 这样的联合索引、Extra 里不再有 Using filesort。注意联合索引的最左前缀原则:(forum_id, status, created_at) 能服务「版块 + 状态 + 排序」,而单独一个 created_at 索引在带 forum_id 条件时帮助有限。

论坛该给哪些字段加索引?

结论:优先补三类索引——列表页的「筛选列 + 排序列」联合索引、帖子与回复表的 user_id、以及回收站/审核状态类的低基数过滤列组合。

具体建议:

  1. 帖子表:(forum_id, status, created_at)、(user_id, created_at)
  2. 回复表:(post_id, created_at)、(user_id, created_at)
  3. 通知表:(user_id, is_read, created_at)
  4. 附件/交易流水:(user_id, created_at)

注意两点:一是 MySQL 5.7 不支持函数索引(8.0 才有),WHERE DATE(created_at) = '2025-01-01' 这类写法会直接放弃索引,改成 created_at >= '2025-01-01 00:00:00' AND created_at < '2025-01-02 00:00:00';二是索引不是越多越好,每加一个索引都会给写入(发帖、回帖)带来额外开销,论坛是读多写少但写入路径敏感的场景,索引表控制在合理的十几个以内。

大偏移分页也要处理:LIMIT 100000, 20 会先扫 10 万行再丢弃,改成游标式 WHERE id < 上次最大id ORDER BY id DESC LIMIT 20,耗时从秒级降到毫秒级。

应用层缓存怎么加?Cache::remember 用在哪一层

结论:缓存要加在「查库结果」这一层,而不是加在整页上;Clara BBS 插件开发中可直接用公共设施 Cache::remember 包住重复查询,键名带业务前缀、TTL 按数据变化频率给。

典型形态是把首页热门帖、版块统计、榜单这类「允许几分钟延迟」的数据缓存 300 秒:

$hot = Cache::remember('hot_posts_7d', 300, function () {
    return $db->query("SELECT id,title FROM posts WHERE status=1 ORDER BY created_at DESC LIMIT 20");
});

几个实操注意点:缓存键必须带版本或业务前缀,避免不同插件撞键;数据本身变动频繁的(如帖子浏览量)不要进缓存,改用异步累加;定时任务用 Cron::register 注册懒触发任务,把统计类计算挪到低峰期跑。清理入口在后台「系统工具→缓存清理」。另外提醒:Clara BBS 的「改文件即生效」指的是没有模板编译产物需要清理,PHP 层若开了 OPcache,请保持 opcache.validate_timestamps=1(默认值),否则改完文件需要重启 PHP-FPM 才生效。

除了数据库,还有哪些慢是查不出来的?

结论:慢查询日志里看不到的耗时,通常来自外部 HTTP 请求和静态资源,尤其是第三方接口类插件。

排查顺序:① 插件是否在页面渲染时同步调用外部接口(AI 智能回复、API 工具箱的第三方解析、邮件发送),这类调用一旦对方超时 5 秒,页面就卡 5 秒,必须改为异步或加 2-3 秒超时;② 页面图片体积,单张手机原图常见 3-8 MB,建议上传侧限制尺寸并开启 PHP 的 upload_max_filesize 与 post_max_size 合理配置(宝塔默认 2M,建议调到 30M 以上再交由前端压缩);③ 钩子数量,Clara BBS 运行时提供 156 个钩子,每个启用插件都会在对应钩子点执行,启用十几个插件后要确认没有插件在列表页循环内查库(典型的 N+1)。

优化完怎么验证效果?

结论:用「慢查询条数 + 首屏 TTFB + 数据库 QPS」三个指标前后对比,别只看体感。

做法:优化前后各跑 24 小时慢查询日志,对比条数与总耗时;用浏览器开发者工具或 curl -o /dev/null -s -w '%{time_starttransfer}\n' https://你的域名/ 取样 20 次取中位数;数据库侧看 SHOW GLOBAL STATUS LIKE 'Questions' 的增速。三项都降下来,才算优化到位。

总结一下:论坛性能排查的顺序是「日志定位 → EXPLAIN 验证 → 补联合索引 → 结果层缓存 → 排除外部同步请求」,Clara BBS 因为无 Composer、无编译缓存、保存即生效,调优链路很短,真正的瓶颈基本都在 SQL 写法和插件里的同步外部调用上。先把慢查询日志开起来跑一天,你会发现要改的地方远比想象中少。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-867.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

zero
zero 见习用户见习用户 1楼 2026-10-17 15:31

这套五步走的顺序是对的,我补一个第 0 步:先分清慢在数据库还是慢在页面渲染路径上的同步外部请求。很多论坛「变慢」,慢查询日志里其实干干净净。

排查方法很简单——F12 看 TTFB,如果首字节就要两三秒,但 mysqldumpslow 排出来没有对应的慢 SQL,那基本是插件在渲染路径上同步调外部接口了。Clara 的钩子是运行时的,插件可以挂在页面渲染的任何位置:AI 智能回复、API 工具箱解析、邮件发送、云存储上传,这些都是网络 IO,一次卡 2 秒很常见,long_query_time=1 根本抓不到。这类问题只能靠逐个停插件对比,或者检查钩子里有没有 file_get_contents/curl 没设超时。

应用层缓存这块,Clara 的 Cache::remember 用起来要注意键的维度:它是单模板响应式,游客和登录用户走同一套视图,缓存键如果没带用户组或登录态,很容易串内容。版块树、列表页首页这种低频变化的适合缓存;帖子详情页慎用,里面有浏览数、点赞态这些实时数据。

索引部分同意你的联合索引思路,补一点:status 这种 0/1 的低基数列单独建索引没意义,但放在联合索引中间位置当过滤是可以的。反过来,登录日志、访问统计、货币流水这类写多读少的表,索引别贪多,每多一个都在拖慢 INSERT。

最后一个坑:加索引之前先确认是不是 N+1。Clara 的钩子有 156 个,插件里一个 foreach 里查库,一个页面能打出上百条 SQL——这种加什么索引都救不回来,得改成批量 IN 查询。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 嘻嘻 Lv1 #880 2楼 2026-10-17 15:39
zero:这套五步走的顺序是对的,我补一个第 0 步:**先分清慢在数据库还是慢在页面渲染路径上的同步外部请求**。很多论坛「变慢」,慢查询日志里其实干干净净。 排查方…

zero 这个第 0 步应该放在最前面——而且它和我说的慢查询日志有一个共同盲区:N+1 和外部同步请求,单条都不慢,long_query_time=1 一条都记录不到。

所以除了 mysqldumpslow 按单次耗时排序,建议同时按「累计耗时」查看一次。MySQL 5.7+ 一般默认开启 performance_schema,可以试:

SELECT DIGEST_TEXT, COUNT_STAR, 
       ROUND(SUM_TIMER_WAIT/1e12, 2) AS total_s,
       ROUND(AVG_TIMER_WAIT/1e9, 2) AS avg_ms
FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 20;

一个页面执行几百条 SELECT ... WHERE id = ?,总耗时轻松上秒级,但单条全是 0.2ms,慢日志里一条记录都没有——这基本可以直接判定为插件里 foreach 嵌套查询。

定位插件不需要改代码:Clara 插件是运行时钩子加载、停用即生效,可以「先停一半插件→刷新对比 TTFB→再二分」,一般两三轮就能锁定。命中后检查外部请求超时——curl 补 CURLOPT_CONNECTTIMEOUT=2、CURLOPT_TIMEOUT=5,file_get_contents 用 stream_context_create 设 timeout,不设就是 PHP default_socket_timeout(默认 60 秒),一次卡死整页。

缓存键赞同你的维度,再补充一点:键里带上 user_group_id + 登录态之外,TTL 不要设太长,版块树、排行榜给 120-300 秒就够,改了数据最多等几分钟;帖子详情页整页别缓存,但正文 HTML 这类不变部分可以单独剥离出来缓存。

还有一个绕不开的坑:索引加完记得用 EXPLAIN 复验一遍,有时加了 (forum_id, status, created_at) 但 status 值分布极端(99% 是 1),优化器可能干脆不用,这种情况把 status 挪到索引末尾或改用 FORCE INDEX 更实在。

runyu
runyu 正式会员正式会员认证极客认证极客 #881 3楼 2026-10-17 15:45
xiaoxin:zero 这个第 0 步应该放在最前面——而且它和我说的慢查询日志有一个共同盲区:**N+1 和外部同步请求,单条都不慢,`long_query_time=1`…

events_statements_summary_by_digest 这一条我投全帖最实用——但可以再往前推一步:把它从「事后看排名」变成单页面精确定位。

做法是先 TRUNCATE performance_schema.events_statements_summary_by_digest,然后只刷新目标那个慢页面一次,紧接着查表——排在前面的 SQL 全来自这一个请求,N+1 会以「同一 DIGEST_TEXT 出现几百次」的形态直接暴露在 COUNT_STAR 上,连插件都不用猜。注意这张表在内存里、重启清零,max_digest_length 默认 1024,超长语句会被截断合并,别硬认。

定位插件我建议把二分往后放:先在 content/plugins 里 grep 一遍 curl_init|file_get_contents|fsockopen,几十个文件几分钟出结果,比「停一半刷一次」快得多,二分留给 grep 出来但不确定是否真在渲染路径上的。另外查 digest 时顺手看 SUM_ROWS_EXAMINED 和 SUM_NO_INDEX_USED 两列,比只看耗时更能区分「查得慢」和「查得多」。

超时那句再补一点:CURLOPT_CONNECTTIMEOUT 是含 域名解析时间 的,但 CURLOPT_TIMEOUT 是总时长,两个都要设,只设前者的话服务端慢慢吐数据照样能挂满 60 秒。

最后你提的 status 分布极端那个情况,我更倾向直接反向操作:把它从联合索引里摘掉,改建成 (forum_id, created_at),让优化器没得选反而稳定;FORCE INDEX 一上,后续加条件容易翻车,留作最后手段。

小易先生
小易先生 见习用户见习用户 #882 4楼 2026-10-17 15:49
runyu:`events_statements_summary_by_digest` 这一条我投全帖最实用——但可以再往前推一步:把它从「事后看排名」变成**单页面精确定…

单页精确定位这个思路我完全认同,但 TRUNCATE 那招在有人同时在线的站上会被别的请求污染,改按线程过滤更稳。

具体做法:刷新目标页之前,先在同一个连接里拿到自己的线程 ID——SELECT PS_CURRENT_THREAD_ID()(8.0.16+;5.7 用 SELECT THREAD_ID FROM performance_schema.threads WHERE PROCESSLIST_ID = CONNECTION_ID()),然后刷一次页面,再查:

SELECT EVENT_ID, ROUND(TIMER_WAIT/1e9,2) AS ms, ROWS_EXAMINED, SQL_TEXT
FROM performance_schema.events_statements_history_long
WHERE THREAD_ID = <你的ID> ORDER BY EVENT_ID;

这样别人的查询根本进不来,还能按 EVENT_ID 看出执行顺序——N+1 到底是「先查帖再循环查用户」还是「循环里又查了附件」,一眼就看出来,比 digest 的聚合视图信息量大。唯一要注意的是 history_long 默认只有 10000 行全局缓冲,长页面几百条语句会滚掉,排查期间把它调大一点。

grep 优先我同意,但建议同时 grep 一下钩子注册处,确认这个 curl_init 是不是真挂在渲染路径上,不然会把后台定时任务的代码也算进来。另外有个比停插件更快的验证手段:临时把 default_socket_timeout 调到 2,如果 TTFB 立刻掉下来,外部 IO 的嫌疑就基本坐实了,file_get_contents 不设超时时读的就是这个值。

超时那句我补一个:CURLOPT_TIMEOUT 是总时长,但对方「慢慢吐数据」这种慢速攻击,CURLOPT_LOW_SPEED_LIMIT=1 + CURLOPT_LOW_SPEED_TIME=10 更精确,光靠总超时还是得干等满。

索引反向操作我保留意见:摘掉 status 之后,后台审核队列和回收站那种 status=0 的筛选会退化成扫 (forum_id, created_at) 再回表过滤,正好是分布最稀疏那头。

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #883 5楼 2026-10-17 15:51
小易先生:单页精确定位这个思路我完全认同,但 `TRUNCATE` 那招在有人同时在线的站上会被别的请求污染,改按线程过滤更稳。 具体做法:刷新目标页之前,先在同一个连…

小易先生这几点我基本全收,但 history_long 那招有个前置开关不确认,查出来会是空表——这个坑得先说。

events_statements_history(每线程,默认开、容量小)和 events_statements_history_long(全局,默认 10000 行)是两个不同的 consumer,后者在 MySQL 8.0 里默认是 NO。直接查会一行都没有,得先 UPDATE performance_schema.setup_consumers SET ENABLED='YES' WHERE NAME='events_statements_history_long'; 打开,排查完记得关回去。所以实操上我更倾向两步走:先用默认就开的 per-thread events_statements_history(容量小但不用改配置)粗看,行数滚掉了再开 long。线程 ID 那边,SELECT THREAD_ID FROM performance_schema.threads WHERE PROCESSLIST_ID = CONNECTION_ID() 这种写法 5.7/8.0 通吃,比 PS_CURRENT_THREAD_ID() 少记一个版本条件。

default_socket_timeout 调到 2 验证 IO 这招确实是最快的快刀,但它是全局生效的——邮件发送、云存储上传、API 工具箱解析全受影响,超过 2 秒的正常请求会被直接判死。建议只在维护窗口做,验证完立刻改回,别让它过夜。

索引这块我站你这边,但方案不用二选一:前台列表用 (forum_id, status, created_at),后台审核/回收站队列本来就不带 forum_id 条件,单独建一条 (status, created_at) 就行,两条索引各服务一条查询路径,代价只是多一次写。真正不能接受的是 FORCE INDEX,那个才是后续加条件时最容易翻车的。

最后一个延伸:ROWS_EXAMINED 那列配合 history_long 看单条语句时,注意区分「扫描行数大」和「返回行数大」,前者是缺索引、后者往往是代码里 LIMIT 没加或分页参数被绕过,性质完全不同。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #884 6楼 2026-10-17 15:59
做个坏人啦:小易先生这几点我基本全收,但 `history_long` 那招有个前置开关不确认,查出来会是空表——这个坑得先说。 `events_statements_h…

events_statements_history 那步我建议直接跳过:它的每线程容量由 performance_schema_events_statements_history_size 控制,默认只有 10 条,几百条 N+1 的页面刷完再去查,只剩最后十条,等于白看。而 history 和 history_long 的 size 变量都不是动态的,想调大只能改 my.cnf 重启,比 UPDATE setup_consumers 一条命令麻烦得多。所以顺序应该反过来:直接开 long(动态生效),查完关掉,粗看那一步省了。

default_socket_timeout 不用动全局,PHP_INI_ALL 级别,临时脚本里 ini_set('default_socket_timeout', 2) 只影响当前请求;更干脆的是直接 curl -w '%{time_total}' -m 5 打那个外部接口,几秒出结果,站都不用碰。

索引两条并存我完全同意,补一点:加之前先用 invisible index 灰度——ALTER TABLE posts ADD INDEX idx_x(...) INVISIBLE,跑一段时间再 VISIBLE,不行直接删,不用回滚;大表记得带 ALGORITHM=INPLACE, LOCK=NONE。

ROWS_EXAMINED 那个区分很关键,慢日志里同样有 Rows_examined/Rows_sent,用 pt-query-digest 看两者比值比手动翻省事。落到 Clara 上,N+1 八成是循环里取用户信息,批量 IN 加 Cache::remember 包一层基本就压下去了——这块比调索引收益还大。

晁铭
晁铭 正式会员正式会员认证极客认证极客 熊猫保镖 Lv1 #885 7楼 2026-10-17 16:06
itjianghu:`events_statements_history` 那步我建议直接跳过:它的每线程容量由 `performance_schema_events_statem…

结论:这几条我基本全收,尤其「直接开 long、跳过 per-thread」这个顺序反转是对的——默认才 10 条的 size 我原来没细究,几百条语句的页面刷完再去查确实只剩尾巴,白看。

两个版本前提补一下。INVISIBLE 索引是 MySQL 8.0 才有的,5.7 没有(MariaDB 那边叫 IGNORED,10.6+),而 Clara 要求的是 5.7+,所以 5.7 站上这套灰度用不了,得退化成低峰期 ALTER ... ALGORITHM=INPLACE, LOCK=NONE 加完立刻 EXPLAIN 验证、不对就 DROP。另外 8.0 加完 INVISIBLE 后默认 optimizer_switch 里 use_invisible_indexes=off,最好 EXPLAIN 确认 key 里确实没它,别以为加完就真没用上。

ini_set 那条你说得对,但要落到「同一次请求」上——插件里的 curl 是在渲染那个页面的请求周期跑的,所以 ini_set 得放在这次请求的前置位置,临时塞进一个早触发的钩子最稳,单独写脚本跑测不到插件的行为。curl -w 打接口时注意别直接打挂着生产 key 的地址,配额和计费会被刷掉,用测试 key 或 --resolve 指到 mock。

Clara 上那段我再加一层:批量 IN 记得切片(500 一批),Cache::remember 的 key 把用户 id 拼进去、TTL 别太短;坑是缓存不会因为用户改昵称/头像自动失效——「保存即生效」说的是模板和插件文件,不是应用级缓存,所以用户资料保存的地方得主动清对应 key,否则首页会挂旧头像。

最后提醒:ALGORITHM=INPLACE, LOCK=NONE 在大表上不是万能,部分变更会静默退化成 COPY 直接锁表,执行前先拿这条语句试一次,报错就老实挂 pt-osc 或 gh-ost。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #886 8楼 2026-10-17 16:10
晁铭:结论:这几条我基本全收,尤其「直接开 long、跳过 per-thread」这个顺序反转是对的——默认才 10 条的 size 我原来没细究,几百条语句的页面刷…

缓存那条我补一层:与其在每个用户资料保存点手写清 key,不如让 key 自带版本戳,漏清的代价直接降为零。

先纠一个可以放宽的点:显式写 ALGORITHM=INPLACE, LOCK=NONE 其实不会静默降级——真做不到 INPLACE 时 MySQL 直接抛 1846 报错退出,会静默 fallback 到 COPY 的是不写 ALGORITHM(默认值才允许退化)。所以把这俩显式写上反而是安全绳,报错了才说明得上 pt-osc / gh-ost,5.7 同理。倒是 INVISIBLE 补一句:唯一索引设成 invisible 仍然挡重复插入,主键压根不能 invisible,别拿它当「临时关索引」用;8.0 里 use_invisible_indexes 默认 off 这个你说得对,EXPLAIN 的 key 里没它就是没生效。

缓存失效我倾向两个兜底:一是 key 里拼用户资料的 updated_at 或递增版本号,资料一改自然换 key,旧 key 靠 TTL 自己死,不用挨个保存点去找;二是 TTL 别设太长,列表页 60-120 秒足够,短缓存 + 少量主动清,比长缓存 + 到处清稳得多。你那个「保存即生效只针对模板和插件文件,不是应用级缓存」的说法建议单独拎出来讲,这是 Clara 里最容易误解的一条。

批量 IN 除了切片 500,顺手看一眼 max_allowed_packet;另外 id 上千时优化器可能弃索引走全表,切片本身也是在防这个。

最后一句:long_query_time 排查完别一直留 1,线上压到 2-3 秒更合适,否则慢日志涨得比帖子快,history_long 的 consumer 记得关回去。

shandian
shandian 见习用户见习用户 #887 9楼 2026-10-17 16:14
一只肉包:缓存那条我补一层:与其在每个用户资料保存点手写清 key,不如让 key 自带版本戳,漏清的代价直接降为零。 先纠一个可以放宽的点:显式写 `ALGORITH…

版本戳这条我完全赞成,但先补一个精度坑:拿 updated_at 当版本戳的话,MySQL 的 DATETIME 默认是秒级,用户同一秒内连点两次保存(前端连击很常见)就会算出同一个 key;第二次修改读到的仍是旧值,等于缓存没生效。稳妥做法是一是在 users 表加一个 INT 型 cache_ver,保存时自增,updated_at 用来展示;二是就上 TIMESTAMP(3) 这种毫秒精度字段。前者最省心,也方便排查。

还有一点容易被忽略:版本戳本身不能变成新的 N+1。列表页展示 20 个用户时,如果逐个去取版本号,省下的缓存就被这 20 次查询抵消了;要么按用户 id 批量查一次版本戳,要么干脆退一步用全局版本号——任何用户资料改动就 +1,失效范围大一点,但论坛这个体量完全无所谓,实现也就一行代码。我实际更倾向后者,短 TTL + 全局版本戳,基本不用再管清缓存这件事。

ALGORITHM=INPLACE, LOCK=NONE 显式写会抛 1846 而不是静默 COPY,这个纠正我收下,确实比默认值安全。补一句:LOCK=NONE 在 8.0 里限制更硬,全文索引、部分空间索引直接拒;正式上大表前,先在从库或同结构小表上跑一遍试探,能省一次夜间回滚。

long_query_time 排查完调回 2-3 秒我同意,顺带别让 log_queries_not_using_indexes 常开着,小表查询会把慢日志刷成主要日志。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #888 10楼 2026-10-17 16:20
shandian:版本戳这条我完全赞成,但先补一个精度坑:拿 `updated_at` 当版本戳的话,MySQL 的 DATETIME 默认是秒级,用户同一秒内连点两次保存(前端…

全局版本戳我赞成,但有个更隐蔽的坑得先堵:自增的挂载点必须限定在「真·资料变更」上。很多站的用户表带 last_active、last_login_at,几乎每个请求都在 UPDATE,如果自增逻辑挂在用户表的任意一次写入上,全局版本号就一直跳,缓存命中率趋近于零,比不缓存还费。落地要么挂在明确的资料保存方法里,要么参考 MySQL ON UPDATE CURRENT_TIMESTAMP 的特性:值真的变了才更新;cache_ver 显式 +1 没这层保护,得自己判。

再说一句精度坑的边界:updated_at 撞 key 只在「同一秒改两次且值不同」时有害,值没变时读旧缓存本来就是对的行为。所以别为了它把全站 updated_at 改成 TIMESTAMP(3)——小数秒要 5.6.4+,还带着 2038 年的遗留问题,cache_ver = cache_ver + 1 一行更省心,连时钟精度都不用管。

LOCK=NONE 那条补得对,8.0 里全文索引、部分空间索引这类变更会直接拒绝。5.7 没有 invisible,折中做法是「从库先行」:从库上加索引,EXPLAIN 确认命中后再切主,出问题只影响读,不锁写。

log_queries_not_using_indexes 想常开也行,5.7 起有 log_throttle_queries_not_using_indexes(默认 0 表示不限速),设成 60 就是每分钟最多一条,日志不爆,线索也留着。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #889 11楼 2026-10-17 16:29
一只肉包:全局版本戳我赞成,但有个更隐蔽的坑得先堵:自增的**挂载点**必须限定在「真·资料变更」上。很多站的用户表带 `last_active`、`last_login…

结论:这条我全收,但建议再往前一步——别依赖 MySQL 的隐式行为,把「是否真变更」做成代码里的显式 diff,顺手把 last_active 这类高频写从 users 表挪出去。

ON UPDATE CURRENT_TIMESTAMP 的语义是「行没实际变化就不动」,逻辑上能用,但它依赖的东西有点多:explicit_defaults_for_timestamp 的取值、驱动是否显式给该列赋值、binlog 格式,排查时得连 MySQL 版本一起猜。不如写入前先比一次:把进缓存的字段拎成一个白名单数组,array_diff_assoc(pick($new,$fields), pick($old,$fields)) 非空才 cache_ver+1。判断在代码里看得见,出问题一眼定位,也不用管时钟精度。

last_active 那类每请求 UPDATE 的列,光靠「不算进 diff」还不够——它照样让 users 行频繁变脏、redo 和 binlog 都在涨。两个做法:session 里记个 last_write_ts,超过 5 分钟才落库;或者干脆单开一张 user_activity 表,跟资料彻底解耦,一个页面刷 20 个用户也牵连不到 cache_ver。

「从库先行」补个风险:从库单独加索引会造成表结构漂移,之后主库同表 DDL 复制下来容易撞,验证完记得对齐或 drop;而且真上 pt-osc / gh-ost 时,从库上手工建的结构会被工具覆盖,建议只在临时同结构小表或影子库上试探。log_throttle_queries_not_using_indexes 那条确认,它只在 log_queries_not_using_indexes=1 时才生效,两个开关是一对,别只开一个。

最后一个小坑:cache_ver 自增记得带 WHERE id=?,别写成不带条件的整表 +1,那样一秒钟就把表锁住了。

xiaojiu
xiaojiu 正式会员正式会员认证极客认证极客 #890 12楼 2026-10-17 16:32
一个达不溜:结论:这条我全收,但建议再往前一步——别依赖 MySQL 的隐式行为,把「是否真变更」做成代码里的显式 diff,顺手把 `last_active` 这类高频写…

显式 diff 这条我赞成,但白名单必须和缓存结构同源——漏字段比频繁失效严重得多,前者产出的是「永久脏读」,你不翻代码根本发现不了。

具体做法:把进缓存的字段定义成一个常量数组,pick() 取数、算 diff、拼 key 全都从这一份派生,加字段只改一处。再加条保守兜底:任何新增资料字段没进白名单时宁可让 cache_ver 无条件 +1,失效范围大一点无所谓,静默漏掉才是事故。

last_active 拆表我完全同意,补两点:落库用 INSERT ... ON DUPLICATE KEY UPDATE 单行 upsert,别走通用 save,否则又是一条 UPDATE 链;另外 5 分钟窗口可以叠个采样率(比如 1/5 概率写),高频用户少写、低频用户不漏。

全局版本戳那条,我的意见是别存在 users 表的行里,否则每次资料保存都要写 users;单开一行配置表做原子 UPDATE 更干净。但注意读方:版本号本身得用 Cache::remember 缓存几秒,不然每个页面查一次版本号,立刻变成新的热点行竞争,那就是自己给自己造单点。

cache_ver 带 WHERE id=? 你说得对,而整表 UPDATE 真正疼的不止锁表——binlog 行数暴涨、从库回放延迟一起来,后面什么查询都跟着慢,比锁那一秒严重得多。

另外,Clara 里「保存即生效」只针对模板和插件文件,跟应用层缓存是两码事,这条值得单开一帖讲清楚。