虚拟线程是JDK21最重要的、可能会成为JDK8的lambda这样标志性的特性。这里对虚拟线程做原理级别的系统性分析。
Redis使用zset存储在线车辆导致CPU100%的分析
近期发现Redis的内存使用率很低(1%),CPU反倒经常干到100%产生告警,需要分析原因。 
观察到两个规律:
- CPU100%的时间和工作时间相同,和工作触发的某些操作有关
- CPU高通常由主线程阻塞或高频操作导致,而内存低说明数据量非主因。需优先定位主线程的耗时操作。
继续观察耗时操作,这里我使用的是Redis官方客户端Redis Insight去分析,你也可以借助命令行、云厂商工具、AI去分析:
可以明显看出高耗时都是进行zrevrangebyscore操作导致的,key是online car,这是我们的在线车辆信息的缓存,用于查看在线车辆的最后上报时间、车辆元信息等数据。数据结构使用的是redisson的MapCache,底层是redis的zset。 问题就在于,zset有非常多的耗时操作,比如zadd、zrange、zrem,共同作用导致redis CPU升高。
利用滚动时间窗口代替zset
在线车辆的本质需求是:维护一个时间窗口,记录7天内上报过数据的最新去重对象,最大10000条,根据上报时间优先淘汰老数据。
Redis+时间窗口+根据时间淘汰旧数据,在《摇一摇匹配架构设计》这种方案中已经设计过很多次了,这里还多了一个需要处理的需求点:数据去重。
1、评估高峰时期数据量
查看在线车辆数峰值、存储数据结构的平均大小,来评估是否会产生bigkey:
- 如果会,需要拆分更细粒度的场次
- 如果不会,则保留简单场次设计
幸运的是,这里的数据量可以承受,因此只需要保留业务时间场次设计。
2、设计时间场次
将数据分为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,使用set和hash
时间穿越问题
我们经常需要重新解析前面某天的车辆数据,或者由于车辆嵌入式资源不足时间发生了穿越,导致新上报的数据时间更旧。 如上图,对于滚动时间窗口,最新的数据是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来触发过期操作,导致访问频率很高
解决方案:
- 使用二级缓存
- 更换
MapCache为Bucket,不限制缓存总数,毕竟内存使用率很低,且数量可控
解决效果
- Redis 请求数:219.35K -> 39.42K 下降明显
- Redis CPU:100% -> 33.11% 下降明显
- Redis 内存:290MB -> 292.47MB 无明显变化
- 二级缓存占用POD内存:无明显增长
总结
Redisson的MapCache的确很好用,能过期、能排序、能去重,但消耗也真的很高,生产环境慎用。



粤公网安备44030002014216号