基于redis实现延时队列的一些思考

最近由于项目的需要原因,需要做一个延时队列,比如用户登录X秒后需要发送一些系统消息。或者要做一个小游戏,需要有操作超时检测,如果超时,则自动跳到下一个玩家操作。这些,都用到了定时检测,而又想到了redis有过期回调功能,所以打算使用redis的过期回调来实现这些功能。由于对于redis的过期回调不熟悉,导致踩了一些坑。

先大致介绍一下延时队列的实现方案:

  1. 基于redis的过期回调
  2. 基于redis的zset实现
  3. 定期轮询数据库
  4. DelayQueue
  5. 基于Rabbitmq、kafka等实现

这里主要介绍基于redis的实现。

基于redis的过期回调实现

主要的问题有:

  1. redis的惰性删除以及过期策略导致的过期不回调问题
  2. 多实例监听导致的重复消费问题
  3. 由于redis的key已经过期,所以无法收到value的值,需要把业务需要的字段加到key中

先贴实现的配置和代码。

yml配置

spring:
    redis:
        database: 1
        host: 127.0.0.1
        port: 6380
        password:
        jedis:
          pool:
            max-active: 32
            max-wait: 2000ms
            max-idle: 8
            min-idle: 0

监听类:


@Service
public class RichmanExpireCallBackListener implements MessageListener {

    private static final Logger logger = LoggerFactory.getLogger(RichmanExpireCallBackListener.class);
    @Autowired
    private RedisTemplate<String, String> stringTemplate;

    @Override
    public void onMessage(Message message, byte[] bytes) {
        String str = (String) stringTemplate.getValueSerializer().deserialize(message.getBody());
        logger.info("=========richmanExpireCallBack onMessage  str:{}", str);
       //实现过期回调业务

    }
}

配置类:


@Configuration
public class RedisConfig {

    @Autowired
    private RedisConnectionFactory redisConnectionFactory;
    @Autowired
    private RichmanExpireCallBackListener redisMessageListener;

    @Bean("stringTemplate")
    public RedisTemplate<String, String> stringTemplate(RedisConnectionFactory redisConnectionFactory, StringRedisSerializer stringRedisSerializer) {
        RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(stringRedisSerializer);
        redisTemplate.setValueSerializer(stringRedisSerializer);
        redisTemplate.setHashKeySerializer(stringRedisSerializer);
        redisTemplate.setHashValueSerializer(stringRedisSerializer);
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        return redisTemplate;
    }

    @Bean
    public StringRedisSerializer stringRedisSerializer() {
        return new StringRedisSerializer();
    }

    @Bean
    public ChannelTopic expiredTopic() {
        return new ChannelTopic("__keyevent@1__:expired");  // 选择1号数据库
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer() {
        RedisMessageListenerContainer redisMessageListenerContainer = new RedisMessageListenerContainer();
        redisMessageListenerContainer.setConnectionFactory(redisConnectionFactory);
        redisMessageListenerContainer.addMessageListener(redisMessageListener, expiredTopic());
        return redisMessageListenerContainer;
    }
}

单元测试:

@Autowired
private RedisTemplate<String, String> stringTemplate;

@Test
public void testRedis() {
    stringTemplate.opsForValue().set("test","1",5, TimeUnit.SECONDS);
    System.out.println("=================");
}

这里配置的redis是取的是1号数据库,需要过期回调的key都set进1号数据库里。在配置类里,监听的也是1号数据库的过期数据。当然监听类也可以同过继承KeyExpirationEventMessageListener类,重写onMessage(Message message, byte[] pattern)来实现监听redis的过期回调。但是KeyExpirationEventMessageListener类的源码里监听所有 db 的过期事件 keyevent@*:expired

,而我这边把需要监听的过期的key都存在了1号数据库,所以只需要监听1号数据库的即可,而不需要因为监听了所有的过期的key,自己还要进行一次匹配过滤。

以下粘贴一个介绍redis过期回调的参数配置的博客,来自https://blog.csdn.net/zhu_tianwei/article/details/80169900

redis自2.8.0之后允许客户订阅Pub / Sub频道, Redis 目前的订阅与发布功能采取的是发送即忘(fire and forget)策略, 所以如果你的程序需要可靠事件通知, 那么目前的键空间通知可能并不适合:当订阅事件的客户端断线时, 它会丢失所有在断线期间分发给它的事件,并不能确保消息送达。

事件类型

对于每个修改数据库的操作,键空间通知都会发送两种不同类型的事件消息:keyspace 和 keyevent。以 keyspace 为前缀的频道被称为键空间通知(key-space notification), 而以 keyevent 为前缀的频道则被称为键事件通知(key-event notification)。

事件是用  __keyspace@DB__:KeyPattern 或者  __keyevent@DB__:OpsType 的格式来发布消息的。
DB表示在第几个库;KeyPattern则是表示需要监控的键模式(可以用通配符,如:__key*__:*);OpsType则表示操作类型。因此,如果想要订阅特殊的Key上的事件,应该是订阅keyspace。
比如说,对 0 号数据库的键 mykey 执行 DEL 命令时, 系统将分发两条消息, 相当于执行以下两个 PUBLISH 命令:
PUBLISH __keyspace@0__:sampleKey del
PUBLISH __keyevent@0__:del sampleKey
订阅第一个频道 __keyspace@0__:mykey 可以接收 0 号数据库中所有修改键 mykey 的事件, 而订阅第二个频道 __keyevent@0__:del 则可以接收 0 号数据库中所有执行 del 命令的键。

开启配置

键空间通知功能耗费CPU,默认关闭。所以在使用该特性之前,请确认一定是要用这个特性的,然后修改配置文件,或使用config配置。相关配置项如下:

字符发送通知
K键空间通知,所有通知以 keyspace@ 为前缀,针对Key
E键事件通知,所有通知以 keyevent@ 为前缀,针对event
gDEL 、 EXPIRE 、 RENAME 等类型无关的通用命令的通知
$字符串命令的通知
l列表命令的通知
s集合命令的通知
h哈希命令的通知
z有序集合命令的通知
x过期事件:每当有过期键被删除时发送
e驱逐(evict)事件:每当有键因为 maxmemory 政策而被删除时发送
A参数 g$lshzxe 的别名,相当于是All

 

 

 

 

 

 

 

 

 

 

 

 

输入的参数中至少要有一个 K 或者 E , 否则的话, 不管其余的参数是什么, 都不会有任何通知被分发。上表中斜体的部分为通用的操作或者事件,而黑体则表示特定数据类型的操作。配置文件中修改 notify-keyspace-events “Kx”,注意:这个双引号是一定要的,否则配置不成功,启动也不报错。例如,“Kx”表示想监控某个Key的失效事件。也可以通过config配置:CONFIG set notify-keyspace-events Ex (但非持久化)

重复消费问题

配置好了之后,在测试环境跑起来,会发现能完美运行,于是开开心心的上了线。但是,上了正式环境之后,就会发现其实这个redis回调并没有那么完美。首先的第一个问题就是重复消费问题。在实现用户登录X秒后收到系统的消息的功能中,发布到生产后,发现有很多用户收到了多条重复的系统通知。一开始以为是dubbo的重试功能导致了多次调用导致的,因为之前就有过由于dubbo的重试机制导致了获取token时报获取重复失败的情况。所以这次一开始以为是dubbo重试机制导致的重复发送,但经过了排查,和禁止dubbo重试之后,还是有这样的情况。后来才发现是生产环境多实例部署导致的问题。

由于生产环境,是多实例部署,而测试环境是单实例部署,所以测试环境并没有出现重复消费问题。redis的过期回调,是所有的监听的实例,都会受到redis的过期回调通知,所以在多实例部署的情况下,会出现一个key过期后,多次执行了回调代码,导致了重复消费。在实现用户登录X秒后收到系统的消息的功能中,就发现用户收到多条重复的系统消息,就是由于重复消费导致的。所以,如果如果回调代码要求不能重复消费的,则可以通过把消费的key放到一个set里,执行回调时,先检测是否有别的实例已经消费了。或者通过分布式锁,来避免重复消费。

部分数据无回调

当你解决了重复消费问题之后,以为事情就结束了。但是没过多久,你就会发现怎么有部分的过期的key为什么没有执行回调。这里就涉及到了redis的过期回调机制和淘汰机制的问题。由于redis是采用了惰性删除机制的,当key过期的时候,并没有立刻被删除的。而redis的回调,是需要key被删除了才会产生回调的。所以key到了过期时间后,并不能保证一定会产生回调。

过期键的删除策略

如果Redis的一个键是过期的,那它到了过期时间之后并不会马上就从内存中被删除,而是会采用相应的删除策略。主要有三种删除策略:

  1. 立即删除
  2. 惰性删除
  3. 定时删除

立即删除是指,在设置键的过期时间时,创建一个回调事件,当过期时间达到时,由时间处理器自动执行键的删除操作。立即删除能保证内存数据的新鲜度,过期的键值会被马上删除,所占用的内存也会随之释放。但是这个策略会消耗cpu,在过期的key比较多的情况下,会占用比较多的cpu资源,将cpu的资源耗费在删除一些无关key的事情上。

惰性删除是指,redis的key过期时,不会立刻被删除,而是等待下一次查询,或者被使用时,才会检测到过期了,才会被删除。所以这个策略的缺点是很浪费内存。

定时删除是指,每隔一段时间程序就会根据内部算法,对Redis数据进行一次检查,删除里面的过期key。定时删除策略介于立即删除和惰性删除之间,比立即删除少耗费cpu资源,比惰性删除节省内存空间。清理算法会通过限制清理的频率和时长来减少对cpu的影响。定时删除会依次遍历所有db,从db里随机取出20个key,判断是否过期,如果过期则清除。若有5个以上key过期,则重复上面步骤,否则遍历下一个db。在清理过程中,如果超过了限定的时间,25%的cpu时间,就会退出清理过程。

Redis配置项hz定义了serverCron任务的执行周期,默认为10,即CPU空闲时每秒执行10次,每隔100ms执行一次。每次过期key清理的时间不超过CPU时间的25%,即若hz=1,则一次清理时间最大为250ms,若hz=10,则一次清理时间最大为25ms

这是一个基于概率的简单算法,基本的假设是抽出的样本能够代表整个key空间,redis持续清理过期的数据直至将要过期的key的百分比降到了25%以下。由于算法采用的随机取key判断是否过期的方式,故几乎不可能清理完所有的过期Key。
调高hz参数可以提升清理的频率,过期key可以更及时的被删除,但hz太高会增加CPU时间的消耗。

数据逐出策略

上面的是过期的数据的删除策略,redis中还有6种数据淘汰策略。redis中过期key的删除策略默认为定时删除+惰性删除,而定时删除也会有部分key没有被删除,长期以往,就会大量的过期的key堆积在内存,导致内存耗尽。所以当redis中使用的内存快达到了设置的限定内存时,就会使用相应的数据淘汰策略,来进行数据淘汰,腾出空间。

  1. noeviction:禁止驱逐数据,当内存不足时,写入操作会报错。一般都不会设置为这个。
  2. volatile-lru:移除最近最少使用的key,只从设置了过期时间的key里移除。
  3. volatile-random:在设置了过期时间的key里,随机移除某个key
  4. volatile-ttl:在设置了过期时间的key里,挑选将要过期的key进行淘汰
  5. allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
  6. allkeys-random:从数据集中任意选择数据淘汰

基于redis回调实现的延时队列的缺点

从上面的介绍来看,可以很明显的看到,基于redis回调实现的延时队列,并不能保证key过期时,一定会触发回调,存在一定的时间差。在一些场景中,比如玩游戏时,操作超时检测这种场景,这个方案就不适合。即key过期就一定执行回调业务的,对时效性有比较高的要求的场景,则不适用该方案。

基于zset + 定时任务的实现

延时队列还可以基于redis的zset结构+定时任务实现。将过期时间设为score,然后通过定时任务来扫描小于当前时间的数据,即过期的数据,然后批量进行业务处理。这个方案也是存在着一些问题的:

  1. 多实例部署时,就会有多个定时任务在跑,那么就会出现重复消费的情况。

  2. 重复消费可以利用分布式锁来进行互斥,使得只有一个实例在跑定时任务。那这样就容易出现单机跑任务,如果任务比较密集,会有一定的延迟。可以考虑利用多线程提升性能,但是还是避免不了单机的问题。

  3. 分布式锁不锁实例,只锁任务。即把锁的粒度调低,但是每个任务都需要获取锁,并且多实例还是会有任务的锁竞争,这些都是耗损性能的地方

  4. 利用zrem方法避免锁竞争。多实例执行定时任务获取过期数据,然后zrem成功的实例进行处理数据。这里有个明显的缺点,就是如果zrem成功后,但是程序运行失败了,则这个数据的处理就丢失了,也就没办法保证可用性。

  5. 定时任务需要多实例之间时钟同步。如果是实时性要求比较高的,机器之间出现了时钟的误差,那么就容易导致时间不精确。

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页