Redis毗连超时排查实录

打印 上一主题 下一主题

主题 904|帖子 904|积分 2712

记一次Redis超时

关键字:#spring-data-redis、#RedisTemplate、#Pipeline、#Lettuce
spring-data-redis:2.6.3
1 现象

时间轴(已脱敏)
day01 线上发现接口耗时不正常变高
day02 其他接口mget操作偶现超时,陆续发现其他Redis命令也偶尔出现超时(持续半个月)
day03 排查Redis无慢查询,毗连数正常,确认为批量写缓存导致
day04 尝试去除题目缓存,Redis超时消失,服务多个接口耗时降落50%~60%
day05 改进配置,重新上线,缓存正常,接口耗时波动不大
2 错误

2.1 spring-data-redis虚假的pipeline

需求:高频批量刷缓存,每个string key单独设置随机过期时间,单次批量操作上限为500。
spring-data-redis的multiSet不支持同时设置过期时间,但是spring-data-redis支持pipeline。
题目代码鉴赏
  1.     /**
  2.      * 批量缓存
  3.      * @param time base过期时间
  4.      * @param random 随机过期时间范围 1表示不增加随机范围
  5.      */
  6.     private void msetWithRandomExpire(Map<String, String> kv, long time, int random) {
  7.         RedisSerializer<String> stringSerializer = template.getStringSerializer();
  8.         Random rand = new Random();
  9.         template.executePipelined((RedisCallback<String>) connection -> {
  10.             connection.openPipeline();
  11.             kv.forEach((k, v) -> {
  12.                 long expireTime = time + rand.nextInt(random);
  13.                 connection.setEx(Objects.requireNonNull(stringSerializer.serialize(k)),
  14.                         expireTime, Objects.requireNonNull(stringSerializer.serialize(v)));
  15.             });
  16.             connection.closePipeline();
  17.             return null;
  18.         });
  19.     }
复制代码
测试发现redis毗连超时。
spring-data-redis采用的默认Redis客户端是Lettuce,Lettuce所有请求默认利用同一个共享毗连的实例,只有当执行事务/pipeline命令时会新建一个私有毗连。
执行单个Redis命令时,每收到一条命令,Lettuce就发送给Redis服务器,而pipeline必要将批量的命令缓存在内存,然后一次性发送给Redis服务器。
但是,查看LettuceConnection源码发现,Lettuce默认的pipeline刷入方式是FlushEachCommand,也就是每条命令都会产生一次发送举动。
利用pipeline的本意是避免多次发送带来的网络开销,所以spring-data-redis的pipeline是个伪批量操作,本质上和一条一条发送没有区别。
  1. // org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory#pipeliningFlushPolicy
  2. public class LettuceConnection extends AbstractRedisConnection {
  3.     // ...
  4.     private PipeliningFlushPolicy pipeliningFlushPolicy = PipeliningFlushPolicy.flushEachCommand();
  5.     // ...
  6. }
复制代码
2.2 Lettuce手动刷入的并发题目

spring-data-redis对Lettuce的封装存在缺陷,思量利用原生的Lettuce客户端实现pipeline。
Lettuce的毗连有一个AutoFlushCommands,默认是true,即收到一个命令就发到服务端一个。如果配置为 false,则将所有命令缓存起来,手动调用flushCommands的时间,将缓存的命令一起发到服务端,这样其实就是实现了 Pipeline。
在Lettuce官网找到了用异步方式实现的pipeline代码,参考官网样例后写出的题目代码如下:
  1.     public void msetWithRandomExpire(Map<String, String> kv, long time, int random) {
  2.         RedisConnectionFactory connectionFactory = redisTemplate.getConnectionFactory();
  3.         LettuceConnection connection = null;
  4.         RedisClusterAsyncCommands<byte[], byte[]> commands = null;
  5.         try {
  6.             Random rand = new Random();
  7.             connection = (LettuceConnection) RedisConnectionUtils.getConnection(connectionFactory);
  8.             commands = connection.getNativeConnection();
  9.             commands.setAutoFlushCommands(false);
  10.             List<RedisFuture<?>> futures = new ArrayList<>();
  11.             for (Map.Entry<String, String> entry : kv.entrySet()) {
  12.                 String k = entry.getKey();
  13.                 String v = entry.getValue();
  14.                 long expireTime = time + rand.nextInt(random);
  15.                 futures.add(commands.setex(k.getBytes(), expireTime, v.getBytes()));
  16.             }
  17.                         // 批量flush命令
  18.             commands.flushCommands();
  19.             LettuceFutures.awaitAll(5, TimeUnit.SECONDS, futures.toArray(new RedisFuture[futures.size()]));
  20.         } finally {
  21.             // 恢复自动刷入
  22.             if (commands != null) {
  23.                 commands.setAutoFlushCommands(true);
  24.             }
  25.             if (connection != null) {
  26.                 RedisConnectionUtils.releaseConnection(connection, connectionFactory);
  27.             }
  28.         }
  29.     }
复制代码
官方声称这样写,在50-1000个批量操作的区间内,吞吐量可以提高五倍,简直完善满足我的需求。
上线测试后确实非常快,500个SETEX命令可以在10ms内完成,没有再发生过Redis毗连超时的现象。
题目在于,AutoFlushCommands这个配置对于共享毗连是全局的,会影响到其他正在利用共享毗连的线程。
所以,Lettuce官方的建议是把这个操作放在一个私有毗连里进行,这样就不会影响到共享毗连中的命令。
The AutoFlushCommands state is set per connection and therefore affects all threads using the shared connection. If you want to omit this effect, use dedicated connections. The AutoFlushCommands state cannot be set on pooled connections by the Lettuce connection pooling.
spring-data-redis里执行pipeline命令,会先申请一个私有毗连,虽然它的刷入命令的战略有题目,但这个可以参考下。
翻了下Lettuce的API,发现通过getNativeConnection方法可以获取到私有毗连。
  1. connection = (LettuceConnection) RedisConnectionUtils.getConnection(connectionFactory);
  2. commands = connection.getNativeConnection();
  3. @Override
  4. public RedisClusterAsyncCommands<byte[], byte[]> getNativeConnection() {
  5.                 LettuceSubscription subscription = this.subscription;
  6.                 // getAsyncConnection()会返回一个私有连接
  7.                 return (subscription != null ? subscription.getNativeConnection().async() : getAsyncConnection());
  8. }
复制代码
研究到这里以为大功告成,由于用了比较取巧的写法,上线也观察了两天,并没有出现题目,直到第三天排行榜Redis莫名开始出现超时。
报错如下
  1. io.lettuce.core.RedisCommandTimeoutException: Command timed out after 1 minute(s)
复制代码
Github issue翻到一个老哥碰到了同样的题目,Lettuce作者的回答是
Switching setAutoFlushCommands to false is only recommended for single-threaded connection use that wants to optimize command buffering for batch imports.
Lettuce works in general in a non-blocking, multiplexing mode regardless of the API that you're using. You can use synchronous, asynchronous, any reactive APIs with the same connection.
That being said, if you don't touch setAutoFlushCommands, you should be good.
只保举在单线程的应用中利用setAutoFlushCommands来手动刷命令。Lettuce通常以非阻塞、多路复用模式工作,与利用什么API无关,不管是同步/异步/响应式API。如果你不碰这东西,就没事了。
作者跟官网说的有点矛盾,官网强调了只要在私有毗连里进行pipeline操作就不会影响到共享毗连,所以怀疑到底有没有正确获取到私有毗连。
回到Lettuce的API,getNativeConnection这个方法再点进去一层
  1.         RedisClusterAsyncCommands<byte[], byte[]> getAsyncConnection() {
  2.                 if (isQueueing() || isPipelined()) {
  3.                         return getAsyncDedicatedConnection();
  4.                 }
  5.         // 当共享连接不为空 返回一个共享连接
  6.                 if (asyncSharedConn != null) {
  7.                         if (asyncSharedConn instanceof StatefulRedisConnection) {
  8.                                 return ((StatefulRedisConnection<byte[], byte[]>) asyncSharedConn).async();
  9.                         }
  10.                         if (asyncSharedConn instanceof StatefulRedisClusterConnection) {
  11.                                 return ((StatefulRedisClusterConnection<byte[], byte[]>) asyncSharedConn).async();
  12.                         }
  13.                 }
  14.                 return getAsyncDedicatedConnection();
  15.         }
复制代码
原来getNativeConnection这个方法获取私有毗连是有条件的,只有当共享毗连被关闭时才会返回私有毗连。
而关闭共享毗连必要调用setShareNativeConnection(false)这个方法,这个配置同样是全局的,关闭后,所有的命令都会走私有毗连,这时必要用毗连池来管理Lettuce毗连。
到这里Redis超时的原因就找到了。
Lettuce官方在文档最后的QA里贴了一个出现RedisCommandTimeoutException的大概原因,最后一条是:【为什么要贴在最后…】
If you manually control the flushing behavior of commands (setAutoFlushCommands(true/false)), you should have a good reason to do so. In multi-threaded environments, race conditions may easily happen, and commands are not flushed. Updating a missing or misplaced flushCommands() call might solve the problem.
意思是,修改在AutoFlushCommands这个配置的时间必要注意,多线程情况中,竞态会频繁出现,命令将会阻塞,修改在不当的场景下利用手动刷入flushCommands大概会解决题目。
【以下为个人理解】
虽然在finally中恢复了自动刷入,但是在并发场景下,会有一些在AutoFlushCommands=false时执行的命令,这些命令将会被阻塞在本地内存,无法发送到Redis服务器。所以这个题目本质是网络的阻塞,通过info clients查询Redis毗连数正常,配置超时没有用,慢日记也查不到任何记录,干掉缓存的批量操作后,Redis终于正常了。
3 修复

在上面的条件下修复这个题目,必要三步
3.1 配置

3.1.1 Lettuce毗连池

所有命令走单独的私有毗连,必要用毗连池管理。
具体参数根据业务调解
  1. spring.redis.lettuce.pool.max-active=50  
  2. # Minimum number of idle connections in the connection pool.
  3. spring.redis.lettuce.pool.min-idle=5  
  4. # Maximum number of idle connections in the connection pool.
  5. spring.redis.lettuce.pool.max-idle=50  
  6. # Maximum time for waiting for connections in the connection pool. A negative value indicates no limit.
  7. spring.redis.lettuce.pool.max-wait=5000  
  8. # Interval for scheduling an eviction thread.
  9. spring.redis.pool.time-between-eviction-runs-millis=2000  
复制代码
3.1.2 关闭共享毗连

搭配spring-data-redis利用,关闭共享毗连
  1. @Bean
  2. public LettuceConnectionFactory lettuceConnectionFactory() {
  3.     LettuceConnectionFactory factory = new LettuceConnectionFactory();
  4.     factory.setShareNativeConnection(true);
  5.     // read config
  6.     return factory;
  7. }
复制代码
3.2 写法调解

3.2.1 用Lettuce API重写代码
  1.     public void msetWithRandomExpire(Map<String, String> kv, long baseTime, int random) {
  2.         RedisClient client = RedisClient.create();
  3.         try (StatefulRedisConnection<String, String> connection = client.connect()) {
  4.             Random rand = new Random();
  5.             RedisAsyncCommands<String, String> commands = connection.async();
  6.             // 关闭命令自动flush
  7.             commands.setAutoFlushCommands(false);
  8.             List<RedisFuture<?>> futures = new ArrayList<>();
  9.             for (Map.Entry<String, String> entry : kv.entrySet()) {
  10.                 long expireTime = baseTime + rand.nextInt(random);
  11.                 futures.add(commands.setex(entry.getKey(), expireTime, entry.getValue()));
  12.             }
  13.             // 手动批量flush
  14.             commands.flushCommands();
  15.             LettuceFutures.awaitAll(5, TimeUnit.SECONDS, futures.toArray(new RedisFuture[0]));
  16.         }
  17.     }
复制代码
3.2.2 spring-data-redis封装的flush战略

除了用Lettuce原生API实现之外,spring-data-redis也已经给pipeline封装好了三种flush战略。
PipeliningFlushPolicy也就是Lettuce的pipeline刷新战略,包罗默认的每个命令都刷入,一共有三种,基本上满足大部门业务场景。
  1.   /**
  2.     * org.springframework.data.redis.connection.lettuce.LettuceConnection.PipeliningFlushPolicy
  3.     * FlushEachCommand: 每个命令flush一次 默认策略
  4.     * FlushOnClose: 每次连接关闭时flush一次
  5.     * BufferedFlushing: 设置buffer大小 每达到buffer个命令刷一次 连接关闭时也刷一次
  6.     */
  7.         public interface PipeliningFlushPolicy {
  8.                 static PipeliningFlushPolicy flushEachCommand() {
  9.                         return FlushEachCommand.INSTANCE;
  10.                 }
  11.    
  12.                 static PipeliningFlushPolicy flushOnClose() {
  13.                         return FlushOnClose.INSTANCE;
  14.                 }
  15.    
  16.                 static PipeliningFlushPolicy buffered(int bufferSize) {
  17.                         Assert.isTrue(bufferSize > 0, "Buffer size must be greater than 0");
  18.                         return () -> new BufferedFlushing(bufferSize);
  19.                 }
  20.                 PipeliningFlushState newPipeline();
  21.         }
复制代码
设置pipeliningFlushPolicy=FlushOnClose之后,上面在2.1节提到的虚假的pipeline就成为真正的pipeline了。
  1. @Bean
  2. public LettuceConnectionFactory lettuceConnectionFactory() {
  3.     LettuceConnectionFactory factory = new LettuceConnectionFactory();
  4.     factory.setShareNativeConnection(true);
  5.     // 设置pipeline的flush策略
  6.     factory.setPipeliningFlushPolicy(LettuceConnection.PipeliningFlushPolicy.flushOnClose());
  7.     // read config
  8.     return factory;
  9. }
复制代码
4 思考

去除题目缓存后,服务所有带Redis缓存的接口平均耗时降落了一半,题目接口耗时稳固在5ms左右。
监控耗时对比非常夸张,这里不放图了,
修复题目后,接口耗时整体稳固,性能无明显提拔。
关于性能

Redis是单线程的,Lettuce也是单线程多路复用的。
现实上Lettuce在单线程状态下有着最佳的性能表现,采用线程池管理后,给系统引入了不必要的复杂度,Lettuce官方也吐槽大量的issue和bug来自多线程情况。
只有当事务/Pipeline等阻塞性操作较多时,主动放弃单线程的上风才是值得的。
否则,在并发没有那么高,甚至db都能hold住的场景,没有必要折腾Redis。
// TODO 性能测试
关于坏的技术

什么是坏的技术?(尤其是在引入新的技术的时间)

  • 研究不透彻的API:陌生的API,从入口到最底部的链路,随手调用一下到底走的是哪条,必要搞清晰
  • 脱离业务场景的:非必要引入的技术只会增长系统复杂度,带来负面影响。开发一时的自我满足是有害的
5 参考

[1] Lettuce文档 https://lettuce.io/
[2] Lettuce Github issue https://github.com/lettuce-io/lettuce-core/issues/1604
[3] lettuce 在spring-data-redis包装后关于pipeline的坑,你知道吗?
[4] 初探 Redis 客户端 Lettuce:真香!
[5] Lettuce毗连池

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

三尺非寒

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表