背景
最近在写代码的时候,有一个方法是使用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
方法不是原子的,所以某次用户连续发了两条消息,就有可能会出现以下情况:
图中红色的我称为线程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
方法,输出如下:
可以看到,stream流本质上是先获取List的长度,然后再通过一个循环逐个获取这个长度以内的集合中的元素。这样就可以解释为什么会有并发安全问题了:线程A添加一个新的元素到集合中后,线程B准备开始获取集合中的所有元素,首先执行LLEN
命令获取集合大小,得到16,接着线程A将多余的message删除了,集合真正的大小也变成了15,当线程B执行LINDEX messages:1 15
,也就是获取第16个元素时,就会返回null
了。
解决方案
- 在
cacheAndGetMessages
方法入口对userId加分布式锁(互斥锁),但是因为互斥锁会阻塞,影响性能,不推荐。 - 使用迭代器遍历。使用迭代器不用先获取List的大小,每遍历一个元素会通过
hasNext()
判断是否有下一个元素,但是这个场景下也可能会多读取元素。 - 推荐:上面几种方案都需要发送多个redis命令,改为使用
LEANGE messages:1 -15 -1
获取尾部的15个元素。
总结
在多线程环境下,使用Redis的集合时,如果代码中存在对这个集合的增删改操作,应尽量避免使用stream流遍历这个集合,对于java中的普通集合也是同理。