前言

最近发现公司内部很多同事利用redis搭配定时器做了一些类似消息中间件的功能。一面生产一面消费。不过每个人都有各自的做法,于是对针对每种做法做了一些总结。


提示:以下是本篇文章正文内容,下面案例可供参考

一、利用Hash数据结构,搭配xxljob方式?直接上代码

生产者

业务代码省略
stringRedisTemplate.opsForHash().put(com.kyexpress.vms.adapter.provider.constant.Constants.VIDEO_CUT_OUT_RECORD_REDIS_KEY, videoCutOutBO.getThirdNo(),
                    JsonUtils.deserializer(videoCutOutBO));

消费者 部分代码

@Scheduled(cron = "0/30 * * * * ?")
    public void execute() {
        if (RedisTemplateLockUtil.tryGetDistributedLock(stringRedisTemplate, VIDEO_CUT_OUT_STATUS_LOCK_KEY, VIDEO_CUT_OUT_STATUS_LOCK_VALUE, 60 * 1)) {
            logger.info("queryVideoCutOutStatus#execute获取锁成功");
            try {
                long beginTime = System.currentTimeMillis();
                executeQueryVideoCutOutStatus();
                logger.info("queryVideoCutOutStatus execute total time:{}", System.currentTimeMillis() - beginTime);
            } catch (Exception e) {
                logger.error("queryVideoCutOutStatus#execute出错:", e);
            } finally {
                RedisTemplateLockUtil.releaseDistributedLock(stringRedisTemplate, VIDEO_CUT_OUT_STATUS_LOCK_KEY, VIDEO_CUT_OUT_STATUS_LOCK_VALUE);
            }
        }
    }

    public void executeQueryVideoCutOutStatus() {
        List<Object> objectList = stringRedisTemplate.opsForHash().values(Constants.VIDEO_CUT_OUT_RECORD_REDIS_KEY);
        省略
stringRedisTemplate.opsForHash().delete(Constants.VIDEO_CUT_OUT_RECORD_REDIS_KEY, 
videoCutOutBO.getThirdNo());

从代码可以看出,这里就是通过Hash结构作为存储,搭配定时任务做消费。定时器的业务时间是30
秒,但是有眼尖的同学应该可以看到定时器的入口加了一把分布式锁。这是为什么呢?

思来想去,那可能就是怕30秒执行不完业务代码,带来重复消费的问题把,因为key是在最后被删除掉的。

二、利用List结构,搭配while死循环方式

生产者

stringRedisTemplate.opsForList().leftPush(Constants.VIDEO_NAV_UPLOAD_REDIS_KEY, JSON.toJSONString(videoNavBO));

消费者1

@Override
    public void run(String... args) throws Exception {
        String redisLstKey = com.kyexpress.vms.adapter.provider.constant.Constants.VIDEO_WAY_BILL_UPLOAD;
        while(true){
            try {
                //视频导航上传到腾讯云
                String videoNavRedisListKey = com.kyexpress.vms.adapter.provider.constant.Constants.VIDEO_NAV_UPLOAD_REDIS_KEY;
                Long videoNavRedisListSize = stringRedisTemplate.opsForList().size(videoNavRedisListKey);
                if(videoNavRedisListSize>0){
                    String videoNavString = stringRedisTemplate.opsForList().rightPop(videoNavRedisListKey);
                    logger.info("视频导航上传videoNavString:{}",videoNavString);
                    VideoNavBO videoNavBO  = JSONObject.parseObject(videoNavString, VideoNavBO.class);
                    videoNavExecutor.execute(new Runnable() {
                        @Override
                        public void run() {
                            try {
                                videoNavUploadService.uploadVideo(videoNavBO);
                            } catch (Exception e) {
                                logger.error("COSClient#uploadVideo#exception",e);
                            }
                        }
                    });
                }

//                logger.info("CosService#commandLine#run#redisLstSize="+redisSize);
                if (stringRedisTemplate.opsForList().size(redisLstKey) < 10) {
                    sleep(3000);
                } else {
                    sleep(1000);
                }
            } catch (Exception e) {
                logger.error("CosService#commandLine#runException="+e.getMessage());
            }

        }

这里通过while死循环的方式,去消费List中的数据,但是下面为什么加了一个睡眠呢 sleep(3000);
而且最长还睡了三秒,后来分析这是怕对cpu的资源造成消耗。值得注意的是,这里用到消费的命令是,stringRedisTemplate.opsForList().rightPop(videoNavRedisListKey)。

针对以上的写法,我稍微做了些修改,虽然不是很完美,但是可能对cpu的消耗减少很多:

ScheduledExecutorService scheduler =  Executors.newScheduledThreadPool(1);
        //视频导航上传到腾讯云
        String videoNavRedisListKey = com.kyexpress.vms.adapter.provider.constant.Constants.VIDEO_NAV_UPLOAD_REDIS_KEY;
        scheduler.scheduleWithFixedDelay(() -> {
            Long videoNavSize = stringRedisTemplate.opsForList().size(videoNavRedisListKey);
            if(videoNavSize > 0) {
                String videoNavString = stringRedisTemplate.opsForList().rightPop(videoNavRedisListKey, 3, TimeUnit.SECONDS);
                logger.info("视频导航上传videoNavString:{}", videoNavString);
                VideoNavBO videoNavBO = JSONObject.parseObject(videoNavString, VideoNavBO.class);
                try {
                    videoNavUploadService.uploadVideo(videoNavBO);
                } catch (Exception e) {
                    logger.error("COSClient#uploadVideo#exception", e);
                }
            }

        },1,1,TimeUnit.SECONDS);

这里就是利用了延时任务,以及阻塞消费的方式,无数据,挂起3秒钟。

总结

针对以上两种方式我稍微做了下总结,这两种方式,可能是我们优先想到的方式,但是仔细思考,都存在着一定的缺陷。
1.针对第一种方式,利用hash作为存储,搭配xxljob,并且融入了分布式锁。使用上依赖了很多的技术,两个字概括麻烦,本身一个很简单的功能,却做的这么麻烦,如果生产者一旦速度很快,30秒消费一次,是否会使我们的Hash造成大key,都存在一定的可能。
2.针对第二种方式,虽然去除掉了很多技术上的依赖,但是一旦消费失败,数据可能存在丢失的问题。虽然针对List结构给了我们一个消费并存储的到另一个数据结构的命令,但是实现太复杂。

如果利用上面两种方式去实现,反而不如直接利用rabbitmq。哈哈哈

接下来继续尝试redis是否有更好,更简单的命令呢

三、利用redis发布订阅模式

生产者:

redisTemplate.convertAndSend(channel, message);

消费者

public class RedisMessageListener implements MessageListener {
    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public void onMessage(Message message, byte[] pattern) {

        redisTemplate.getValueSerializer().deserialize(message.getBody());
        log.info(messageDto.getData()+","+messageDto.getContent());
    }
}

以上消费简单,快速,并且不需要介入定时器。
内部实现原理,其实就是存储了一个 以channel为key,以订阅者为链表的数据结构,虽然也会出现数据丢失,但是如果扩展起来方便。

四、利用redis stream

生产者

String result = redisUtil.addMap(redisKey, map);

消费者

@Slf4j
@Component
public class ConsumeListener1 implements StreamListener<String, MapRecord<String, String, String>> {
 
    @Autowired
    private RedisUtil redisUtil;
 
    private static ConsumeListener1 consumeListener1;
 
    @PostConstruct
    public void init(){
        consumeListener1 = this;
        consumeListener1.redisUtil=this.redisUtil;
    }
 
    @Override
    public void onMessage(MapRecord<String, String, String> message) {
        String stream = message.getStream();
        RecordId id = message.getId();
        Map<String, String> map = message.getValue();
        log.info("[不自动ack] group:[group-a] consumerName:[{}] 接收到一个消息 stream:[{}],id:[{}],value:[{}]", stream, id, map);
        consumeListener1.redisUtil.ack(stream, "group-a", id.getValue());
        consumeListener1.redisUtil.del(stream, id.getValue());
    }

————————————————

redis stream 是redis5.0之后出来的,增加了消费者组,ack,数据持久化等概念,简直跟kafka太像了。这里就不一一介绍了

针对以上的各个方式,你觉得哪个更合理呢?