
Edufu 是我在负责的一个在线教育项目今年用户量上来之后出现了两个特别“分裂”的性能问题后端 MySQL 经常毫无征兆地 CPU 飙高、慢查询堆积前端客服那边又收到一堆用户反馈说页面白屏、表单加载不出来还弹出“your browser is blocking the recaptcha script”这种提示。一开始我以为是偶发问题直到某天数据库连接数直接被打满前端首屏时间从 1.8 秒涨到 4 秒以上才意识到这不是普通抖动。折腾了两周最后定位到两条完全不同的技术线一个是 InnoDB 存储引擎内部的 latch contention也就是锁存器争用另一个是浏览器渲染过程中的 CSSOM blocking样式对象模型阻塞渲染。这两个词听起来都很“底层”但其实对应的是数据库和前端两个领域最典型的阻塞问题。这篇文章我就用 Edufu 这个项目作为引子把我实际排查和优化的过程完整拆开包括命令、分析和最终落地的方案希望能帮到正在被类似问题折磨的人。1. 先盘清家底Edufu 的两个“卡点”到底卡在哪1.1 数据库侧的第一症状连接堆积和 CPU 飘红问题是从一次版本发布后开始的。那段时间 Edufu 正好上线了热门课程秒杀活动流量冲击比平时大了好几倍。第二天 DBA 群里就开始刷告警MySQL 实例的 CPU 使用率从 20% 一路冲到 95%慢查询日志每 5 分钟刷出上百条记录活跃会话数持续在 50 以上。我当时第一反应是有慢 SQL 没走索引于是上了SHOW FULL PROCESSLIST看到的场景是大量会话卡在Sending data和updating状态。进一步用EXPLAIN检查那些 INSERT 和 UPDATE 语句索引都用上了执行计划也没问题但就是快不了。更诡异的是跑了SHOW ENGINE INNODB STATUS之后在LATEST DETECTED DEADLOCK部分没看到死锁而在TRANSACTIONS部分看到了很多线程在等待一个叫 “rw-lock” 的玩意。那个瞬间我意识到问题不在 SQL 本身而在 InnoDB 内部更底层的地方。1.2 前端侧的奇怪表现白屏、CSS 卡顿、第三方脚本被拦前端那边更乱。客服转来的用户反馈五花八门但归纳下来就两类一类是打开课程详情页白屏浏览器标签页一直转圈另一类是提交报名表单时卡在验证码环节控制台会打印一句your browser is blocking the recaptcha script. to continue, please enable th...然后整个页面就跟死住了一样。一开始前台同事以为是自己代码没做容错后来用 Chrome DevTools 的 Performance 录制了完整链路发现几个问题页面的首屏 CSS 太大306KB 的样式文件全放在head里在弱网环境下光下载解析就要 2 秒多而 recaptcha 这个第三方脚本被某个浏览器扩展拦截后它的加载流程没有正常结束后面的 JS 和 CSSOM 构建全部被卡住。数据侧和前端侧看起来风马牛不相及但本质都是一回事某个环节的“阻塞”没有被及时发现和识别。下面我分两条线把 InnoDB latch contention 和 CSSOM blocking 的排查过程完整展开。2. InnoDB Latch Contention数据库内部的“红绿灯”堵死了2.1 latch 不是锁它是数据库内存里的“红绿灯”很多刚从业务 SQL 入手的朋友容易搞混一个概念InnoDB 的 latch 和 lock 不是一回事。简单来说lock是事务层的锁保护的是记录、表这类逻辑对象事务提交或回滚时释放会有死锁检测而latch是内存层的轻量级同步原语保护的是缓冲池里的页、索引节点、数据字典这些内存结构只在操作内存的过程中持有通常微秒级释放不参与死锁检测。用生活里的事打比方lock 像是会议室预约你预约了一个小时别人就得等下一位latch 像是去洗手间你只是进去洗个手门一开一关的事但如果所有人同时都挤到同一个洗手间门口门口就堵死了。Edufu 遇到的正是第二种情况——大量线程同时操作同一个内存页或者索引节点导致 latch 等待飙升CPU 都耗在自旋锁上SQL 再正常也快不起来。为了加深印象这里放一个对比表格对比项InnoDB LockInnoDB Latch保护对象记录、表、间隙等逻辑结构内存页、索引节点、缓冲池 block持有周期事务级提交/回滚释放操作级瞬时持有释放死锁检测有依赖等待图无一般用自旋等待通常表现长时间等待LOCK WAIT线程卡在rw-lock、mutex上常见场景并发更新同一行、间隙锁冲突热点页争用、索引页分裂、AHI 冲突2.2 怎么确认是不是 latch contention三个必查步骤在处理 Edufu 的问题时我形成了一个固定的排查套路不是靠猜而是从三个层面抓证据。第一步看SHOW ENGINE INNODB STATUS的输出。重点不是死锁部分而是TRANSACTIONS和SEMAPHORES部分。如果看到类似Thread 12345 has waited at .\storage\innobase\btr\btr0cur.cc line 1234 for 3.00 seconds这样的信息同时在SEMAPHORES里出现大量Mutex和RW-latch的数字基本可以确认 latch 等待存在。Edufu 那会儿OS WAIT ARRAY INFO: reservation count 1201000数字大得吓人几乎是正常状态下的几百倍。第二步查performance_schema的等待事件。我用的 SQL 大概是这样的SELECT THREAD_ID, EVENT_NAME, COUNT_STAR, SUM_TIMER_WAIT FROM performance_schema.events_waits_summary_by_thread_by_event_name WHERE EVENT_NAME LIKE %wait/synch% ORDER BY SUM_TIMER_WAIT DESC LIMIT 20;这个查询能按线程看到wait/synch/mutex/innodb/btr_search_latch之类的事件耗了多少时间。如果btr_search_latch名列前茅说明自适应哈希索引AHI在打架如果rw-lock相关事件很多那就是典型的数据页或索引页争用。第三步结合 Linux 系统的vmstat或top查看线程状态。latch 争用严重时会有大量线程处于R状态自旋但 CPU 使用率中的sy内核态占比异常高。Edufu 当时的趋势就是sy高达 60%这个比例正常情况不会超过 20%。2.3 根因定位热点行更新和索引页分裂打架通过上面三步我基本确定是 latch contention但要解决还得知道“谁在抢什么”。Edufu 的项目结构并不复杂核心热点就三张表课程表、课程场次表、报名订单表。秒杀活动把所有并发压到了同一门热门课的同一场次大家都去更新“剩余名额”这个字段于是大量更新集中在同一行上。这里有个容易被忽略的细节当多个线程更新同一行时InnoDB 先要定位到这行所在的聚簇索引页再在内存中修改该页。所有访问这条记录的线程都去竞争这一个 page 的rw-lock这个页就成了“全网最堵的路口”。第二重压力来自索引页分裂报名订单表插入量极大主键是自增 ID即便这样在批量插入时二级索引上的页也可能频繁分裂分裂过程需要持有btr_search_latch进一步加剧争用。还有一个隐藏因素Edufu 的历史表结构没有统一规范有些表没有显式建表约束走的是老版本的默认行格式COMPACT。行格式和 latch 争用听起来八竿子打不着但其实有关系COMPACT在存储变长字段时空间利用率不高页内容易出现碎片碎片越多插入时定位空闲空间的开销就越大间接增加了页操作时长。后来我们统一建表规范时在注释里明确了参数CREATE TABLE class_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, class_id BIGINT UNSIGNED NOT NULL, session_id BIGINT UNSIGNED NOT NULL, student_id BIGINT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_class_session (class_id, session_id) ) ENGINEinnodb DEFAULT CHARSETutf8mb4 ROW_FORMATDYNAMIC COMMENT课程报名订单表统一参数字符串: engineinnodb default row_formatdynamic comment about edu order;ROW_FORMATDYNAMIC在 MySQL 8.0 里本来就是默认值但代码里显式写上保证不同环境行为一致避免有人建表时翻出老模板生成 COMPACT 表。这个细节后来也成了我们所有新表的建表底线。2.4 让热点分散索引设计和数据分片双管齐下确认根因之后接下来就是解决。最直接的办法是把“同一行上的更新”变成“多行上的更新”。Edufu 原来的报名逻辑是这样的BEGIN; SELECT quota FROM class_session WHERE id ? FOR UPDATE; -- 判断剩余名额 INSERT INTO class_order (...) VALUES (...); UPDATE class_session SET quota quota - 1 WHERE id ?; COMMIT;这里的问题很明显所有用户都抢同一场次的同一行FOR UPDATE把并发全部串行化。我们做了一次改造把热门场次的“库存”分成 N 个桶比如 10 个桶每个桶独立更新下单时随机选择一个桶。库存扣减从“更新一行”变成“更新一行中的某个桶字段”锁粒度没变但争用频率变成了原先的十分之一。-- 场景表增加库存桶字段 ALTER TABLE class_session ADD COLUMN quota_bucket_1 INT NOT NULL DEFAULT 0; ALTER TABLE class_session ADD COLUMN quota_bucket_2 INT NOT NULL DEFAULT 0; -- ...这种方案不复杂但前后端都改了逻辑业务方也认可。如果不想动表结构还可以从索引层面做文章把报名表里的class_id session_id二级索引改成包含随机分片键的索引让新插入的行尽量分布在不同的索引页中避免每个新行都插到同一批热点页上。最终我们两个方案都上了数据库的SHOW ENGINE INNODB STATUS里等待计数肉眼可见地下降。2.5 事务长短直接影响 latch 持有时长除了分散热点另一个被验证有效的思路是缩短事务执行时间。latch 是内存操作本身极快但事务里如果夹杂了慢查询、网络往返、应用层计算latch 的持有总时长就会被拉长。很多新手忽略了这一点你以为优化的是事务逻辑实际上救的是 latch。Edufu 原来的报名业务有一个浪费时间的点事务内先做了远程 Redis 调用再查课程信息最后才更新数据库。Redis 延迟即便只有几毫秒在高并发下也会显著拉长事务边界。我们把这些不需要在数据库事务里做的操作全部挪到事务外只保留“扣库存插入订单”两个 SQL事务内时间从上百毫秒降到 20 毫秒以内latch 等待时间跟着大幅下降。另外不要把SELECT ... FOR UPDATE当成万能锁。Edufu 后来对库存扣减场景改成了原子更新加返回受影响行数的方式UPDATE class_session SET quota quota - 1 WHERE id ? AND quota 0;如果ROW_COUNT()返回 0再走补偿流程省掉了显式加锁和再查一次的过程。这样既控制了事务边界也减少了对同一数据页的重复访问latch 的自然竞争当然更小。2.6 InnoDB 参数调优里哪些真有用哪些只是心理安慰网上关于 latch contention 的文章常常一上来就甩参数比如innodb_thread_concurrency、innodb_adaptive_hash_index、innodb_buffer_pool_instances。我实测下来有的参数确实有效但前提是找对症结。先说innodb_adaptive_hash_index。这个参数控制自适应哈希索引开关AHI 能加速索引查找但高并发写场景下AHI 的维护本身会成为新的争用点。Edufu 是典型的“读多写多”关掉 AHI 之后btr_search_latch的等待明显下降。代价是某些只读查询耗时略增但对整体吞吐来说是划算的。再说innodb_buffer_pool_instances。它把缓冲池拆成多个分片理论上能减少不同线程同时访问同一个缓冲区块时的争用。Edufu 的机器内存有 64G我把 buffer pool 设为 48G实例数从默认值调到了 16确实分散了部分热点页的访问压力。这个调整要结合具体内存大小一般规律是每个 buffer pool instance 不小于 1G太小反而增加管理开销。最后是innodb_thread_concurrency这个参数我试过从 0 调到 64又从 64 调回 0结论是在没有明确瓶颈是“线程上下文切换”时不建议乱改。它控制的是 InnoDB 并发线程上限设得太小会让 CPU 空转设得太大又失去了限制意义。在 Edufu 的场景下真正解决问题的是业务削峰和索引优化参数调优只是配角。3. CSSOM Blocking浏览器渲染线程的第一道“闸门”3.1 从 HTML 到像素CSSOM 是怎么变成阻塞点的讲完数据库切到前端。很多人以为“页面渲染慢”就是资源下载慢但 Edufu 的案例很典型地说明了一件事CSS 文件解析构建 CSSOMCSS Object Model的过程本身就是阻塞渲染的关键路径。浏览器拿到 HTML 后不是等所有资源下载完才渲染而是边解析边准备。解析 HTML 生成 DOM遇到link relstylesheet或style标签时需要同时构建 CSSOM。CSSOM 和 DOM 合并后形成渲染树浏览器才能计算布局、绘制像素。关键问题是CSSOM 的构建是“全阻塞”的只要 CSS 还没下载和解析完后续的渲染步骤就不能开始。我习惯用一个厨房类比来解释这件事DOM 是菜和盘子CSSOM 是菜谱你得先看完菜谱才知道怎么摆盘。如果菜谱又厚又乱连看菜谱都要三分钟那顾客就只能干等着。Edufu 的页面正好就贴着这么一本“厚菜谱”。3.2 Edufu 页面首屏 CSS 的“三宗罪”用 DevTools Performance 跑一轮Edufu 首页的 CSS 问题可以总结成三条第一单文件体积过大。基于开源后台模板改造的前端把整个项目的所有页面样式打成了一个app.css体积 306KB其中大量样式类在首屏根本没用到。体积大带来的是下载时间翻倍尤其是在弱网环境下3G 网络里纯下载就要 2 秒。第二CSS 文件渲染路径过长。页面里除了自己的 CSS还外链了 5 个第三方域名的样式和字体文件font-face一个接一个。浏览器按顺序加载这些资源任何一个卡住后面的渲染就会往后拖。第三第三方脚本“堵枪眼”。Edufu 的报名表单集成了 Google 的 recaptcha 验证码。某些用户浏览器装了广告拦截插件或者网络策略比较严会直接拦掉www.google.com/recaptcha/api.js这个脚本。控制台出现your browser is blocking the recaptcha script. to continue, please enable th...这行提示本身只是第三方库的友好提醒但它揭示了一个致命问题脚本加载失败后页面里绑定的 onload 回调可能永远不触发后续的业务脚本一直等它整个页面的 CSSOM 和 JS 执行链路被一起卡死。3.3 关键 CSS 内联和非关键 CSS 异步加载的正确姿势解决 Edufu 首屏样式问题我们用了一个组合拳关键 CSS 内联 非关键 CSS 延迟加载。第一步用构建工具提取首屏关键样式。不论你用的是 Webpack 的critical插件、html-critical-webpack-plugin还是手写 Puppeteer 截图本质都是把首屏可视区域用到的 CSS 规则提取出来内联到 HTML 的style标签里。这样浏览器解析 HTML 时不需要额外请求就能拿到关键样式CSSOM 立刻能构建。第二步把剩余的非关键样式通过preload方式异步加载。具体代码是link relpreload href/css/non-critical.css asstyle onloadthis.onloadnull;this.relstylesheet noscriptlink relstylesheet href/css/non-critical.css/noscript这个写法和普通link relstylesheet的区别在于relpreload只是告诉浏览器“先把这个资源下载好”不会立刻阻塞渲染。下载完成后通过onload把rel改成stylesheet浏览器这时才把它“激活”。加上noscript回退是为了照顾禁用 JS 的极端情况。这里有的人会问为什么不能用async加载 CSS答案很直接async对 CSS 没有任何效果CSS 的加载天然是阻塞的只有通过preload或动态插入样式表才能绕过。这也是 Edufu 早期压缩过 CSS 但首屏没变快的原因——压缩只能减小体积不能改变“CSSOM 构建必须等样式全到”的优先级。3.4 第三方脚本怎么“围剿”指定加载时机和降级策略recaptcha 的问题比较特殊它不是性能问题而是第三方资源的不可靠导致的阻塞。对 Edufu 来说我们做了三层处理。第一层不再让验证码脚本在页面初始化时强制加载。改用IntersectionObserver监听表单区域只有当用户滚动到报名模块时再动态插入 recaptcha 脚本。这样没到表单区的用户完全不受这个脚本影响首屏净重减少一份。第二层给必须加载的第三方脚本统一加上defer或async。要注意defer会延迟到 DOM 解析完成后执行async是下载完立刻执行。验证码脚本对 DOM 依赖强用defer更安全。不过.defer无法保证多个依赖关系复杂的脚本之间的顺序所以 Edufu 后面干脆封装了一个小工具把验证码初始化逻辑包成一个 Promise在业务代码里显式等待避免回调地狱。第三层就是容错提示。如果检测到 recaptcha 脚本加载失败不要任凭页面卡死而是弹出一个合理的中文提示比如“验证码加载失败请刷新重试或联系客服”。同时在验证码旁边放一个“人工核验”的备用通道让用户不至于因为验证码插件被拦截就完全没法报名。这个思路对所有第三方依赖都适用你可以依赖别人但不能把自己的命门放在别人手里。4. 前后端“阻塞”的统一治理Edufu 的落地复盘4.1 用性能预算防止问题“复发”解决了眼前的坑之后我意识到一件事不管是 InnoDB 的 latch contention 还是 CSSOM blocking它们的共同根源是“没有一个明确红线”。如果提前给系统设好性能预算很多问题是可以在上线前就拦住的。Edufu 后来建立了简单的性能预算机制主要就这么几条每个页面首屏 CSS 内联部分不超过 30KB非关键 CSS 总大小不超过 80KB页面加载的第三方脚本域名不超过 3 个并全部设置defer或动态加载核心数据库表的更新事务控制在 20ms 内超过就触发熔断报警performance_schema里的 latch 等待累计时间和页面 LCPLargest Contentful Paint写入同一个监控大盘。预算不是拍脑袋定的而是参考当前网络环境和机器配置给出的一个“足够好”的数值。比如 CSS 30KB是因为内联到 HTML 后在 4G 网络下额外增加 100ms 以内用户感知不明显。4.2 数据侧和前端侧的联动监控才是关键复盘时最让我后怕的是这两个故障单独看都像“偶发事件”。数据库 CPU 高可能是运营在跑报表前端白屏可能是用户网络差。只有当我把两边的监控打通后才发现每一次页面白屏高峰数据库的 latch 等待也在同步飙升。这个联动的根源很好解释Edufu 秒杀活动时前端大量用户刷新页面请求涌到后端后端处理慢导致前端长时间占用连接浏览器等待资源的时间变长。而数据库的 latch 争用让请求响应变慢又进一步加剧了前端的排队时间。这不是单向影响是双向放大。Edufu 的监控脚本里会同时拉performance_schema的等待事件和浏览器上报的TNTTime to Interactive/LCP 指标。当数据库等待超过阈值同时前端 LCP 超过 2.5 秒时自动回溯最近 10 分钟的生产日志定位是哪个接口、哪张表、哪条 SQL 在拖底。这个思路并不花哨但真的能把“数据库 DBA 不管前端、前端工程师不管数据库”的老毛病扭转过来。4.3 一次真实的优化前后对比话不多说直接晒数字。这是 Edufu 那次优化前后同一时段、同一业务场景下的核心指标对比指标优化前优化后p99 数据库事务延迟450ms80msrw-lock等待次数每分钟18万次1.2万次首屏 LCP4.2s1.8sCSS 总大小306KB内联 28KB 异步 75KB第三方脚本阻塞导致的报错率7%0.3%这个结果不是某一个神奇配置带来的而是前文说的所有方案叠加的效果。数据库侧从热点分片、事务缩短、AHI 调优上用力前端侧从关键 CSS 内联、第三方脚本降级上用力。两边各自解决一个“阻塞”点最后在用户体验指标上汇合。5. 避坑清单与排查经验工具表5.1 我踩过的坑希望你别再踩第一坑把 latch contention 当成死锁处理。刚开始看到SHOW ENGINE INNODB STATUS里那些WAITING FOR THIS LOCK TO BE GRANTED我一度以为是死锁甚至去调死锁超时参数。结果死锁日志里啥都没有白折腾一天。死锁检测处理的是事务 locklatch 等待根本不会出现在死锁报告里。看到大量的rw-lock和mutex就要立刻转到内存页和热点行分析不要恋战。第二坑只查慢 SQL 看不到 latch。Edufu 最开始用pt-query-digest分析慢查询满屏都是UPDATE class_session执行计划也正常很难联想到是页争用。慢 SQL 是结果不是原因。要查必须查性能视图performance_schema.events_waits_summary_global_by_event_name这类表是更接近真相的地方。第三坑CSS 压缩不等于解决阻塞。我一度以为把 306KB 压缩到 180KB 就万事大吉结果 LCP 几乎没变。原因是压缩减少的是网络传输体积但 CSSOM 构建的阻塞本质没有变浏览器还是必须在渲染前等到这个文件加载完。真正有效的是“拆关键路径”而不是“瘦身”。第四坑给第三方脚本乱加async。测试 recaptcha 被拦截问题的时候我把api.js改成async加载结果验证码初始化时机不对表单提交报错。第三方库内部可能有自己的加载时序要求改加载方式之后一定要做全链路回归。5.2 排查速查表遇到同类问题直接照做症状可能原因排查命令/工具对应章节MySQL CPU 飙升慢查询多活跃会话卡在 updatingInnoDB latch contentionSHOW ENGINE INNODB STATUSperformance_schema.events_waits_summary_global_by_event_name2.2大量线程等待rw-lock热点页更新或索引页分裂查看SEMAPHORES部分定位具体等待文件与线程2.3页面首啸 CSS 加载慢白屏CSSOM blockingDevTools Performance查看 Follow 链路Lighthouse3.1-3.3第三方脚本被拦后续 JS 卡死脚本阻塞渲染 / 无降级策略控制台提示动态加载 容错提示3.4以上问题同时出现前后端性能未联动治理统一监控大盘性能预算4.1-4.26. 写在最后这次 Edufu 的优化经历让我对“阻塞”这两个字有了更立体的理解。数据库里的 latch 争用是线程在等内存资源浏览器里的 CSSOM 阻塞是渲染引擎在等样式规则两者一个发生在服务端最深处一个发生在用户设备最前端看似毫无关联但它们的本质都一样资源在某一时刻被过度集中或过度依赖导致后续操作全部排队。我个人最大的体会是遇到性能问题不要急着找“大招”先把现象和指标对齐。用SHOW ENGINE INNODB STATUS看数据库用 Performance 面板看浏览器两边的时间线拉到同一张图上往往能发现一个请求在数据库等了几十毫秒在前端却因为他人的脚本阻塞被放大了成秒级。没有这种跨层视角我只会在数据库参数里瞎调或者在 CSS 文件里反复压缩。最后再分享一个小技巧给任何关键页面做性能优化时先在浏览器里录制一次完整的加载过程再在数据库里导出一份同时间段的等待事件列表然后把两份数据放到同一个表格里对比。你会惊讶地发现很多“偶发”的卡顿底层原因就那么一两个。Edufu 的这次优化已经过去半年但这两条线的监控和性能预算一直保留着后续每次大版本发布这套“阻塞体检”都是必做项。希望这篇文章能给你提供同样的排查思路。