我压测了 Jedis 和 Lettuce,发现网上说的都对,但不完整

9 阅读12分钟

为什么网上都说Lettuce性能相对Jedis性能更好?

如果你搜过"Jedis vs Lettuce",大概率会看到清一色的结论:Lettuce 更快、更现代,基于 Netty 的异步模型,一条连接就能扛住高并发;Jedis 是老旧的 BIO 模型,不用连接池的话性能堪忧。Spring Boot 2.x 把 Redis 的默认客户端从 Jedis 换成了 Lettuce,这条新闻又给"Lettuce 更好"这个结论加了一层官方背书。

我一开始也是信的。但信了之后总觉得少点什么——这些文章大多数只给了一个"更快"的定性结论,很少有人把"多快""在什么条件下更快"讲清楚。压测参数、并发量、连接池怎么配的,往往语焉不详。既然 Jedis 是阻塞模型、Lettuce 是异步模型,这两者的性能曲线大概率不是一条直线的"谁全程碾压谁",而是会随着并发量变化交叉的。

与其继续信,不如自己测一遍。

先说结论:分场景怎么选

如果你赶时间,直接看下面的场景对照表;完整的证据链、以及"Jedis 反超 Lettuce"这个反直觉结果是怎么测出来的,在后面几节里。

  • 并发 ≤ 100:选 Lettuce 单连接(裸客户端或 Spring 封装都行,差距很小),网上说的完全成立,而且代码更简单,不用管连接池怎么配。
  • 并发 100~500:单连接开始退化,该切连接池了,这时候用哪个客户端问题不大。
  • 并发 ≥ 500 且必须用连接池:优先裸 Jedis,而不是想当然地继续用 Lettuce 连接池——这条是网上几乎不会告诉你的,因为很少有人测这个场景。
  • 如果你在用 Spring Data Redis:上面这条要打个折扣。Spring 封装的 Jedis 在 1000 线程下自己就掉了小一半性能,裸 Jedis 的优势被削弱了不少;而 Spring 封装的 Lettuce 几乎没有额外开销。真要在极高并发场景死磕性能,可能得考虑绕开 RedisTemplate 直接用裸客户端。

怎么测的:并发数 × 连接策略

只跑一轮"QPS 对比"就下结论太单薄,我想看的是一条曲线,而不是一个点——所以把参数拉成了两个维度:并发线程数 × 连接策略

并发线程数从 10 一路加到 1000(10 / 50 / 100 / 500 / 1000),覆盖从"随便测测"到"往死里压"的全程。连接策略这边,Jedis 是阻塞模型,离了连接池没法用,所以只给了 JedisPool 8 / 32 / 100 三档池大小;Lettuce 测了两种用法——官方推荐的单连接共享,以及用 ConnectionPoolSupport 包一层连接池(同样 8/32/100 三档作为对照,毕竟现实里总有人把 Lettuce 当 Jedis 一样配连接池)。两边加起来,Jedis 3 档 + Lettuce 4 档 = 7 种客户端配置。

每种配置在每档并发下跑 3 次,每次每线程执行 500 次 SET+GET,取 QPS 中位数排除抖动;Redis 用本地 7.x,和压测进程同一台机器,避免网络延迟这个变量搅局。

7 种配置 × 5 档并发,一共 35 组数据,全程 0 错误。为了防止手写压测器自己的实现有偏差,又单独跑了一遍 JMH 微基准做交叉验证——两边结果吻合,后面的结论才敢往下写。

网上说的,先验证一遍

先看最常见的对比场景:中等并发下,Lettuce 单连接 vs Jedis 连接池。

并发线程数Lettuce 单连接 QPSJedis pool=100 QPS倍数
1039,37030,1201.3x
5077,64031,4472.5x
10078,12530,9982.5x

结果很干脆:网上说的是对的。50~100 线程这个绝大多数中小型应用日常并发量的区间里,Lettuce 单连接把 Jedis 连接池按在地上摩擦,2.5 倍的差距不是噪声。而且代码更简单——Lettuce 不用管连接池怎么配,一条连接、一个 RedisCommands,多线程随便用。

到这一步,故事看起来要结束了:网上说得对,验证完毕,回去继续用 Lettuce 单连接就行。

但我留了个心眼:并发量继续往上拉会怎样?

拉满并发,单连接开始露馅

把线程数从 100 加到 500,再加到 1000,只看 Lettuce 单连接这一条线:

并发线程数QPSP50 延迟P99 延迟
10078,1251.26ms1.76ms
50038,41412.89ms15.29ms
100015,73063.72ms69.24ms

从峰值到 1000 线程,QPS 跌了近 80%,P50 延迟从 1.26ms 涨到 63.72ms,涨了 50 倍。而且 P50 和 P99 挨得很近(63.72ms vs 69.24ms)——这不是少数请求偶尔卡顿的长尾问题,是所有请求都在排队

这其实是 Lettuce 架构里没怎么被强调的一面:所谓"一条连接线程安全",线程安全的代价是所有命令都要排进同一条连接的队列里,靠 Netty 的事件循环一个个处理。并发量小的时候,队列几乎不用等,你甚至能吃到 pipeline 打包发送的红利,所以反而比 Jedis 快。但并发量一旦超过这条连接能吞吐的上限,所有人都在排队,延迟就跟着并发数一起往上涨。拐点就卡在 100~500 线程之间。

到这里,故事有了第一个转折:"Lettuce 更快"是有前提的,前提是你没把并发拉到失控

那就上连接池——结果发现了更反直觉的事

单连接崩了,很自然的想法是:Lettuce 不是也支持连接池吗?用连接池顶住高并发不就行了。于是把 Lettuce 换成 ConnectionPoolSupport 连接池,和同样池大小的 Jedis 摆在一起,只看 1000 线程这个最极端的档位:

连接池大小Jedis 连接池 QPSLettuce 连接池 QPS谁快
821,13013,141Jedis 快 61%
3228,64015,071Jedis 快 90%
10034,43817,712Jedis 快 94%

这个结果我第一次看到是有点意外的。同样是"连接池",同样的池大小,Jedis 全面碾压 Lettuce,而且差距接近 2 倍,还越大的池差距越大。Lettuce 连接池 pool=8 时的 P99 更是飙到 2132.74ms,是全表 35 组数据里最差的一个。

这不是网上任何一篇文章会告诉你的结论,因为几乎没人会去测"Lettuce 连接池模式"——Lettuce 官方文档的立场一直是"你通常不需要连接池",所以大家要么测单连接,要么压根不测连接池这条路。但现实中确实有场景会逼你不得不用 Lettuce 连接池(比如需要把连接隔离给不同业务、或者历史代码就是这么写的),这时候网上"Lettuce 更快"的经验就直接失灵了。

我以为是没调优,结果只改善了一点点

发现这个反直觉结果后,第一反应是"是不是 Lettuce 连接池的默认配置有问题,没调好"。扒了一下 lettuce-core 的字节码,确实找到两处可以优化的地方:

  1. ConnectionPoolSupport 默认给每个借出的连接套了一层用不上的动态代理(为了让 close() 自动归还连接,但压测代码是手动管理生命周期的,这层代理纯粹白付费)。
  2. 更关键的:Lettuce 默认的 Netty I/O 线程数固定等于 CPU 核数,和你配的连接池大小完全没关系。本机实测是 14 个线程——池开到 100,实际处理这 100 条连接读写事件的还是这 14 个线程。连接数看起来给了"并行度",但背后调度这些连接的线程池根本没跟着变大。

抱着"这次应该能追上了"的期待,把这两处都改了(去掉不必要的代理、让 I/O 线程数跟着池大小走),重新跑了一遍完整矩阵。结果是:

配置(1000 线程)优化前优化后变化
Lettuce pool=813,06213,141+0.6%
Lettuce pool=3215,09215,071-0.1%
Lettuce pool=10017,07417,712+3.7%

改是真的改了,测试也都通过了,但提升幅度小到可以忽略,离追上 Jedis 的 34,438 还差得远。这说明 I/O 线程数不够只是个小问题,不是根本原因。

真正的原因大概率是架构性的、改不掉的:Jedis 拿到一条连接后,调用它的那个线程自己在自己的 socket 上同步读写,从头到尾不换线程。Lettuce 就算池化了,同步 API 底层还是"调用线程把命令扔进队列 → Netty 线程异步处理 → 处理完再唤醒原来那个调用线程"这一套。这次跨线程的入队和唤醒,在 1000 个线程同时发生的时候,调度成本是跟着线程数涨的,不是跟着 I/O 线程数降的——你加再多 I/O 线程,也省不掉"每次操作都要跨线程打一次招呼"这个固有开销。

这个解释目前是基于两轮独立压测数据加反编译源码得出的逻辑推断,还没有拿 profiler 抓火焰图直接证实"跨线程唤醒"就是耗时大头。所以严谨地说:"高并发下 Lettuce 连接池打不过 Jedis 连接池"这件事,已经被两轮压测复现,是事实;"为什么"的架构解释,目前是最合理的假说,但还不是最终结论。

回到起点——用 Spring 官方的封装,再测一遍

整篇文章是从"Spring Boot 官方把默认客户端换成 Lettuce"这件事讲起的。但前面测的一直是裸客户端(直接 new JedisPool、直接 RedisClient.create()),没有用过 Spring Data Redis 真正的官方封装——RedisTemplate + LettuceConnectionFactory/JedisConnectionFactory。如果结论要闭环,这一层也得测。

按同样的方法论加了两组策略:spring-lettuce(LettuceConnectionFactory 默认配置,即单连接,对应 Lettuce 官方推荐用法)和 spring-jedis(JedisConnectionFactory 配 8/32/100 三档池)。跑法完全一样,3 次取中位数,只是操作换成了 StringRedisTemplate.opsForValue().set()/get()

先看 Spring 封装的 Lettuce,和裸 Lettuce 单连接放在一起:

并发线程数裸 Lettuce 单连接Spring 封装 Lettuce差距
1039,37036,765-6.6%
10078,12575,873-2.9%
50038,41434,459-10.3%
100015,73015,459-1.7%

Spring 封装的开销很稳定,基本在个位数百分比,曲线形状和裸客户端几乎一样——该崩的地方(1000 线程)一样崩,不该崩的地方也没多崩。符合预期,毕竟 LettuceConnectionFactory 本质上就是薄薄一层包装。

但 Spring 封装的 Jedis 就不一样了:

并发线程数裸 Jedis pool=32Spring 封装 Jedis pool=32差距
1025,77330,864+19.8%(噪声)
10034,55437,230+7.7%(噪声)
50036,05924,190-32.9%
100028,64015,296-46.6%

低并发下 Spring 封装和裸 Jedis 打平甚至略有胜出(在噪声范围内),但一过 500 线程,Spring 封装的 Jedis 直接腰斩。pool=100 这一档同样的模式:1000 线程时裸 Jedis 是 34,438,Spring 封装只有 26,959,少了 21.7%。

也就是说,"Jedis 连接池在高并发下比 Lettuce 连接池快"这条结论,只在裸客户端层面成立;一旦套上 Spring 官方封装,Jedis 这边自己就先被削掉了小三分之一到接近一半RedisTemplate 每次操作都要走一遍连接获取/释放的模板方法(RedisConnectionUtils 的资源管理、异常转译这些中间层),这些开销在低并发下摊不出存在感,但 1000 个线程同时挤这条链路的时候,叠加出来的成本就藏不住了——和前面 Lettuce 连接池的故事是同一个道理:并发量拉满之后,任何一层不是"直接算了就完事"的中间抽象,都会开始收利息。这个解释同样没有用 profiler 验证到具体是哪一步在收费,是观察到的现象,不是钉死的结论。

尾声:为什么网上没说全

回过头看,这不是一个"网上文章都在骗人"的故事,而是一个"结论对,但前提被省略了"的故事——具体怎么选,前面已经按场景列过了。网上铺天盖地说 Lettuce 更快,大概率是因为大多数人的应用并发量根本到不了让单连接排队的量级,再加上对比时经常拿"没配好池的 Jedis"去比,再加上 Spring 官方切换默认客户端这件事被简化成了纯性能结论——每一条单独看都不算错,叠在一起就成了一个不完整的共识。真要在自己的系统里做选型,还是得看清楚自己的并发量落在哪个区间、用不用 Spring 封装,而不是抄一个"更快"的结论。

复现方法

完整代码、压测脚本和本文数据都在 github.com/1919chichi/…

docker-compose up -d
mvn compile exec:java -Dexec.args="500"   # 裸客户端完整矩阵,结果写入 results/benchmark-*.csv
mvn package -DskipTests && java -jar target/benchmarks.jar   # JMH 交叉验证
mvn compile exec:java -Dexec.mainClass="com.redisbench.SpringMain" -Dexec.args="500"   # Spring 封装对照,results/benchmark-spring-*.csv

局限性说在前面:单机本地环境,没做网络隔离,也没有多次独立 JVM 冷启动取平均,数值代表的是相对趋势和拐点位置,不是生产环境的绝对性能。只测了简单 SET/GET,Pipeline 和大 value 场景不在这次范围里。