最近在生产环境中有一个业务场景,保存用户最近操作的n条数据,在之前的实现中,使用的Redis的 key-value,其中value保存的是使用json进行序列化的数据的集合,在测试过程中偶发数据丢失问题,最后排查是因为并发修改,导致数据丢失。
问题描述
在保存用户最近操作数据的场景中,容易想到使用 LRU(Least Recently Used)算法。但是,LRU 的节点 + Map 的实现(如 LinkedHashMap)在 Redis 中并没有直接的实现。若使用 Java 中的数据结构,会如同之前的 key-value 一样,存在并发问题。
解决方案
基于业务功能和并发的考虑,决定使用 SortSet。以操作数据的时间戳作为 score,需要保存的数据作为 value,同样可以实现该功能,时间复杂度为 O(log(n))。虽然比不上 LinkedHashMap 的 O(1),但考虑到针对每个 key,保存的数据量不会太大,在性能方面不会存在问题。
解决并发问题的方法
在业务功能满足的条件下,需要解决并发问题。若使用原生命令,在执行完添加操作后,需查询当前 set 的大小,若比设定的目标值大,则删除下标靠前的元素。然而,在并发场景下,会存在问题。例如,设定最大值为 5,当前 set 中已有 4 个元素,现并发三个请求。第一个请求保存完后,在查询 set 大小之前,第二个请求也保存了数据。此时,第一个请求会判断当前超过目标值而进行删除,在删除之前,第二个请求也查询出当前 set 的大小,也会进行删除,同理,第三个请求可能也会删除。这样会导致数据的重复删除,且并行请求越多,额外删除的数据也越多。
针对此情况,有两种解决办法:
- 方法一:在添加数据的地方,不再进行数据的删除,而是维护一个
key的集合,使用定时任务定时检查数据,来删除多余的数据。 - 方法二:使用
Lua脚本,原子性地进行数据的维护。本次采用的是使用Lua脚本的方式。
Lua脚本详情
private static final String ADD_RECORD_SCRIPT =
"local key = KEYS[1] " +
"local member = ARGV[1] " +
"local score = ARGV[2] " +
"local maxSize = tonumber(ARGV[3]) " +
"redis.call('ZADD', key, score, member) " +
"local size = redis.call('ZCARD', key) " +
"if size > maxSize then " +
" redis.call('ZREMRANGEBYRANK', key, 0, size - maxSize - 1) " +
"end " +
"return 1";
加载Lua脚本
private void initScript() {
try {
saveScriptSha = cluster.scriptLoad(ADD_RECORD_SCRIPT);
log.info("加载lua脚本成功: {}", saveInterviewScriptSha);
} catch (Exception e) {
log.error("加载lua脚本失败", e);
throw new IllegalStateException("加载lua脚本失败", e);
}
}
执行Lua脚本
public void saveSingleRecord(String key, String value) {
long score = System.currentTimeMillis() / 1000;
int count = 0;
while (++count <= MAX_TRY_COUNT) {
try {
cluster.evalsha(saveScriptSha, Collections.singletonList(key), Arrays.asList(value, score + "", MAX_SIZE + ""), false);
return;
} catch (JedisNoScriptException jedisNoScriptException) {
log.error("脚本不存在: {} {}", key, value, jedisNoScriptException);
initScript();
} catch (Exception e) {
log.error("保存失败: {} {}", key, value, e);
throw new RuntimeException(e);
}
}
throw new RuntimeException("保存失败");
}
在执行的时候唯一一点需要注意的是脚本可能因为服务重启或者执行flush命令等原因,导致脚本缓存清除,所以当我们出现脚本不存在的异常的时候,进行重试。