被测系统:nageoffer / shortlink(Spring Boot 3.0.7 + ShardingSphere-JDBC 5.3.2 + Redis Stream) · 服务器:10.176.36.38 · 压测时间:2026-10-07 · 压测工具:JMeter 5.6.3(CLI)
覆盖范围:读路径(短链跳转,A7 / B4 / C1 / C2 / C4 / C9,第一~五章) + 写路径(创建接口 G1 / G2 对比,见 2.2)
(3407 个不可达 → 缺陷①)
343.3 → 1109.7
143458 请求 → 150 次查询
600 请求 → 24 行
且 TTL = -1 永不过期
积压 233118 条
59.1 → 7.0 TPS(2.2 节)
的外部调用占比(2.2 节)
createShortLinkByLock 从不调用
bloomFilter.add(),导致该链路创建的短链在缓存失效后永久不可跳转(DB 里记录完好)。
② 统计链路积压 —— 消费者线程池只有 1 个线程、每条消息 8 次 DB 写 + 1 次同步外网 HTTP,
消费能力 4.6 条/秒,仅为生产速率的 1/149。一、测试环境#
1.1 服务器配置#
| 项 | 值 |
|---|---|
| 主机 / 内核 | Ubuntu 18.04.6 LTS · Linux 5.4.0-150-generic x86_64 |
| CPU | Intel Xeon Silver 4215R @ 3.20GHz · 32 vCPU |
| 内存 | 58 GiB(可用 仅 4.7 GiB,已用 52 GiB) |
| 系统盘 | /dev/sda2 · 3.0 TB · 已用 2.8 TB(98%)· 可用 79 GB |
| 测试后负载 | load average 17.41 / 18.23 / 21.66(32 vCPU · 运行 49 天) |
| Docker | 31 个运行中容器(本项目占 4 个,其余为无关业务,持续占用 CPU/IO) |
注:宿主机为非独占环境,基准负载已达 17~21,是本次测试的主要噪声来源,所有结论均按“同一时段内相对对比”口径给出。
1.2 部署与运行参数#
| 组件 | 端口 | 关键参数 |
|---|---|---|
| aggregation(admin + project 合并 fat jar) | 8003 | JDK 17 · -Xms/-Xmx 1024m · G1 GC · MaxMetaspace 256m |
| gateway | 8000 | JDK 17 · -Xms/-Xmx 512m |
| MySQL | 21000 | 8.0.46 · max_connections=151 · innodb_buffer_pool_size=256 MB |
| Redis | 21001 | 7.2.15 · maxmemory=0(无上限) · maxmemory-policy=noeviction · maxclients=10000 |
| Nacos | 21002 | 2.2.3 standalone · MySQL 存储 |
| 数据层 | — | ShardingSphere-JDBC 5.3.2 · t_link / t_link_goto 各 16 片 HASH_MOD |
| 连接池 | — | HikariCP 未配置 → 默认 maximumPoolSize=10 |
1.3 测试条件#
| 项 | 值 |
|---|---|
| 被测链路 | 读路径 GET /{short-uri}(跳转)+ 写路径 POST /api/short-link/v1/create(C4) |
| 压测机 | 与应用同机,JMeter 5.6.3 CLI 非 GUI,直连 10.176.36.38:8003,不跟随 302 |
| 数据基础 | t_link_9 中 gid=perf 的短链 21624 条(A7 复核时 21634 条) |
| 限流隔离 | B4/C1/C2/C9 在压测态(关 Sentinel + 关用户级风控 + 白名单加 127.0.0.1);C4 在生产态 |
| 还原验证 | 配置 md5 恢复为 d898e9c2…,反向冒烟重新出现 B100000 / A000300 |
二、结果总览#
2.1 读路径 · 短链跳转#
| 编号 | 测试项 | 关键指标 | 判定 |
|---|---|---|---|
| A7 | 分片数据一致性 | 两侧各 21634 条 · goto 孤儿 0 · link 孤儿 0 · gid 不一致 0 | 通过 |
| B4 | 缓存预热 | 82065 次请求 / 119.6s / TPS 685.9 · goto_keys 0 → 18217(覆盖率 100.00%) | 通过 |
| C1 | 冷 / 热缓存对比 | 冷 343.3 TPS / 141.9ms · 热 1109.7 TPS / 42.6ms → 3.23×;DB 回源 590× | 通过 |
| C2 | 缓存穿透 | 143458 请求 / TPS 2414.8 / 全部 302 · MySQL 仅 150 次查询(0.105%) | 通过 |
| C4 | 限流降级 | 600 突发请求 · HTTP 200 100% · 非 200 = 0 · 成功 1.0% · 落库 4.0% | 通过 |
| C9 | Redis 内存膨胀 | 10000 次访问 → uv/uip 各 10000 成员 · 143.4 B/次 · TTL = -1 | 发现缺陷 |
2.2 写路径 · 短链创建接口对比#
| 编号 | 测试项 | 关键指标 | 结论 |
|---|---|---|---|
| G1 | createShortLink布隆过滤器判重 · 无锁 · @Transactional并发梯度 1 / 5 / 10 / 50 / 100 / 200 | 峰值 59.1 TPS(并发 200)· avg RT 106 → 3306 ms(13.7 倍)· hikaricp_connections_active 自并发 10 起钉在 10(= HikariCP 默认池大小)· 池等待峰值 190 | 瓶颈在连接池 |
| G2 | createShortLinkByLockDB selectOne 判重 · Redisson 全局单键锁 · 无事务并发梯度 1 / 5 / 10 / 50 / 100 / 200 | 峰值 7.0 TPS(并发 5),各档位恒在 6.4 附近、与并发无关 · active 恒 1 / pending 恒 0 · 并发 200 时 avg RT 27518 ms ≈ 200 × 138 ms | 瓶颈在全局锁 |
| G1 / G2 | 两条链路同梯度对比 | 峰值 8.4×(59.1 / 7.0)· 并发 200 时 9.98× · P95 从 263 ms 恶化到 52548 ms | 两者都饱和 但原因不同 |
| A / B | favicon 目标:回环地址 vs 真实外网 (同一份代码 / 同一台机器 / 同一套 JMeter 计划,仅换 originUrl) | G2 锁内临界区 140 → 331 ms(+191 ms)· 推算吞吐上限 7.1 → 3.0 TPS(−58%),实测 7.04 → 2.99 与之吻合 · 并发 1→5:G1 提升 2.87 倍,G2 仅 1.10 倍 | 锁内可移出的外部 调用占 58% |
三、逐项结果#
A7 · 分片数据一致性#
复核 t_link_N 与 t_link_goto_N 的对应关系,两侧分别导出 (full_short_url, gid) 后做双向差集与 join 比对。
| 检查项 | 结果 |
|---|---|
| 两侧记录数 | t_link 21634 · t_link_goto 21634 |
| ① goto 有、link 无(goto 孤儿,会跳转但查不到链) | 0 |
| ② link 有、goto 无(link 孤儿,永远无法跳转) | 0 |
| ③ 同一 full_short_url 两侧 gid 不一致 | 0 |
| ④ 两侧完全一致的记录数 | 21634 / 21634(100%) |
t_link 按 gid 分片,本次数据 gid 全为 perf,故 21626 行全部落在 t_link_9 一个分片(其余 15 片仅 8 行历史数据);而 t_link_goto 按 full_short_url 分片,在 16 片上分布均匀(1279 ~ 1428 行)。B4 · 缓存预热#
先清空 short-link:goto:* 制造冷启动,再用 20 并发 × 120s 遍历全部可达短码。
| 指标 | 值 |
|---|---|
| 请求总数 / 墙钟 | 82065 次 / 119.6 s |
| 吞吐量 | 685.9 TPS |
short-link:goto:* key 数 | 0 → 18217(Δ=18217) |
| 缓存覆盖率 | 100.00%(18217 / 18217 可达短码) |
| MySQL Com_select 增量 | 36740 ≈ 2 × 18217(每个未命中回源 2 次查询) |
| Redis GET / SET 增量 | 155146 / 18856 |
| goto key TTL 抽样 | 2626471 s ≈ 30.4 天 |
createShortLink 建链时就会写入 goto 缓存,所以「建完链立刻读」观察不到冷启动 —— 必须显式清空 key 才能测出冷缓存的真实代价。C1 · 冷 / 热缓存对比#
同一批 10000 个短码、50 并发、每码只访问一次。冷:先清空 goto:*;热:紧接着再跑一遍(缓存已被上一遍预热)。重复 3 轮。
| 轮次 | 冷 TPS | 冷 avg | 冷 P50 | 冷 P99 | 热 TPS | 热 avg | 热 P50 | 热 P99 | 冷 Com_select | 热 Com_select |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | 345.6 | 142.2ms | 128ms | 484ms | 1219.5 | 39.2ms | 25ms | 185ms | 20074 | 37 |
| 2 | 338.4 | 143.8ms | 128ms | 474ms | 1135.2 | 41.3ms | 29ms | 250ms | 20075 | 35 |
| 3 | 346.0 | 140.0ms | 114ms | 454ms | 974.3 | 47.4ms | 26ms | 280ms | 20072 | 31 |
| 均值 | 343.3 | 142.0ms | — | — | 1109.7 | 42.6ms | — | — | 20074 | 34.3 |
| 指标 | 冷缓存 | 热缓存 | 差距 |
|---|---|---|---|
| 吞吐量 TPS | 343.3 | 1109.7 | 3.23× |
| 平均响应时间 | 142.0 ms | 42.6 ms | 3.33× |
| P50 | 123 ms | 27 ms | 4.6× |
| P99 | 471 ms | 238 ms | 1.98× |
| MySQL Com_select / 请求 | 2.007 | 0.0034 | 590× |
| Redis GET / 请求 | 5.0 | 1.0 | 5.0× |
| goto_keys 增量 | 10000(0 → 10000) | 0 | — |
goto 未命中 → 布隆过滤器 → is-null 未命中 → 加 Redisson 锁 → 锁内再各查一次 goto 与 is-null(double-check)→ 回源 2 次 DB → 写缓存。热缓存只需 1 次 GET 即返回。C2 · 缓存穿透#
100 并发 × 60s 持续请求 1000 个确保不存在的随机短码(先用全量真实短码做去重校验)。
| 指标 | 值 |
|---|---|
| 请求总数 / 墙钟 | 143458 次 / 59.4 s |
| 吞吐量 | 2414.8 TPS |
| 响应时间 | avg 35.0 ms · P50 22 · P90 78 · P99 198 · max 438 |
| 响应码 | 302 × 143458(错误 0) |
| Redis GET 次数 | 143554(1.0007 / 请求) |
| MySQL Com_select 增量 | 150(0.105% / 请求) |
short-link:is-null:* 增量 | 0 |
short-link:goto:* 增量 | 0 |
enable_status 置 1,同时该接口会删掉 goto 缓存)后,第 1 次访问写入 short-link:is-null:goto_…,TTL = 1798 s(30 分钟);第 2 次访问 Com_select 增量 = 1,证明空值缓存生效。而随机不存在的短码不会写空值缓存 —— 说明对“随机后缀攻击”的防线是布隆过滤器,不是空值缓存。C4 · 限流降级(生产态)#
在未做任何修改的生产配置下打创建接口。被限流的响应是 HTTP 200 + 业务码,因此必须断言响应体而不能只看状态码。
| 阶段 | 并发 / 次数 | 墙钟 | code:0 成功 | B100000(Sentinel) | A000300(用户级风控) | 非 200 响应 |
|---|---|---|---|---|---|---|
| 预热确认 | 10 / 30 | — | 1 (3.3%) | 19 (63.3%) | 10 (33.3%) | 0 |
| 突发 1 | 50 / 200 | 1.71 s | 2 (1.0%) | 38 (19.0%) | 160 (80.0%) | 0 |
| 突发 2 | 200 / 400 | 3.68 s | 4 (1.0%) | 76 (19.0%) | 320 (80.0%) | 0 |
| 指标 | 突发 1(并发 50) | 突发 2(并发 200) |
|---|---|---|
| 整体响应时间 | avg 135.2 ms · P50 97.4 · P95 311.1 · P99 1164.9 | avg 85.2 ms · P50 57.5 · P95 260.2 · P99 996.9 |
| 成功请求(code:0) | avg 872.1 ms | avg 661.1 ms |
| B100000 被限流 | avg 218.6 ms | avg 134.3 ms |
| A000300 被限流 | avg 106.2 ms | avg 66.4 ms |
| 突发后健康检查 | health 200 · 登录 code:0 · 进程 2 · 启动异常 0 | 同左 |
| 落库行数(describe=c4-limit) | 24 行 / 600 请求 = 4.0% | 同左 |
- 限流按 80:20 分工:用户级风控(Redis LUA 计数器,20 请求/秒窗口)挡掉 80%,Sentinel 单机 QPS=1 兜住剩下的 19%。
- 被限流的请求是快速失败(66~219 ms),真正的成功请求反而最慢(661~872 ms)——说明降级生效且没有把线程池堵死。
- 600 次请求只有 24 行落库(4.0%),限流真正拦掉了 96% 的写压力。
- 风控计数 key
short-link:user-flow-risk-control:admin事后 TTL=-2(已被清理),未出现计数泄漏。
C9 · Redis 内存膨胀#
用一条全新创建的短链(45seFe,其 UV/UIP Set 此刻还不存在)做干净基线,20 线程 × 500 轮 = 10000 次访问,每次带全局唯一 X-Forwarded-For、且不带 Cookie(迫使服务端每次新生成 uv UUID)。
| 指标 | 基线 | 结束 | 增量 |
|---|---|---|---|
stats:uv:{url} SCARD | 0 | 10000 | +10000 |
stats:uip:{url} SCARD | 0 | 10000 | +10000 |
Redis sadd 调用数 | 451002 | 471002 | +20000(2 / 请求) |
| MySQL Com_select | 379477 | 379512 | +35 |
Redis used_memory(全局,混合口径) | 283393320 B | 287281736 B | +3888416 B(3.71 MB) |
上表的 Redis 全局 used_memory 是混合口径(同时含统计 Stream 与幂等键的开销),不能直接除以请求数当作 Set 的成本。因此再对两个 key 逐条做 MEMORY USAGE + OBJECT ENCODING 精确计量:
| key | SCARD | 编码 | MEMORY USAGE | 折合每成员 |
|---|---|---|---|---|
short-link:stats:uv:…/45seFe | 10000 | hashtable | 836752 B | 83.7 B(成员为 36 字符 UUID) |
short-link:stats:uip:…/45seFe | 10000 | hashtable | 596760 B | 59.7 B(成员为 11 字符 IP) |
| 合计 | 20000 | — | 1433512 B = 1.37 MB | 143.4 B / 次成功跳转 |
| 指标 | 值 |
|---|---|
| 吞吐量 / 响应时间 | 672.4 TPS · avg 24.5 ms · P50 9 · P90 55 · P99 258 · max 711 · 错误 0 |
TTL(uv) / TTL(uip) | -1 / -1(永不过期) |
| 单次跳转新增 Set 成员数 | 2 个(uv 1 + uip 1),只增不减 |
intset/listpack 紧凑编码,成员变长或数量变多后自动升级为 hashtable,因此每成员成本不是线性常数;本次 10000 成员均已处于 hashtable 稳态,143.4 B/次是可用作外推的量级。关键在于:每有一条短链被访问,这个 key 就永久涨 2 个成员,且没有任何回收机制。maxmemory = 0 且 noeviction,内存无上限、打满后写直接失败 —— 届时连缓存都写不进去,读路径会整体退化到全量回源 DB。按 456 B/次外推:1 亿次累计跳转 ≈ 45.6 GB,已达本机 58 GiB 物理内存的约 73%(且 Redis 只是同机多个组件之一)。
G1 / G2 · 短链创建接口对比#
四、附带发现①:布隆过滤器缺口(严重)#
缺陷 createShortLinkByLock 从不把 fullShortUrl 写入布隆过滤器。
读源码对比两条创建链路的收尾逻辑:
// createShortLink —— 有 stringRedisTemplate.opsForValue().set(GOTO_SHORT_LINK_KEY + fullShortUrl, ...); shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl); return ...; // createShortLinkByLock —— 无 stringRedisTemplate.opsForValue().set(GOTO_SHORT_LINK_KEY + fullShortUrl, ...); // 全方法内没有任何 shortUriCreateCachePenetrationBloomFilter.add(...) return ...;
后果:by-lock 链路创建的短链只活在 Redis goto 缓存里。一旦缓存被清空 / 过期 / 驱逐,restoreUrl 会在布隆过滤器处被短路 —— 连 DB 都不查:
boolean contains = shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl);
if (!contains) {
((HttpServletResponse) response).sendRedirect("/page/notfound");
return; // ← 到此为止,t_link_goto 里的记录形同不存在
}
4.1 全量冷缓存普查#
清空 goto:* 后,用并发 50 逐个访问全部 21624 个短码(墙钟 135.9 s,159.1 req/s)。
随机抽 5 个不可达短码,逐一核查两个分片表 —— 记录全部存在:
| 短码 | t_link_9 | t_link_goto_N |
|---|---|---|
| SO8vd | 10.176.36.38:8003/SO8vd | t_link_goto_13 |
| 2HP5NJ | 10.176.36.38:8003/2HP5NJ | t_link_goto_12 |
| 1Ny22k | 10.176.36.38:8003/1Ny22k | t_link_goto_4 |
| 3anGDo | 10.176.36.38:8003/3anGDo | t_link_goto_1 |
| 187Co6 | 10.176.36.38:8003/187Co6 | t_link_goto_9 |
4.2 最小可复现实验(A / B 对照)#
造两条链:A 走 POST /api/short-link/v1/create,B 走 POST /api/short-link/v1/create/by-lock;其余参数完全一致。在两条都删掉 Redis 缓存的同样条件下对比。
| 对象 | 热缓存下 | 删掉 Redis 缓存后 | 结论 |
|---|---|---|---|
A · /v1/create(有 bloomFilter.add) | 302 → http://127.0.0.1:21003/ | 302 → http://127.0.0.1:21003/ | 正常:布隆放行 → 回源 DB 命中 |
B · /v1/create/by-lock(无 bloomFilter.add) | 302 → http://127.0.0.1:21003/ | 302 → /page/notfound | 异常:布隆拦截 → 永不回源 DB |
补充验证:
- 实验后 B 的
t_link_goto_15记录依然健在(1 行)——排除“数据被删”。 - 手工把 B 的缓存 key 写回去,立刻恢复
302 → http://127.0.0.1:21003/——佐证阻塞点就在「布隆过滤器 + 缓存」这一层,DB 无需任何改动。
by-lock 链路创建的短链,在缓存失效(清空 / 30.4 天 TTL 到期 / 内存驱逐)后永久无法跳转,且不写 is-null 空值缓存、不触发任何告警或日志。修复方向:在
createShortLinkByLock 插入成功后补一行 shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl)。五、附带发现②:统计 Stream 积压(严重)#
在压测收尾时读取 Redis Stream 状态,发现积压 23 万条未消费消息。
| 指标 | 值 |
|---|---|
XLEN short-link:stats-stream | 233118 |
MEMORY USAGE | 72985150 B = 69.60 MB(313 B / 条) |
entries-added | 235502 |
消费者组 pending | 0(说明不是“已投递未确认”,而是根本没投递出去) |
last-delivered-id | 2026-10-07 17:32:37 |
last-generated-id | 2026-10-07 17:57:18 |
| 消费速率(10 s 实测) | 4.6 条 / 秒 |
| 清空积压预计耗时 | 233118 / 4.6 ≈ 14.1 小时(零新增前提下) |
根因在 RedisStreamConfiguration:
@Bean
public ExecutorService asyncStreamConsumer() {
return new ThreadPoolExecutor(
1, // corePoolSize
1, // maximumPoolSize ← 消费者只有 1 个线程
60, TimeUnit.SECONDS,
new SynchronousQueue<>(), // ← 队列容量 0,完全不缓冲
...,
new ThreadPoolExecutor.DiscardOldestPolicy() // ← 满了直接丢弃任务
);
}
// 另有 .batchSize(10) / .pollTimeout(3s)
而单条消息的处理成本极高(ShortLinkStatsSaveConsumer#actualSaveShortLinkStats):
- 1 次 Redisson 读锁(按 fullShortUrl)
- 1 次 DB 查询取 gid
- 8 次 DB 写:access_stats / locale / os / browser / device / network / access_logs / t_link.incrementStats / stats_today
- 1 次同步外网 HTTP:
HttpUtil.get(AMAP_REMOTE_URL, ...)高德 IP 归属地解析,未设置任何超时 - 1 次
XDEL(仅成功后才删)
① 统计数据严重延迟(本次实测滞后 25 分钟以上且在扩大),业务上「实时统计」不可用;消费者线程池是
DiscardOldestPolicy,任务被丢弃时静默无日志。②
XADD 未设置 MAXLEN,积压会线性吃 Redis 内存(313 B/条)——与缺陷 C9 的无 TTL Set 叠加,构成 Redis 内存的双重无界增长。修复方向:消费者线程池调大(core≥8);高德调用改异步 + 加超时;引入批量落库;
XADD 带 MAXLEN ~ N。XTRIM short-link:stats-stream MAXLEN 0。附录 · 指标口径说明#
本报告的每一个指标都是「分子 / 分母」或「单位量」。下表给出确切定义,便于独立复核。
| 指标名词 | 确切定义 | 本次取值 |
|---|---|---|
| 请求总数(samples) | JMeter 实际发出的 HTTP 请求条数(含失败) | B4 = 82065 · C2 = 143458 |
| TPS(吞吐量) | 请求总数 ÷ 墙钟秒数,客户端视角的完成速率 | B4 = 685.9 · C1 冷 = 343.3 / 热 = 1109.7 |
| avg / P50 / P90 / P99 / max | 响应时间分布。P99 = 99% 的请求都快于该值 | C1 冷 avg 142.0 ms · 热 avg 42.6 ms |
| 覆盖率(B4) | 分子=压测结束后 Redis 中真实存在的 short-link:goto:* key 数=18217;分母=有布隆过滤器登记、因而「本该可达」的短码数=18217;18217 ÷ 18217 = 100.00%,含义是所有可达短码都被预热进了缓存 | 18217 / 18217 = 100.00% |
| B(字节) | Byte,1 B = 8 bit。143.4 B/次 = 每发生一次成功跳转,UV Set 与 UIP Set 合计多占 143.4 字节(uv 83.7 + uip 59.7) | 143.4 B/次 |
| Δ(增量) | 结束值 − 开始值。直接读 Redis INFO commandstats 与 MySQL Com_select 等累计计数器再相减,避免瞬时采样噪声 | C1 冷 Com_select Δ=20074 |
Com_select | MySQL 全局「SELECT 语句执行次数」计数器,用来近似 DB 回源次数 | C2 仅 Δ=150 |
| HTTP 200 占比 / 业务码分布 | 本项目限流返回 HTTP 200 + 业务错误码,所以必须统计响应体 code 字段,不能只看状态码 | C4 = 200 占 100% |
goto_keys 0 → 18217 | 压测前先 DEL 掉全部 short-link:goto:* 制造冷启动;0 是清空后的基线,18217 是跑完后的实际 key 数 | Δ = 18217 |
| 服务端 TPS(2.2 节) | 取自 http_server_requests_seconds_count 的增量 ÷ 时间窗,与客户端实测独立复算,用于排除压测机自身瓶颈 | G1 @200 并发:客户端 59.1 / 服务端 53.54 |
| 池等待峰值 / 取连接均值(2.2 节) | hikaricp_connections_pending 在场景时间窗内的峰值;hikaricp_connections_acquire_seconds 的窗口均值(ms) | G1 @200:峰值 190 · 均值 3091.54 ms |
| 表中的百分比 | 算法 | 含义 |
|---|---|---|
| C2 0.105% | MySQL 查询数 150 ÷ 请求数 143458 | 万分之一级别的请求才真的碰了数据库 |
| C4 4.0% | 落库行数 24 ÷ 突发请求数 600 | 限流把 96% 的写压力挡在了业务逻辑之外 |
| B4 100.00% | 已缓存短码 18217 ÷ 可达短码 18217 | 冷启动遍历一遍即完成全量预热 |
| C1 3.23× / 590× | 热 TPS÷冷 TPS;冷 Com_select÷热 Com_select | 缓存带来的吞吐收益与 DB 减负倍数 |
| C9 84.24%(见四章) | 冷缓存下可达短码 18217 ÷ 全量短码 21624 | 15.76% 的短码因缺陷①永久不可达 |
| 创建 8.4×(见 2.2 节) | G1 峰值 TPS 59.1 ÷ G2 峰值 TPS 7.0 | 两条创建链路的吞吐差距 |
| 创建 58%(见 2.2 节) | (331 ms − 140 ms)÷ 331 ms | G2 锁内临界区中可完全移出的外部调用(favicon)占比 |