短链系统压测报告

被测系统: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)

84.24%短码在冷缓存下可达
(3407 个不可达 → 缺陷①)
3.23×C1 冷/热缓存 TPS 差距
343.3 → 1109.7
0.105%C2 穿透时 DB 命中率
143458 请求 → 150 次查询
4.0%C4 限流后写库比例
600 请求 → 24 行
143.4 BC9 单次跳转的 Set 内存
且 TTL = -1 永不过期
149×统计 Stream 生产/消费比
积压 233118 条
8.4×创建接口 G1 / G2 吞吐差距
59.1 → 7.0 TPS(2.2 节)
58%G2 锁内临界区中可移出
的外部调用占比(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
CPUIntel 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 天)
Docker31 个运行中容器(本项目占 4 个,其余为无关业务,持续占用 CPU/IO)

注:宿主机为非独占环境,基准负载已达 17~21,是本次测试的主要噪声来源,所有结论均按“同一时段内相对对比”口径给出。

1.2 部署与运行参数#

组件端口关键参数
aggregation(admin + project 合并 fat jar)8003JDK 17 · -Xms/-Xmx 1024m · G1 GC · MaxMetaspace 256m
gateway8000JDK 17 · -Xms/-Xmx 512m
MySQL210008.0.46 · max_connections=151 · innodb_buffer_pool_size=256 MB
Redis210017.2.15 · maxmemory=0(无上限) · maxmemory-policy=noeviction · maxclients=10000
Nacos210022.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%通过
C9Redis 内存膨胀10000 次访问 → uv/uip 各 10000 成员 · 143.4 B/次 · TTL = -1发现缺陷

2.2 写路径 · 短链创建接口对比#

编号测试项关键指标结论
G1createShortLink
布隆过滤器判重 · 无锁 · @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瓶颈在连接池
G2createShortLinkByLock
DB 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 / Bfavicon 目标:回环地址 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
1345.6142.2ms128ms484ms1219.539.2ms25ms185ms2007437
2338.4143.8ms128ms474ms1135.241.3ms29ms250ms2007535
3346.0140.0ms114ms454ms974.347.4ms26ms280ms2007231
均值343.3142.0ms——1109.742.6ms——2007434.3
冷/热缓存吞吐量与响应时间(3 轮实测)。冷缓存 TPS 稳定在 338~346,说明测量可复现。
图 1 冷/热缓存吞吐量与响应时间(3 轮实测)。冷缓存 TPS 稳定在 338~346,说明测量可复现。
同样 10000 次请求,冷缓存回源 20074 次、热缓存 34 次,相差 590 倍。
图 2 同样 10000 次请求,冷缓存回源 20074 次、热缓存 34 次,相差 590 倍。
指标冷缓存热缓存差距
吞吐量 TPS343.31109.73.23×
平均响应时间142.0 ms42.6 ms3.33×
P50123 ms27 ms4.6×
P99471 ms238 ms1.98×
MySQL Com_select / 请求2.0070.0034590×
Redis GET / 请求5.01.05.0×
goto_keys 增量10000(0 → 10000)0—
冷缓存路径一次请求要 5 次 Redis GET: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
14.3 万次穿透请求只换来 150 次 DB 查询 —— 布隆过滤器把穿透挡在了 DB 之前。
图 3 14.3 万次穿透请求只换来 150 次 DB 查询 —— 布隆过滤器把穿透挡在了 DB 之前。
空值缓存(is-null)只服务“布隆认为存在、但 DB 查不到”的码,典型来源是短链被删除(布隆过滤器无法删除元素)。实测:用 admin 回收站把短链软删(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
突发 150 / 2001.71 s2 (1.0%)38 (19.0%)160 (80.0%)0
突发 2200 / 4003.68 s4 (1.0%)76 (19.0%)320 (80.0%)0
两档突发下业务码分布一致(风控 80% / Sentinel 19% / 成功 1%),且 600 次请求 100% 返回 HTTP 200。
图 4 两档突发下业务码分布一致(风控 80% / Sentinel 19% / 成功 1%),且 600 次请求 100% 返回 HTTP 200。
指标突发 1(并发 50)突发 2(并发 200)
整体响应时间avg 135.2 ms · P50 97.4 · P95 311.1 · P99 1164.9avg 85.2 ms · P50 57.5 · P95 260.2 · P99 996.9
成功请求(code:0)avg 872.1 msavg 661.1 ms
B100000 被限流avg 218.6 msavg 134.3 ms
A000300 被限流avg 106.2 msavg 66.4 ms
突发后健康检查health 200 · 登录 code:0 · 进程 2 · 启动异常 0同左
落库行数(describe=c4-limit)24 行 / 600 请求 = 4.0%同左

C9 · Redis 内存膨胀#

用一条全新创建的短链(45seFe,其 UV/UIP Set 此刻还不存在)做干净基线,20 线程 × 500 轮 = 10000 次访问,每次带全局唯一 X-Forwarded-For、且不带 Cookie(迫使服务端每次新生成 uv UUID)。

指标基线结束增量
stats:uv:{url} SCARD010000+10000
stats:uip:{url} SCARD010000+10000
Redis sadd 调用数451002471002+20000(2 / 请求)
MySQL Com_select379477379512+35
Redis used_memory(全局,混合口径)283393320 B287281736 B+3888416 B(3.71 MB)

上表的 Redis 全局 used_memory 是混合口径(同时含统计 Stream 与幂等键的开销),不能直接除以请求数当作 Set 的成本。因此再对两个 key 逐条做 MEMORY USAGE + OBJECT ENCODING 精确计量:

keySCARD编码MEMORY USAGE折合每成员
short-link:stats:uv:…/45seFe10000hashtable836752 B83.7 B(成员为 36 字符 UUID)
short-link:stats:uip:…/45seFe10000hashtable596760 B59.7 B(成员为 11 字符 IP)
合计20000—1433512 B = 1.37 MB143.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),只增不减
左侧:10000 次访问把两个 Set 精确撑到各 10000 个成员;右侧:按实测 143.4 B/次(uv 83.7 + uip 59.7)外推。
图 5 左侧:10000 次访问把两个 Set 精确撑到各 10000 个成员;右侧:按实测 143.4 B/次(uv 83.7 + uip 59.7)外推。
口径说明:小规模 Set 会用 intset/listpack 紧凑编码,成员变长或数量变多后自动升级为 hashtable,因此每成员成本不是线性常数;本次 10000 成员均已处于 hashtable 稳态,143.4 B/次是可用作外推的量级。关键在于:每有一条短链被访问,这个 key 就永久涨 2 个成员,且没有任何回收机制。
叠加缺陷②后,单次成功跳转的不可回收内存 ≈ 143.4 B(UV/UIP Set)+ 313 B(Stream 积压)= 456 B。而 Redis maxmemory = 0 且 noeviction,内存无上限、打满后写直接失败 —— 届时连缓存都写不进去,读路径会整体退化到全量回源 DB。
按 456 B/次外推:1 亿次累计跳转 ≈ 45.6 GB,已达本机 58 GiB 物理内存的约 73%(且 Redis 只是同机多个组件之一)。

G1 / G2 · 短链创建接口对比#

吞吐量随并发变化(主结论)。G1 在 10 并发即接近饱和,G2 全程贴地且与并发无关。
图 6 吞吐量随并发变化(主结论)。G1 在 10 并发即接近饱和,G2 全程贴地且与并发无关。
P95 / P99 响应时间随并发变化。G2 的延迟 ≈ 并发数 × 临界区耗时,是串行排队的直接体现。
图 7 P95 / P99 响应时间随并发变化。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)。

全量 21624 个短码中,18217 个可达、3407 个(15.76%)在冷缓存下不可达。
图 8 全量 21624 个短码中,18217 个可达、3407 个(15.76%)在冷缓存下不可达。

随机抽 5 个不可达短码,逐一核查两个分片表 —— 记录全部存在:

短码t_link_9t_link_goto_N
SO8vd10.176.36.38:8003/SO8vdt_link_goto_13
2HP5NJ10.176.36.38:8003/2HP5NJt_link_goto_12
1Ny22k10.176.36.38:8003/1Ny22kt_link_goto_4
3anGDo10.176.36.38:8003/3anGDot_link_goto_1
187Co610.176.36.38:8003/187Co6t_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

补充验证:

  1. 实验后 B 的 t_link_goto_15 记录依然健在(1 行)——排除“数据被删”。
  2. 手工把 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-stream233118
MEMORY USAGE72985150 B = 69.60 MB(313 B / 条)
entries-added235502
消费者组 pending0(说明不是“已投递未确认”,而是根本没投递出去)
last-delivered-id2026-10-07 17:32:37
last-generated-id2026-10-07 17:57:18
消费速率(10 s 实测)4.6 条 / 秒
清空积压预计耗时233118 / 4.6 ≈ 14.1 小时(零新增前提下)
生产速率 685.9~1109.7 条/s,消费速率实测仅 4.6 条/s。
图 9 生产速率 685.9~1109.7 条/s,消费速率实测仅 4.6 条/s。

根因在 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):

两个后果:
① 统计数据严重延迟(本次实测滞后 25 分钟以上且在扩大),业务上「实时统计」不可用;消费者线程池是 DiscardOldestPolicy,任务被丢弃时静默无日志。
② XADD 未设置 MAXLEN,积压会线性吃 Redis 内存(313 B/条)——与缺陷 C9 的无 TTL Set 叠加,构成 Redis 内存的双重无界增长。
修复方向:消费者线程池调大(core≥8);高德调用改异步 + 加超时;引入批量落库;XADD 带 MAXLEN ~ N。
说明:本次 233118 条积压是本次压测(合计约 25 万次成功跳转)制造的,全部指向 gid=perf 的测试短链。已按“不擅自改动服务器”的约定保留未清理,如需丢弃可执行 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_selectMySQL 全局「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 ÷ 全量短码 2162415.76% 的短码因缺陷①永久不可达
创建 8.4×(见 2.2 节)G1 峰值 TPS 59.1 ÷ G2 峰值 TPS 7.0两条创建链路的吞吐差距
创建 58%(见 2.2 节)(331 ms − 140 ms)÷ 331 msG2 锁内临界区中可完全移出的外部调用(favicon)占比