本作品采用知识共享署名-非商业性使用 4.0 国际许可协议进行许可。
在互联网服务中,特别是在云环境下,网络及硬件环境复杂,所有应用程序都可能遇到暂时性故障。暂时性故障包括瞬时的网络抖动,服务暂时不可用,服务繁忙导致超时等,这些故障通常可以自我修复,在间隔一定时间后重试,则很大概率可以成功。
为什么会出现暂时性故障
云 Redis 集群的高可用
云 Redis 集群或者云环境中的很多产品,都是由大量机器组成的集群提供服务,机器可能发生各种各样的硬件故障,比如 CPU,内存,磁盘 都可能发生故障。当故障发生时,Redis 服务会自动做 HA (High Available),切换备用节点上来,客户端则可能遇到连接闪断,节点只读 (主动 HA 流程需要对主禁止写入,从而让数据完整同步到备)等暂时性故障。
客户已知的慢查询瞬间打满Redis
如果 Redis 在定期执行一些已知的慢查询,比如(keys, hgetall, smembers)等复杂度为 O(N) 的操作,则可能导致其余 O(1) 复杂度的 API 出现暂时性失败。
复杂的网络环境
云上网络环境复杂,客户端和服务器之间的网络状况会不时改变,偶现网络抖动,数据包重传等问题。
重试的准则
适当的重试次数与间隔
必须优化重试次数与间隔。如果重试次数不足或者间隔太长,应用程序无法完成操作,并且可能失败。如果重试次数太多或者间隔太短,则可能会增大程序对系统资源的占用,且有可能造成服务端堆积太多请求无法恢复。确定适当的重试间隔是最困难的部分,常见的重试间隔如指数退让、增量间隔、固定间隔[1]等。
避免重试嵌套
大多数情况下,应该避免重试嵌套,嵌套容易造成反复重试且无法停止。
记录重试过程异常,但只有最终失败被报告
重试过程中,建议按照 WARN 级别打印重试的错误日志,但是只有最终重试失败时候才抛出 Exception,重试过程中如果能完成请求的正确访问,则不会打扰到用户。
超时之后不一定就可以重试
超时是一种未决现象,配置超时时间 2s,那么客户端通常的行为是当命令发送之后开始计时,如果在 2s 内没有成功收到回复,则客户端抛出 Timeout Exception。但此时服务端有没有执行过命令呢?是未决的,因为超时可能发生在任何阶段:
- 命令被客户端发出,但是还没有到达 Redis
- 命令到达 Redis,但是执行时候超时
- 命令执行结束,但是结果返回客户端时候超时
因此,对于有的命令,超时之后就不一定可以重试,是否可以重试的准则是命令执行是否幂等。
幂等操作才能重试
需要确定重试的操作是否为幂等的,例如:
- set a b,就是幂等的,多次执行最终 a 的值只可能为 b 或者失败。
- lpush list a,就不是幂等的,如果多次 lpush list a,则 list 中可能包含多个 a 元素。
Redis 常见客户端如何做重试
Redis 客户端开源生态繁荣,几乎各个编程语言都有对应的 Redis 客户端,Redis 官网(https://redis.io/clients)列出了很多 Redis 客户端,下面我们就常见的几个客户端讨论如何做重试。
Jedis
如果是 JedisCluster 模式,可以通过配置 maxAttempts 参数来定义失败情况下的重试次数,默认值为 5。此参数实际上是为了处理 Redis Cluster 集群路由变更时,客户端更新路由表用的,但是也可以用来作为 API 访问失败重试的控制参数。
如果是普通 JedisPool 模式,Jedis 本身不提供重试功能,因此我们通过 alibabacloud-tairjedis-sdk 封装了一个 Jedis 的重试类,可以方便你完成 Jedis 的重试操作。alibabacloud-tairjedis-sdk 是基于 Jedis 封装的,可以访问 Redis 社区版,并支持操作 Redis企业版 的客户端,支持多种 Module 的操作命令。
1,添加依赖
<dependency>
<groupId>com.aliyun.tair</groupId>
<artifactId>alibabacloud-tairjedis-sdk</artifactId>
<version>latest</version>
</dependency>
2,下面的代码会将 set key value 命令自动重试5次,且总的重试时间不超过10s,每次重试之间等待类指数间隔的时间,如果最终不成功,则抛出异常。
int maxRetries = 5; // 最大重试次数
Duration maxTotalRetriesDuration = Duration.ofSeconds(10); // 最大的重试时间
try {
String ret = new JedisRetryCommand<String>(jedisPool, maxRetries, maxTotalRetriesDuration) {
@Override
public String execute(Jedis connection) {
return connection.set("key", "value");
}
}.runWithRetries();
} catch (JedisException e) {
// 表示尝试 maxRetries 次或者到达了最大查询时间 maxTotalRetriesDuration 仍旧没有访问成功
e.printStackTrace();
}
Lettuce
Lettuce 本身不提供命令超时之后可以重试的参数,但是 Lettuce 有两种命令执行可靠性选项:
- at-most-once execution:最多执行一次,即此命令执行零次或者一次,如果连接断开重连,则可能会丢失。
- at-least-once execution(默认):最少执行一次,即使链接重连,也会将命令再次执行。
上面的两种选项都是通过 autoReconnect 这个客户端选项控制的:
clientOptions.isAutoReconnect() ? Reliability.AT_LEAST_ONCE : Reliability.AT_MOST_ONCE;
autoReconnect 默认是 true,即默认为 at-least-once,这个选项会带来的一个问题是,如果 Redis HA 之后,客户端可能堆积满的重试命令发过来会造成 Redis CPU 尖峰,社区也有人反馈这个问题,作者也在考虑引入一种新的过滤机制,可以控制重连之后重试逻辑。
Redisson
Redisson 提供了重试次数 retryAttempts (默认为3次) 和重试间隔 retryInterval (默认为1500ms) 两个参数来控制重试逻辑,如果想更改,可以参考下面的代码:
Config config = new Config();
config.useSingleServer()
.setTimeout(1000)
.setRetryAttempts(x)
.setRetryInterval(x) //ms
.setAddress("redis://127.0.0.1:6379");
RedissonClient connect = Redisson.create(config);
StackExchange.Redis
StackExchang.Redis 目前客户端本身只支持 connect 时候的重试,可参考此。
var conn = ConnectionMultiplexer.Connect("redis0:6380,redis1:6380,connectRetry=3");
对于 API 层面的重试,可以通过 Polly 来完成。
总结
本文总结了 Redis 为什么需要做重试,以及重试应当遵循的准则。并举例了常见客户端 Jedis、Lettuce、Redisson、StackExchange.Redis 如何做重试,对于 Jedis,因为其用户使用量最大且没有成熟方案,因此在 alibabacloud-tairjedis-sdk 中提供了重试类让用户集成使用。
参考:
[1] Transient fault handling https://docs.microsoft.com/zh-cn/azure/architecture/best-practices/transient-faults