问题背景
Redis 代理集群版流量模型如上图,客户端通过域名访问到 AliLB,这是一个 4 层的负载均衡,会把连接均匀地分发到后端的 proxy 上,理论上每个 proxy 上处理的客户端连接数应该相近。
如果 proxy 上出现负载不均,就可能出现一个 proxy 的 cpu 已经接近满的状态,但其他 proxy 还很空闲,用户的实际吞吐远低于集群的能力上限,但访问到高负载 proxy 的请求 RT 开始升高,导致业务受损。
导致 proxy 负载不均的原因通常有 2 类。
连接不均衡
- 早期 AliLB 调度算法采用了 WRR,这是一种带权重的调度算法,当后端 proxy 在增加或减少时,由于算法本身的问题会出现连接调度不均,目前换为 RR 调度算法后,该问题不再出现。
- 部分 proxy 重启。RR 算法下 AliLB 是轮训调度,不会考虑后端 proxy 上的连接数,所以当部分 proxy 重启后,重启的 proxy 连接数会变 0,后续新建连接数和其他 proxy 相同,总连接数会低于其他 proxy。但非主动重启的 proxy 占比较小,实际情况下还没有因为这种情况导致问题。
负载不均衡
- 应用可能使用 pipeline 或异步的方式在一个连接上发送大量请求,这导致处理该连接的 proxy 负载很高。该问题只能修改业务的访问代码来优化,将请求通过更多的连接分发来达到均衡。
除了上述已知原因导致的不均衡外,还有一个困扰了 1-2 年的连接不均衡问题。
该问题现象如下,在某一时刻,因为一台机器故障导致该机器上部署的 proxy RT 变高,或者因为瞬时的流量峰值导致其中一个 proxy RT 变高,从故障时间点开始该 proxy 上的连接数就逐步上升,负载越来越高,导致 RT 也变得更高,呈现一个雪崩的状态。
问题分析
怀疑 AliLB 连接分配不均
proxy 是被动接受新连接,连接数多于其他 proxy 肯定是分配过来的新连接更多。所以该问题首先猜测是 AliLB 调度不均匀。
但根据原理判断问题 proxy 更有可能出现到 AliLB 的健康保活失败,理论上应该调度过来的新连接更少才对,这和现象相反, 拉 ALB 相关同学分析后台日志并没有连接调度不均的情况。当时 proxy 的监控信息没有建连总数,问题排查阻塞了,只能增加日志继续观察。
怀疑客户端连接泄露
后来问题第二次出现了,这次从 proxy 日志看到问题时间段每个 proxy 上新建连接数确实是相近的,那么连接数不均衡只能是一个原因,就是问题 proxy 上断连的数量变少了。
但问题时间段 proxy 没有主动断连,所有的断连请求都是客户端发起,这就非常奇怪,客户端所有的连接的目的端地址都是指向 AliLB 的域名,对于客户端而言每个连接没什么区别,它怎么会保留问题 proxy 的连接而断开其他的。
思来想去得出一个结论,可能是客户端访问 RT 高的 proxy 时出现了超时异常,代码没有处理好异常导致连接泄露,于是问题 proxy 上的连接就越来越多。
该逻辑能够解释通,后续和业务方一起通过压测尝试复现过该问题,通过统计日志能够看到具体的客户端 ip 和 qps,但实际场景非常复杂,业务方有多个应用使用不同的模型访问 Redis,统计日志中没有找到明显的连接数、流量上升的客户端 ip,也没有找到客户端上连接泄露的具体代码。
所以很长时间的结论是,AliLB 调度新建连接是均匀的,proxy 是被动地建立和释放连接,客户端对所有连接是一视同仁的,可能是业务代码哪里超时后没有释放连接。
问题复现
排查另一个问题时发现 Jedis、lettuce 等客户端连接池默认管理策略是 LIFO,之前一直认为 Jedis 连接池是轮训调度,在内部讨论以及和业务方交流时从来没人质疑过这一点。LIFO 的调度策略本身是不均匀的,基于该策略考虑构造一个场景来复现该问题。
测试环境
服务端为 4 个 proxy 的 Redis 集群版,其中一个 proxy 增加了 200ms 延时。
客户端使用 Jedis 连接池来访问 Redis。
流量模型为每个客户端进程每秒 100 get 请求。
每 10 秒一次流量峰值,每秒 150 get 请求。
同时启动 4 个客户端进程。
测试代码
<dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>3.6.3</version> </dependency>
JedisPoolConfig config = new JedisPoolConfig(); config.setMaxIdle(200); config.setMaxTotal(200); config.setMinEvictableIdleTimeMillis(5000); config.setTimeBetweenEvictionRunsMillis(1000); config.setTestOnBorrow(false); config.setTestOnReturn(false); config.setTestWhileIdle(false); config.setTestOnCreate(false); JedisPool pool = new JedisPool(config, host, port, 10000, password); Semaphore sem = new Semaphore(0); for (int i = 0; i < 200; i++) { new Thread(new Runnable() { public void run() { while (true) { Jedis jedis = null; try { sem.acquire(1); jedis = pool.getResource(); jedis.get("key"); } catch (Exception e) { e.printStackTrace(); } finally { if (jedis != null) { jedis.close(); } } } } }).start(); } long last_peak_time = System.currentTimeMillis(); while (true) { try { long cur = System.currentTimeMillis(); if (cur - last_peak_time > 10000) { last_peak_time = cur; sem.release(150); } else { sem.release(100); } Thread.sleep(1000); } catch (Exception e) { } }
测试结果
问题 proxy 的连接数和流量逐步上升。
正常 proxy 的连接数和流量在下降。
现象分析
因为 Jedis 连接池默认参数设置了 LIFO 为 True,该模式下后归还的连接会放在队列头,后续被更高频的使用。
当流量峰值时,会扩充连接池的大小,这些连接会随机建立到 4 个 proxy 上,但因为问题 proxy 的 RT 高,连接到问题 proxy 的连接会更晚归还到连接池中,导致后续请求会优先访问到问题 proxy。而那些 RT 更低的 proxy 的连接因为更早归还到连接池中,被放到了队列尾部,在低峰期不会被使用,因此连接空闲过段时间就被自动释放了。
长期下来每次高峰期 Jedis 会扩一批连接,低峰期又将 RT 正常的 proxy 连接释放,最后大部分连接都会集中在问题 proxy 上,导致负载不均,对业务的影响也越来越大。
问题避免
还是上面的测试代码,但增加下面设置 ,此时 Jedis 会均匀地使用所有连接,再次测试的结果。
config.setLifo(false)
所有 proxy 的连接和流量都很平稳(分钟级监控把波动拉平了)。
但这样所有连接都会被使用,连接基本不会因为空闲被释放。
问题根因
Jedis、lettuce 客户端连接池使用:
org.apache.commons.pool2.impl.GenericObjectPool 管理,该对象池默认策略为 LIFO,这会导致访问慢的连接被放到队列头更高频地使用,而访问快的连接放到队列尾,空闲时被关闭,最终连接会向访问慢的 proxy 倾斜。
优化方案
对于有 proxy 的 Redis 集群版,建议设置 LIFO 为 false,这样每个 proxy 的负载更均匀,而且不会出现连接倾斜的问题。但该设置会导致连接很难处于空闲状态,总连接数可能会上升,对于连接数很多的应用需要具体评估。
对于直连的 Redis 集群版或主从版,建议设置 LIFO 为 true,这样会尽量复用连接,空闲连接能及时释放,有利于提升 Redis 性能。
目前 LIFO 的推荐设置不是固定的,客户端在不知道后端统计信息的情况下很难自动调节连接池策略来保持最优,比较好的方案是 proxy/Redis 和 客户端之间具备一种优雅关闭连接的协议,当服务端检测到负载不均、RT 异常时能通知客户端无损关闭连接,这样在连接数、负载、RT 上做动态平衡,优雅关闭连接的协议同样能减少变配、升级场景下对业务的影响。
来源 | 阿里云开发者公众号
作者 | 丕天