延迟任务之redis key过期事件问题分析
前言
业务中有类似等待一定时间之后执行某种行为的需求 , 比如30分钟之后关闭订单, 其中一种方式是使用Redis key的过期事件来执行延时任务 , 但是其实这是个大坑。
问题
项目中使用Redis key过期事件发现的几个问题:
1、 Redis key过期事件通知的滞后性;
2、 RedisClusterClient偶发过期事件消息丢失
3、 RedisCommandTimeoutException
一、滞后性
一般可能认为redis key过期之后马上就会产生一条过期事件消息,但实际情况并非如此, 因为Redis不能确保key在指定时间被删除 , 也就造成了通知的延期。
如何处理过期key
Redis官方对key过期有如下说明(https://redis.io/commands/expire#how-redis-expires-keys):
大意是: redis每秒执行10次检查,随机检查20个可能过期的key,然后把检查出来过期的key删除,如果超过25%的key是过期的,就重复执行上述过程。
换句话说,redis key过期时候并不一定会被马上删除,而是要等到检查到key过期了才会进行删除。
过期事件触发时机
key过期之后什么时候产生过期事件呢,官方说明如下(https://redis.io/topics/notifications#timing-of-expired-events):
也就是说,只有删除key之后才会产生过期事件,而不是key过期时间到了就产生过期事件。
验证
理论上key越多,导致的滞后就会越大。通过代码测试一下这个延迟可能有多大:
服务器环境: 开发环境,3个节点redis集群:
初始化key:
@Scheduled(cron = "0/5 * * * * ?")
public void initKeys(){
LocalDateTime now = LocalDateTime.now();
Random random = new Random();
LocalDateTime begin = now.withSecond(0).withNano(0);
//每5秒跑一次,每次加999个缓存
while(begin.getNano()<999){
//过期时间,随机加(0-10)分钟
LocalDateTime expireTime = begin.plusMinutes(random.nextInt(10)).plusSeconds(random.nextInt(60));
String key = dateTimeFormatter.format(now)+count.getAndIncrement() + "@" + dateTimeFormatter.format(expireTime);
redisTemplate.opsForValue().set(key, "a", Duration.between(expireTime, LocalDateTime.now()).abs());
}
log.info("initKeys job结束,-------count="+count.get());
}
获取key过期事件:
/**
* 监听redis key过期事件
*/
@Slf4j
@Component
public class RedisKeyExpireListener extends RedisClusterPubSubAdapter<String, String> {
private DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Override
public void message(RedisClusterNode node, String channel, String message) {
if(message.contains("@")){
LocalDateTime parse = LocalDateTime.parse(message.split("@")[1], dateTimeFormatter);
long seconds = Duration.between(parse, LocalDateTime.now()).getSeconds();
log.info("过期key:"+message+",滞后时间:"+seconds);
}
}
结果如下:
1w+key的滞后时间(大多在0-10s之间)
3W+key的滞后时间(大多在0-20s之间)
5W+key的滞后时间(大多在0-30s之间)
7W+key的滞后时间(大多在10-50s之间)
可以看到,key越大,滞后时间趋势是越大的。当key达到5w个时,滞后时间都可能超过100s,对业务影响会非常大。
二、 消息丢失
项目中使用的是RedisClusterPubSubAdapter,是lettuce框架组件。
直接看官方说(https://lettuce.io/core/release/reference/#pubsub.cluster):
发布场景:发布时候会进行广播
订阅场景:
Key事件通知只会在本节点,不会在集群广播
可以通过设置节点消息传播和NodeSelection API处理;
项目中配置已经开启了消息传播和监听所有的mater消息,但是之前在预发布环境出现过消息丢失现象,目前没找到其他原因
@Slf4j
@Component
public class RedisClusterSubscriber extends RedisPubSubAdapter {
private static final String EXPIRED_CHANNEL = "__keyevent@0__:expired";
@Autowired
RedisClusterClient clusterClient;
@Autowired
private RedisKeyExpireListener redisKeyExpireListener;
/**
* 启动监听,异步订阅
*/
@PostConstruct
public void subscribe() {
final StatefulRedisClusterPubSubConnection<String, String> pubSubConnection = clusterClient.connectPubSub();
//消息传播
pubSubConnection.setNodeMessagePropagation(true);
pubSubConnection.addListener(redisKeyExpireListener);
//选择所有master节点
final PubSubAsyncNodeSelection<String, String> masters = pubSubConnection.async().masters();
final NodeSelectionPubSubAsyncCommands<String, String> commands = masters.commands();
commands.subscribe(EXPIRED_CHANNEL);
}
}
三、RedisCommandTimeoutException
这个错误在服务中出现过几次,概率很小,网上很多博客说把过期时间设置大一点,并未解决;还是在Lettuce官方文档中找到了说明(https://lettuce.io/core/release/reference/#faq.timeout.blpop):
在回调方法中调用了同步阻塞的方法,比如通过RedisTemplate(底层也是委托的Lettuce)客户端查询缓存等操作,就会导致这个异常
查看代码,确实在RedisKeyExpireListener消息处理方法中执行了查询缓存的操作,查看错误日志确实是在查询redis缓存的地方产生的报错
解决方法也很简单,把任务处理方法改为异步即可
总结
用redis key过期事件做延迟任务还是很不靠谱的,可以用其他的替代方案:
1、 DelayQueue 延时队列
2、 定时任务
3、 Redis sorted set
5、 RabbitMQ 延时队列
6、 时间轮
下期讲讲Redisson的RedissonDelayedQueue(使用时间轮实现的分布式延时队列)