Redis定长队列设计与实现

木҉马҉不҉是҉马 / 2023-05-04 / 原文

业务背景:
只展示最近10条礼物打赏动态,用户名+礼物名称
不管在app端还是在web端,或多或少都有这样的需求,所谓技术方案的选型都是受限于实际的业务场景的,都是以解决实际业务为目的,由于刚开始这样的需求还是比较少的,所以采用了简单的方式实现了功能,但是随着业务扩大,重复的也会很多,再写一套代码就显得很臃肿了 ,所以将这类业务进行抽象封装。

如上图实时展示了最近打赏的动态,通过这些来刺激消费和曝光用户,同时也是丰富页面,像这类动态不需要全部的数据,也不需要太多的历史数据,而是最近的10条数据,虽然数据量不是很大,但是要求实时性,所以需要有一个队列来存储这些数据,并且是有序的,由于这里项目用的RocketMQ与Redis,对比之后发现redis的效果更好也容易实现一些,利用redis的list结构来实现一个简单的动态消息定长队列,主要思路如下:
生产者:在队列里添加数据(rpush),每次往队列里添加数据的时候判断队列的总长度(llen),如果大于要求的长度10,则剔除第一个元素(lpop)
消费者:这里严格意义上不是消费者,只是读取数据可以全部读取,但是建议lrange(0, 10)来读取数据
以往粗略的实现方式为先获取长度(llen),再判断是否需要lpop,最后rpush来实现定长队列,保证队列长度的可控(因为多了也没用)
但是llen+lpop+rpush这个过程是非原子性的,所以这里采用lua脚本来保证原子性并且不用三次调用redis
list基本命令回顾

LLEN key
返回list的长度,时间复杂度O(1) 如果key不存在,则返回0,如果key非list类型,返回error

LPOP key [count]
删除并返回元素,时间复杂度为O(N),N为移除元素的个数,默认会返回第一个

RPUSH key element [element ...]
从list右侧插入元素,时间复杂度为O(N),N为插入元素个数,返回值为插入之后list的长度

整合上述三个命令,我们可以实现固定长度的队列,通过判断队列长度是否达到定长结合新增队列元素和移除队列元素来完成,整体命令的复杂度都是O(n)的常量时间
定义Lua脚本

local key = KEYS[1]
local num = tonumber(ARGV[1])
local val = ARGV[2]
if (redis.call('llen', key) >= num) then redis.call('lpop', key) end
redis.call('rpush', key, val)

这里整个key不设置过期时间,整个脚本也不返回值,默认成功

java代码封装

public interface IQueue<E> {
    /**
     * 入队操作
     * @param e 入队数据
     * @return
     */
    boolean offer(E e);

    /**
     * 出队
     * @return 一条队列数据
     */
    E remove();

    /**
     * 队列大小
     * @return
     */
    long size();
}

这里最好不要实现jdk的Queue,由于继承了Collection,所以会有很多无用的方法,所以自定义或者改造项目已有的队列接口

/**
 * redis定长队列
 *
 * @author liufuqiang
 * @since 2023/5/4
 */
public class RedisLimitQueue implements IQueue<String> {

    /**
     * redis操作模版
     */
    private RedisTemplate<String, String> redisQueueTemplate;

    /**
     * 存储key值
     */
    private String key;

    /**
     * 队列总长度
     */
    private Integer length;

    public RedisLimitQueue(RedisTemplate<String, String> redisQueueTemplate, String key, Integer length) {
        this.redisQueueTemplate = redisQueueTemplate;
        this.key = key;
        this.length = length;
    }

    private static final String LIMIT_OFFER_LUA =
            "local key = KEYS[1]" +
            "local num = tonumber(ARGV[1])" +
            "local val = ARGV[2]" +
            "if (redis.call('llen', key) >= num) then redis.call('lpop', key) end " +
            "redis.call('rpush', key, val)";

    public List<String> list() {
        return redisQueueTemplate.opsForList().range(key, 0, length);
    }

    @Override
    public boolean offer(String s) {
        DefaultRedisScript script = new DefaultRedisScript(LIMIT_OFFER_LUA);
        redisQueueTemplate.execute(script, Collections.singletonList(key), String.valueOf(length), s);
        return true;
    }

    @Override
    public String remove() {
        return null;
    }

    @Override
    public long size() {
        return redisQueueTemplate.opsForList().size(key);
    }
}

由于上述代码在公用项目里面供多个微服使用,所以在初始化的时候,按需引入,一般结合nacos动态配置队列的长度

@Bean(name = "cardLimitQueue")
	public RedisLimitQueue cardLimitQueue(@Qualifier("cacheQueueRedisTemplate") StringRedisTemplate cacheQueueRedisTemplate) {
		return new RedisLimitQueue(cacheQueueRedisTemplate, key, length);
	}

由此就完成了简单的定长队列,后续有不同的业务要求,只需新增一个key,就可以完成定长要求。
上述Lua脚本首先执行了llen,熟悉rpush命令的可能一眼能看出来,这个步骤略微有点多余,因为rpush的返回值就是插入元素之后list的长度,所以可以用下面写法代替,减少了llen的过程

这里加入要10条数据,上述脚本在并发的情况下可能返回11条数据,如果对数据总长度严格要求的话,上述写法不可取

*虽然这里简单实现了,但是还有些细节,就以文章开头的图片打赏礼物为例。假如第一条数据为用户张三打赏了A礼物,第二条数据第三条数据还是张三这个人,并且打赏的礼物还是A,那么这个时候出现了刷屏的现象,对别的用户不管是感官上还是页面呈现,大概率会出现不适的情况,争对这种情况,需要多的就是去重,由于使用list未具备去重的功能,这里如果不需要出现重复的数据,可以提供两种思路:
1、list实现的定长队列改为zset的zcard+zadd+zpopmin来实现,但是这里zset肯定没用list效果好
2、根据业务主键进行加锁,比如这里需要展示的数据更真实一些,采用用户ID+礼物ID作为主键的key来进行加锁,根据业务量来设置过期时间

总结:
本文主要探索在特定业务场景下通过Redis的原生命令实现类MQ的功能,创新式的通过Lua脚本组合Redis的List的基础命令,整体解决方案在线上环境落地并平稳运行,为特定场景提供了一种通用的解决方案。