Spring Boot

延迟任务之redis key过期事件问题分析

2021-01-20  本文已影响0人  圣瓦伦

前言

业务中有类似等待一定时间之后执行某种行为的需求 , 比如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(使用时间轮实现的分布式延时队列)

上一篇下一篇

猜你喜欢

热点阅读