Skip to content

滚动时间窗口代替zset解决RedisCPU100%问题

虚拟线程是JDK21最重要的、可能会成为JDK8的lambda这样标志性的特性。这里对虚拟线程做原理级别的系统性分析。

Redis使用zset存储在线车辆导致CPU100%的分析

近期发现Redis的内存使用率很低(1%),CPU反倒经常干到100%产生告警,需要分析原因。 cpu100%.png

观察到两个规律:

  • CPU100%的时间和工作时间相同,和工作触发的某些操作有关
  • CPU高通常由主线程阻塞或高频操作导致,而内存低说明数据量非主因。需优先定位主线程的耗时操作。

继续观察耗时操作,这里我使用的是Redis官方客户端Redis Insight去分析,你也可以借助命令行、云厂商工具、AI去分析: slowlog.png 可以明显看出高耗时都是进行zrevrangebyscore操作导致的,key是online car,这是我们的在线车辆信息的缓存,用于查看在线车辆的最后上报时间、车辆元信息等数据。数据结构使用的是redisson的MapCache,底层是redis的zset。 问题就在于,zset有非常多的耗时操作,比如zadd、zrange、zrem,共同作用导致redis CPU升高。

利用滚动时间窗口代替zset

在线车辆的本质需求是:维护一个时间窗口,记录7天内上报过数据的最新去重对象,最大10000条,根据上报时间优先淘汰老数据。

Redis+时间窗口+根据时间淘汰旧数据,在《摇一摇匹配架构设计》这种方案中已经设计过很多次了,这里还多了一个需要处理的需求点:数据去重。

1、评估高峰时期数据量

查看在线车辆数峰值、存储数据结构的平均大小,来评估是否会产生bigkey:

  • 如果会,需要拆分更细粒度的场次
  • 如果不会,则保留简单场次设计

幸运的是,这里的数据量可以承受,因此只需要保留业务时间场次设计。

2、设计时间场次

time_bucket.jpg 将数据分为3个桶,分别为:

  • Before:上一个插满的桶,不可写入
  • Current:当前插入的桶
  • Next:逻辑上即将产生的桶

查询

Current+Before数据,内存排序后,limit 10000。

  • 由于Before>=10000,0<=Current<=10000,两者加起来必然能够过滤出10000个数据。

更新

  • 如果Current<10000,直接写入
    • 这里判断Current的size可以不用原子,节省性能,允许少量超过10000,因为我们解析车辆上传的数据能力也有限,业务上高峰时期的并发不多且可控,redis不会爆内存
  • 如果Current>10000,则原子更新Current的Idx,Current设置为Next,Before设置为Current,Next可以创建新桶,也可以复用过期的Before桶
    • Idx需要保证原子更新

删除

  • 创建新桶时删除旧桶,数据量大可以考虑使用unlink而不是del
  • expireTime兜底

去重

使用Redisson的RSet或者RMap数据结构。如果是原生组件如Jedis,使用sethash

时间穿越问题

time_travel.jpg 我们经常需要重新解析前面某天的车辆数据,或者由于车辆嵌入式资源不足时间发生了穿越,导致新上报的数据时间更旧。 如上图,对于滚动时间窗口,最新的数据是02.07、02.10、02.11; 对于zset方案,最新的数据是02.09、02.10、02.11。

这就是滚动时间窗口的限制——强依赖时间顺序性。有两种方案解决:

  • 方案1:给每个桶维护一个最小时间,新上报的数据不允许超过这个最小时间
    • 我们解析数据是异步的,时间上有着细微的混乱,这种最小时间太过于精确,会导致部分数据被拦截
  • 方案2:维护一个时间容忍度,比如1天,新上报的数据不允许超过这个时间
    • 通常“重新解析前面某天的数据”,时间上会超过1天,同时车辆时间穿越发生概率较小,因此最终采用了这个方案。

过渡

  • 数据记录7天(其实1天就够了,因为数据量大),但不对外提供查询
  • 测试查询结果是否一致,如果一致,切换到这个方案

伪代码

java
public class OnlineCarService {
    private final RedisManager redisManager;
    private final AppConfig appConfig;

    private static final int MAX_BUCKET_NUM = 10;

    public void updateOnlineTime(long eventTimestampMillis, OnlineCarInfoRd onlineInfoRd) {
        // 判断最小时间,防止旧数据插入导致滚动
        long minOnlineThresholdTime = System.currentTimeMillis() - appConfig.getOnlineThresholdTime().toMillis();
        if (eventTimestampMillis < minOnlineThresholdTime) {
            log.debug("[ONLINE CAR] ignore old online car meta: {}", DateUtils.formatHumanDateTime(eventTimestampMillis));
            return;
        }

        OnlineCarMeta meta = new OnlineCarMeta();
        meta.setCarId(onlineInfoRd.getCarId());
        meta.setManufacturer(Objects.toString(onlineInfoRd.getManufacturer(), ""));
        meta.setRegion(Objects.toString(onlineInfoRd.getRegion(), ""));
        meta.setLastTimestamp(eventTimestampMillis);

        RAtomicLong idx = redisManager.onlineCarMetaIdx();
        long currentIdx = idx.get();
        RMap<String, OnlineCarMeta> current = redisManager.onlineCarMeta(bucketIdx(currentIdx));
        if (current.size() < appConfig.getOnlineKeepMaxCount()) {
            // 当前集合未满,直接添加
            current.put(meta.getCarId(), meta);
        } else {
            if (idx.compareAndSet(currentIdx, currentIdx + 1)) {
                // 索引更新成功,添加到下一个集合,删除前一个集合
                RMap<String, OnlineCarMeta> next = redisManager.onlineCarMeta(bucketIdx(currentIdx + 1));
                next.put(meta.getCarId(), meta);
                redisManager.onlineCarMeta(bucketIdx(currentIdx - 1)).unlink();
                log.info("[ONLINE CAR] changed online car meta index {}({}) -> {}({})",
                        currentIdx, bucketIdx(currentIdx), currentIdx + 1, bucketIdx(currentIdx + 1));
            } else {
                // 索引更新失败,说明其他线程或机器已经更新了索引,仍然添加到当前集合,允许稍微超过最大数量
                current.put(meta.getCarId(), meta);
            }
        }
    }

    public List<OnlineCarMeta> getOnlineCars(long startTimeMs, long endTimeMs) {
        RAtomicLong idx = redisManager.onlineCarMetaIdx();
        long currentIdx = idx.get();
        RMap<String, OnlineCarMeta> current = redisManager.onlineCarMeta(bucketIdx(currentIdx));
        RMap<String, OnlineCarMeta> before = redisManager.onlineCarMeta(bucketIdx(currentIdx + MAX_BUCKET_NUM - 1));

        Set<OnlineCarMeta> allOnlineCars = new HashSet<>();
        if (before != null && !before.isEmpty()) {
            allOnlineCars.addAll(before.values());
        }
        if (current != null && !current.isEmpty()) {
            allOnlineCars.addAll(current.values());
        }
        return allOnlineCars.stream()
                .filter(meta -> meta.getLastTimestamp() >= startTimeMs && meta.getLastTimestamp() <= endTimeMs)
                .sorted(Comparator.comparingLong(OnlineCarMeta::getLastTimestamp).reversed())
                .limit(appConfig.getOnlineKeepMaxCount())
                .collect(Collectors.toList());
    }

    private long bucketIdx(long idx) {
        return idx % MAX_BUCKET_NUM;
    }
}

其他问题

除了前面的这个问题,还有其他很多地方使用了MapCache,导致Redis请求量大,或者后台执行LUA脚本次数多,从而CPU很高。通过阿里云平台(或者通过redis命令行)找一些热key来排查,比如:

  • hash {KEY_A}:redisson_options -> 36356
  • zset redisson__map_cache__last_access__set:{KEY_A} -> 18178
  • hash KEY_A -> 18178
  • hash KEY_B -> 18399
  • zset redisson__timeout__set:{KEY_A} -> 18178
  • zset redisson__timeout__set:{KEY_B} -> 18395

产生热key的原因:

  • 采用了MapCache,每次都请求,导致redisson_options等访问频率很高
  • 采用了MapCache,每次设置时,会利用zset来触发过期操作,导致访问频率很高

解决方案:

  • 使用二级缓存
  • 更换MapCacheBucket,不限制缓存总数,毕竟内存使用率很低,且数量可控

解决效果

  • Redis 请求数:219.35K -> 39.42K 下降明显
  • Redis CPU:100% -> 33.11% 下降明显
  • Redis 内存:290MB -> 292.47MB 无明显变化
  • 二级缓存占用POD内存:无明显增长

总结

Redisson的MapCache的确很好用,能过期、能排序、能去重,但消耗也真的很高,生产环境慎用。

转载请注明出处https://bananaoven.com/articles/58730.html | 香蕉微波炉
分享许可方式知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议
重大发现:转载注明原文网址的同学刚买了彩票就中奖,刚写完代码就跑通,刚转身就遇到了真爱。