并发场景下使用Redis-SortSet + Lua脚本保存最近操作的n条数据

后端 潘老师 3个月前 (01-24) 50 ℃ (0) 扫码查看

在生产环境中,为了保存用户最近操作的 n 条数据,最初采用 Redis 的 key-value 结构,但因并发修改导致数据丢失。为解决此问题,改用 Redis 的 SortedSet,以时间戳作为 score,数据作为 value,实现类似 LRU 的功能,时间复杂度为 O(log(n))。为避免并发问题,采用 Lua 脚本保证操作的原子性。脚本通过 ZADD 添加数据,ZCARD 查询集合大小,并在超出限制时通过 ZREMRANGEBYRANK 删除多余数据。通过这种方式,成功解决了并发场景下的数据丢失问题,同时保证了业务功能的高效实现。

在最近的生产环境中,我们遇到了一个业务场景:需要保存用户最近操作的 n 条数据。最初,我们采用了 Redis 的 key-value 结构,其中 value 是通过 JSON 序列化的数据集合。但在测试过程中,发现偶尔会出现数据丢失的问题。经过排查,问题根源在于并发修改导致的数据覆盖。

问题分析

在保存用户最近操作数据的场景中,很容易想到使用 LRU(Least Recently Used) 算法。然而,Redis 中并没有直接实现 LRU 的节点 + Map(例如 Java 中的 LinkedHashMap)。如果使用 Java 中的数据结构,就会像之前的 key-value 方案一样,面临并发问题。

解决方案

综合业务需求和并发特性,我们决定使用 Redis 的 SortedSet(有序集合)。通过将操作数据的时间戳作为 score,需要保存的数据作为 value,可以实现类似 LRU 的功能,且时间复杂度为 O(log(n))。虽然稍逊于 LinkedHashMapO(1),但由于每个 key 的数据量不会太大,性能完全能够满足要求。

并发问题的解决

在满足业务功能的基础上,还需要解决并发问题。如果使用原生命令,执行添加操作后,需要查询当前 set 的大小。如果大小超过设定的目标值,则删除多余的元素。但在并发场景下,这种方法很容易出现问题。例如,设定最大值为 5,当前 set 中已有 4 个元素,此时并发三个请求:

  1. 第一个请求保存数据后,在查询 set 大小之前,第二个请求也保存了数据。
  2. 此时,第一个请求会判断当前 set 超过目标值并进行删除。
  3. 在第一个请求删除之前,第二个请求也查询了 set 的大小,并进行删除。
  4. 同理,第三个请求也可能进行删除。

这样会导致数据被重复删除,且并发请求越多,额外删除的数据也越多。
针对这个问题,我们有两种解决方案:

方法一:定时任务清理

不在添加数据时进行删除操作,而是维护一个 key 的集合,通过定时任务定期检查数据并删除多余的部分。这种方法简单易实现,但可能会导致数据在短时间内超出目标值。

方法二:使用 Lua 脚本

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 命令等原因被清除。因此,当出现脚本不存在的异常时,我们会进行重试。


版权声明:本站文章,如无说明,均为本站原创,转载请注明文章来源。如有侵权,请联系博主删除。
本文链接:https://www.panziye.com/back/13175.html
喜欢 (0)
请潘老师喝杯Coffee吧!】
分享 (0)
用户头像
发表我的评论
取消评论
表情 贴图 签到 代码

Hi,您需要填写昵称和邮箱!

  • 昵称【必填】
  • 邮箱【必填】
  • 网址【可选】