慎用Redisson的stream流

404 阅读4分钟

背景

最近在写代码的时候,有一个方法是使用Redisson,通过stream流读取一个Redis的List结构中的内容并返回一个Java的List,但是这个过程中居然报了npe(空指针异常),代码如下:

public List<Message> getMessages(String userId) {
        String key = getKey(userId);
        return redissonClient.getDeque(key).stream()
            .map(item -> JacksonUtils.deserialize(item.toString(), Message.class))
            .toList();
}

报错的描述是item为null

问题分析

这个方法其实就是获取用户聊天的上下文。在这之前还有另一个方法,将用户的聊天信息存入上下文,给出完整的代码如下:

public class Cache {
    public List<Message> cacheAndGetMessages(String userId, String text) {
            Message userMsg = new Message("user", text);
            addMsgCache(userId, userMsg);
            return getMsgCache(userId);
    }

    public void addMsgCache(String userId, Message message) {
            String key = getKey(userId);
            RDeque<String> messages = redissonClient.getDeque(key);
            messages.addLast(JacksonUtils.serialize(message));

            int msgCacheSize = 15// 通过lua脚本原子删除多余消息,防止删除时出现线程安全问题
            deleteExtraMessages(msgCacheSize, key);
        
            // 历史记录保留三个小时
            redisUtils.expire(key, 60 * 60 * 3 * 1000, TimeUnit.MILLISECONDS);
    }

    public List<Message> getMessages(String userId) {
            String key = getKey(userId);
            return redissonClient.getDeque(key).stream()
            .map(item -> JacksonUtils.deserialize(item.toString(), Message.class))
            .toList();
    }
}

从代码来看,cacheAndGetMessages方法做了两件事,缓存新的message,获取所有的message。其中缓存新的message时,因为我设置了一个上下文的最大值,也就是msgCacheSize的值为15,当超出这个阈值就会将多余的message删除。

为什么getMessages方法中item会为null,先说结论,是因为出现了线程安全问题

并发安全问题的根源

由于cacheAndGetMessages方法不是原子的,所以某次用户连续发了两条消息,就有可能会出现以下情况:

image.png

图中红色的我称为线程A,蓝色的称为线程B。也就是有这样一种可能,当线程A向List中加入一个message后,并且删除多余的message前,线程B恰好使用代码中的stream流遍历这个List。但是这样为什么会出现npe呢?

深入分析

报错后我去查看Redis的这个List,发现并没有真正为null或空的元素。抱着一定要弄明白的心态,我开始怀疑是stream流底层执行的redis命令的问题。

刚开始,我单纯的以为这段代码会将Redis中的List的所有元素加载到内存中并遍历,后来我突然想起来,java的stream流本身是不会存储数据的,于是我便想通过代码查看这个stream流底层执行的redis的命令,可是Redisson封装的太好了,我没能找到(希望读到这篇文章的读者有知道的可以在评论区留言)。

再接着,我想到了Redis有一个命令,可以监控每一个执行的Redis命令,就是MONITOR命令(注意不要在生产环境使用)。为了输出好看,我在我的项目中随便找了一个有@Component修饰的类中加入了以下代码:

@PostConstruct
public void init() {
    new Thread(() -> {
        try (Jedis jedis = new Jedis(redisHost, redisPort)) {
            jedis.auth(redisPassword);
            jedis.monitor(new JedisMonitor() {
                @Override
                public void onCommand(String command) {
                    if (command.contains("message:1")) {
                        System.out.println("Command: " + command);
                    }
                }
            });
        } catch (Exception e) {
            e.printStackTrace();
        }
    }).start();
}

然后我单独执行getMessages方法,输出如下:

image.png

可以看到,stream流本质上是先获取List的长度,然后再通过一个循环逐个获取这个长度以内的集合中的元素。这样就可以解释为什么会有并发安全问题了:线程A添加一个新的元素到集合中后,线程B准备开始获取集合中的所有元素,首先执行LLEN命令获取集合大小,得到16,接着线程A将多余的message删除了,集合真正的大小也变成了15,当线程B执行LINDEX messages:1 15,也就是获取第16个元素时,就会返回null了。

解决方案

  1. cacheAndGetMessages方法入口对userId加分布式锁(互斥锁),但是因为互斥锁会阻塞,影响性能,不推荐。
  2. 使用迭代器遍历。使用迭代器不用先获取List的大小,每遍历一个元素会通过hasNext()判断是否有下一个元素,但是这个场景下也可能会多读取元素。
  3. 推荐:上面几种方案都需要发送多个redis命令,改为使用LEANGE messages:1 -15 -1获取尾部的15个元素。

总结

在多线程环境下,使用Redis的集合时,如果代码中存在对这个集合的增删改操作,应尽量避免使用stream流遍历这个集合,对于java中的普通集合也是同理。