美团面试:MySQL有1000w数据,redis只存20w的数据,如何做 缓存 设计?
本文 的 原文 地址本文 的 原文 地址
尼恩说在前面:
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、shein 希音、shopee、百度、网易的面试资格,遇到很多很重要的面试题:
[*]MySQL有1000w数据,redis只存20w的数据,如何做缓存的设计?
前几天 小伙伴面试美团,遇到了这个问题。但是由于 没有回答好,导致面试挂了。
小伙伴面试完了之后,来求助尼恩。那么,遇到 这个问题,该如何才能回答得很漂亮,才能 让面试官刮目相看、口水直流。
所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典》V145版本PDF集群,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,后台回复:领电子书
问题本质:需要一套 高明的 三级缓存架构
这是一位小伙伴在美团三面时遇到的问题。题目简单,考验的是对缓存 、高并发、三级缓存 的深度理解和灵活应用。
缓存架构时候,如何 把那些老被访问的数据留在缓存,把不怎么受待见的数据踢出去,让缓存能“活”得又准又久——这是我们设计的一个难题。
假设,1000万条数据搁 MySQL 里,每条占1KB,那得要20GB磁盘地儿。可 Redis 就给20GB内存,只能装20万条数据,这得削掉99%的数据。
难点,这就出来了:
(1) 成本卡得紧:Redis 内存贵啊,MySQL 磁盘便宜,咱得把钱花在刀刃上,把重要的数据供着。
(2) 流量不平均:互联网的流量就像瞬时的,抖动 厉害。
一般情况,20%的数据得扛80%的流量,有时候甚至10%的数据得顶90%的流量,比如那些秒杀商品 。(3) 热点说变就变:热点数据就像流行歌曲,有时效。秒杀商品火个24小时就凉了,或者像明星一撒狗粮,相关数据突然就爆了,咱得赶趟儿地跟着变。
比如说啊,大促的时候,电商那 TOP 100 的商品,访问量能占60%,可这些宝贝在数据库里才占0.05%。
问题本质:需要一套 高明的 三级缓存架构 。
要让 Redis 的 20 万数据都是热点,需构建需要一套 高明的 三级缓存架构。
尼恩带大家构建一套牛逼 的 缓存架构,这 就是 五大招: “ 冷热 分离+ 命中率治理+实时探测+多级防御+多维预热 ” 。
结合京东 HotKey 等工具主动识别热点,通过动态策略调整和精细化运维,最终实现缓存系统的 “精准生存”。
第一招: 冷热 分离
通过 MySQL 和 Redis 的“冷热分层”策略,能够有效 平衡 数据存储和访问效率,同时降低整体成本。
冷数据回归磁盘:
冷数据,通常指的是那些不经常被访问或者访问频率较低的历史数据、归档数据等。
MySQL 作为一种关系型数据库,擅长处理复杂的查询和事务操作,能够为冷数据提供可靠的存储和管理。
通过将这些冷数据保留在 MySQL 中,可以确保它们在需要时仍能被有效地检索和使用,同时避免了为这些数据占用昂贵的内存资源。
热点数据集中在内存:
热点数据则是那些访问频繁、对业务流程至关重要的数据,比如实时的用户会话信息、热门商品的库存数据等。
这些数据需要在短时间内被大量访问和快速处理,对响应时间有着极高的要求。
Redis将热点数据存储在内存中,利用内存的高速读写能力,极大地加快了数据的访问速度,从而提升了应用程序的整体性能,为用户提供实际体验的快速响应。
冷热 分离的核心, 在于对数据进行 热度的识别、统计,使得不同热度的数据,能够在最适合它们的存储环境中发挥作用。
**比如,可以在 MySQL中,对 数据 (如 products)的热度进行 热度记录 并且进行 索引 ** :
-- 添加热度评分字段(基于最近 7 天访问次数计算)
ALTER TABLE products
ADD COLUMN hot_score INT DEFAULT 0 COMMENT '热度评分(最近 7 天访问次数)',
ADD INDEX idx_hot_score (hot_score DESC);-- 按热度降序索引,加速热点查询
-- 定时任务更新热度评分(每日凌晨执行)
UPDATE products p
JOIN (
SELECT item_id, COUNT(*) AS cnt
FROM access_log
WHERE access_time >= DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY item_id
) l ON p.id = l.item_id
SET p.hot_score = l.cnt;对于 热点的 TOP N数据,可以定期进行Redis 预热 , 把Top N 商品加载到 redis中:
# 示例:存储商品 ID 为 1234 的热点数据
HMSET product:1234
data "{\"name\":\"iPhone 15\", \"price\":6999}"-- 商品详情(JSON 格式)
hot 1-- 热点标记(1 = 热点,0 = 非热点)
expire 1735689600-- 最终过期时间(时间戳)
score 1500-- 热度评分(用于动态排序)后面直接可以 通过 CacheAside 模式, 首先从redis 进行访问。
关于 CacheAside 模式,请参见 尼恩团队的文章 亿级流量,如何保证Redis与MySQL的一致性?操作失败 如何设计 补偿?
第二招:命中率 治理
命中率提升治理 ,就是设置有效的 Redis 的淘汰策略 ,直接决定哪些数据被淘汰、哪些被保留,提升 redis 命中率。
命中率提升治理 关键是,根据业务场景选择 最适配的淘汰算法 。
Redis支持8种不同策略来选择要删除的key:
noeviction: 不淘汰任何key,但是内存满时不允许写入新数据但报错, 默认就是这种策略。
volatile-ttl: 对设置了TTL(过期时间)的key,比较key的剩余TTL值,TTL越小越先被淘汰
allkeys-random:对全体key ,随机进行淘汰。
volatile-random:对设置了TTL(过期时间)的key ,随机进行淘汰。
allkeys-lru: 对全体key,基于LRU算法进行淘汰
volatile-lru: 对设置了TTL(过期时间)的key,基于LRU算法进行淘汰
allkeys-lfu: 对全体key,基于LFU算法进行淘汰
volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰
Redis 的默认缓存淘汰策略是 noeviction (不淘汰)。
当内存使用达到 maxmemory 设置的上限时,如果再有新的写入操作,Redis 会直接返回错误,不会主动淘汰已有数据来腾出空间。
适用于不允许数据丢失的场景,或者数据量不会超过 Redis 可用内存的情况。在这种策略下,可以确保所有写入的数据都得到完整保留,直到内存被占满后无法继续写入。
在 Redis 中,allkeys-lru 和 allkeys-lfu 是两种常用的内存淘汰策略,它们的原理和适用场景有所不同:
allkeys-lru
LRU(Least Recently Used)算法思想 :LRU 是一种经典的缓存淘汰策略,主要基于数据的访问时间。它的核心思想是认为最近被访问的数据更有可能在未来再次被访问,而很久没有被访问的数据则可以优先被淘汰。
Redis 使用近似 LRU(Approximated LRU)算法来实现 allkeys-lru 策略。由于精确的 LRU 实现需要较高的时间和空间复杂度,Redis 采用了对访问时间进行近似的方法。每个键都有一个访问时间戳字段(lru 字段),当键被访问时,这个字段会被更新为当前的时钟周期(以秒为单位)。当需要淘汰键时,Redis 会随机选择一组键,比较它们的访问时间戳,然后选择最久未被访问的键进行淘汰。
allkeys-lru适用于数据的访问模式存在明显的冷热分层,且冷数据一旦冷下来就很难再次变热的场景。例如,在一个用户会话缓存系统中,用户在一段时间内活跃,之后就很少再访问其会话数据,这种情况下可以使用 allkeys-lru 策略来淘汰长时间未被访问的会话数据。
allkeys-lfu
LFU 算法是基于数据的访问频率。它认为访问频率高的数据更有可能在未来继续被频繁访问,而访问频率低的数据可以优先被淘汰。
Redis 的 LFU 实现是在近似 LRU 的基础上进行了扩展。每个键除了有访问时间戳字段外,还增加了一个 LFU 计数器。当键被访问时,LFU 计数器会根据一定的规则(考虑访问时间和访问次数)进行更新。随着时间的推移,LFU 计数器会逐渐衰减,这样可以防止很久之前的一些高频访问数据一直占据较高的计数器值。当需要淘汰键时,Redis 会比较各个键的 LFU 计数器,选择计数器值最低的键进行淘汰。
适用场景 :allkeys-lfu 适合数据的访问模式中,某些数据虽然在近期访问时间不长,但长期来看访问频率一直较高。例如,在一个搜索引擎的缓存系统中,一些热门搜索关键词的搜索频次持续较高,而一些不热门的关键词搜索频次较低,使用 allkeys-lfu 策略可以更好地保留这些长期热门的关键词数据,避免因偶尔长时间未访问而被淘汰。
allkeys-lru和 allkeys-lfu的区别
淘汰依据不同 :
[*]allkeys-lru 主要依据数据的访问时间,淘汰最久未被访问的键。
[*]allkeys-lfu 主要依据数据的访问频率,淘汰访问频率最低的键。
各自适用场景 :
[*]allkeys-lru 更适合淘汰长时间未被访问的冷数据,适用于数据冷热分层明显且冷数据较难变热的场景。
[*]allkeys-lfu 更适合保留长期访问频率高的数据,适用于数据访问频率差异较大, 且需要考虑长期访问趋势的场景。
秒杀 建议使用 allkeys-lfu,为啥?
1、热点数据特征
秒杀场景中,特定商品会在短时间内被高频且集中访问(如瞬间数万次请求),属于典型的短时热点。
allkeys-lfu通过统计访问次数,能更精准锁定这些高频键并保留,避免被淘汰。
2、避免冷数据干扰
allkeys-lru依赖访问时间戳,可能因少量新写入数据(如库存更新)覆盖旧热点,导致秒杀商品被意外淘汰 。
而allkeys-lfu通过频率统计,对偶发操作敏感度更低。
3、参数调优建议
[*]lfu-log-factor 10(默认值)
降低高频数据的计数器增速,避免计数器快速饱和,增强热点区分度 。
[*]lfu-decay-time 1(默认1分钟)
加速长期未访问数据的计数器衰减,防止秒杀结束后旧数据残留 。
使用 allkeys-lfu进行秒杀数据淘汰
Redis 核心配置示例 :
// redis.conf 关键配置
maxmemory 20gb # 限制 Redis 最大内存(按 20 万条数据 × 1KB / 条计算)
maxmemory-policy allkeys-lfu# 优先淘汰访问频率最低的数据(LFU 算法)
lfu-log-factor 10 # 调整 LFU 计数器的增长速率(值越大,高频数据的计数器增长越慢,适合区分度高的场景)
lfu-decay-time 1 # 计数器衰减时间(单位分钟,值越小,长期未访问数据的计数器下降越快)以下是对这几个 Redis 参数的详细介绍:
1、 maxmemory
maxmemory 这个参数,用于设置 Redis 实例所使用的最大内存量。
当 Redis 使用的内存达到这个设定值后,就会根据指定的淘汰策略来释放内存,以确保 Redis 的正常运行,防止内存溢出导致 Redis 服务崩溃。
在这里设置为 "20gb",即 20GB。计算方式是基于存储的数据量进行估算,假设每条数据占用 1KB,那么 20GB 可以存储大约 20000000 条数据(其实这个计算只是粗略估计,实际数据存储大小还会受到数据结构、键名长度、值的内容等多种因素的影响)。
2、 maxmemory-policy allkeys - lfu
maxmemory-policy是 Redis 的内存淘汰策略。
"allkeys - lfu" 表示从所有键中淘汰最近使用频率最低的键。这种策略主要是基于数据的访问频率来决定哪些数据应该被优先淘汰。
**"allkeys - lfu"工作原理-按照 频率淘汰 ** :
[*]Redis 会为每个键维护一个访问频率计数器。当一个键被访问(如读取或写入操作)时,它的计数器会根据一定的规则增加。
[*]随着时间的推移,这些计数器会逐渐衰减,这样可以避免很久之前的一些高频访问数据一直占据较高的计数器值而影响淘汰决策。
[*]当内存达到 maxmemory 设置的上限时,Redis 会扫描键空间,比较各个键的访问频率计数器,选择计数器值最低的键进行淘汰。
"allkeys - lfu"适用场景 :适用于数据访问存在明显冷热之分的场景。例如,在一个电商网站的缓存系统中,一些热门商品的页面数据会被频繁访问,而一些过季商品或很少有人关注的商品数据访问频率较低。使用 allkeys - lfu 策略可以优先淘汰这些访问频率低的数据,为新的热门数据腾出空间。
3、 **lfu-log-factor10 **
用于调整 LFU(Least Frequently Used)计数器的增长速率。
LFU 算法中的计数器是用来衡量数据访问频率的,这个参数可以影响计数器的增长方式。
当 lfu- log-factor 值越大,高频访问数据的计数器增长会越慢。 反之, 高频访问数据的计数器增长会越 快。
这是因为较大的 log - factor 会使得计数器增长更加平缓,需要更多的访问次数才能使计数器达到较高的值。
这在区分度高的场景比较有用,例如在一个包含大量不同访问频率数据的缓存系统中,能够更精准地识别出真正高频和低频的数据。
举例:假设初始计数器值为 1,lfu-log-factor 设置为 10。当一个键被访问一次后,计数器会增加一个较小的增量,而不是简单地加 1。
这个增量的计算方式与访问次数和 log - factor 有关,通过这种方式控制计数器的增长速率。
4、 lfu- decay-time
设置计数器的衰减时间,单位是分钟。它决定了计数器值随时间衰减的速度。
lfu-decay-time 值越小,长期未访问数据的计数器下降越快。
举例:如果设置为 1 分钟,那么对于一个长时间没有被访问的键,它的计数器会很快衰减到较低的值,这样在内存淘汰时就更容易被选中淘汰。
这有助于及时清理掉那些已经不被使用的数据,使缓存空间能够更好地适应数据访问模式的变化。
在一个动态的缓存应用场景中,比如一个新闻网站的缓存,新闻的热度会随着时间的推移而变化。新发布的新闻可能一开始访问频率较高,但随着时间推移访问频率下降。通过设置合适的 lfu-decay-time,可以及时将这些热度下降的新闻数据从缓存中淘汰,为新的热点新闻数据腾出空间。
第三招: 热点探测
仅靠 Redis 的淘汰策略是被动的,要主动识别热点,需引入 实时热点探测系统 。
京东开源的 HotKey 系统 是一个优秀的解决方案,其通过 “客户端埋点 - 服务端聚合 - 全局推送” 实现更精准的热点探测。
京东 HotKey 核心架构 :
[*]客户端 :拦截所有缓存请求,统计本地 JVM 内的热点(如 1 分钟内某 Key 被访问超 100 次),上报到 HotKey 服务端。
[*]服务端 :聚合所有客户端上报的热点数据,计算全局热点(如 Top 20 万),并推送到各客户端。
[*]存储层 :ZooKeeper 作为协调中心,存储热点列表;Redis 根据热点列表调整缓存策略(如延长热点 Key 的过期时间)。
客户端集成示例(Java) :
// 引入 HotKey 客户端依赖
<dependency>
<groupId>com.jd.platform</groupId>
hotkey-client</artifactId>
<version>2.0.3</version>
</dependency>
// 初始化 HotKey 客户端(Spring Boot 场景)
@Configuration
public class HotKeyConfig {
@Bean
public HotKeyClient hotKeyClient() {
HotKeyClient client = new HotKeyClient("your_app_name");
client.setZkAddr("zk1:2181,zk2:2181");// 连接 ZooKeeper
client.setCheckInterval(60);// 本地热点检测间隔(秒)
client.start();
return client;
}
}
// 业务代码中使用热点标记
public Object getProduct(String productId) {
// 客户端自动统计该 Key 的访问次数
boolean isHot = HotKeyClient.isHotKey(productId);
if (isHot) {
// 热点数据优先从 Redis 获取,或延长缓存时间
return redis.get(productId);
} else {
// 非热点数据从 MySQL 获取,减少 Redis 内存占用
return mysql.query("SELECT * FROM products WHERE id = ?", productId);
}
}服务端热点聚合逻辑(Java) :
// 模拟 HotKey 服务端热点聚合逻辑
@Component
public class HotKeyAggregator {
private static final Logger logger = LoggerFactory.getLogger(HotKeyAggregator.class);
private static final ConcurrentHashMap<String, LongAdder> globalHotKeyCounter = new ConcurrentHashMap<>();
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private ZooKeeperClient zooKeeperClient;
// 定时聚合热点数据(每 10 秒执行一次)
@Scheduled(fixedRate = 10 * 1000)
public void aggregateHotKeys() {
logger.info("开始聚合热点数据...");
// 清空上一轮聚合结果
globalHotKeyCounter.clear();
// 收集所有客户端上报的热点数据
List<String> reportedHotKeys = zooKeeperClient.getReportedHotKeys();
for (String hotKeyInfo : reportedHotKeys) {
String[] parts = hotKeyInfo.split(":");
if (parts.length == 2) {
String key = parts;
long count = Long.parseLong(parts);
globalHotKeyCounter.computeIfAbsent(key, k -> new LongAdder()).add(count);
}
}
// 计算全局 Top 20 万热点
List<Map.Entry<String, LongAdder>> hotKeyList = new ArrayList<>(globalHotKeyCounter.entrySet());
hotKeyList.sort((a, b) -> Long.compare(b.getValue().sum(), a.getValue().sum()));
List<String> topHotKeys = hotKeyList.stream()
.limit(200000)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
// 将热点列表存储到 ZooKeeper,并更新 Redis 缓存策略
zooKeeperClient.storeTopHotKeys(topHotKeys);
updateRedisCachePolicy(topHotKeys);
logger.info("热点数据聚合完成,共发现 {} 个热点 Key", topHotKeys.size());
}
// 更新 Redis 缓存策略
private void updateRedisCachePolicy(List<String> topHotKeys) {
// 延长热点 Key 的过期时间
for (String key : topHotKeys) {
redisTemplate.expire(key, 1, TimeUnit.DAYS);
}
// 可以根据需要进一步优化 Redis 缓存策略,如调整淘汰策略等
}
}第四招: 多级防御, 层层加码,多级缓存架构
仅靠 Redis 单级缓存,易受 “缓存击穿” 冲击(热点 Key 过期瞬间大量请求直达 MySQL)。
通过 “接入层Nginx缓存 + 本地缓存 + Redis + 远程存储” 的多级架构,可进一步降低 MySQL 压力。
根据分布式缓存、 本地缓存的特点, 对缓存进行分级。
在整个系统架构的不同系统层级进行数据缓存, 以提升访问的高并发吞吐量。
从Java程序在访问缓存时的距离远近的角度对缓存进行分级, 可以将缓存划分为:
[*]一级缓存: JVM本地缓存, 如Guava Cache、 Caffeine等。
[*]二级缓存: 经典的分布式缓存, 如Redis Cluster集群。
[*]三级缓存: 在接入层的本地缓存, 如Nginx的shared_dict(共享字典) 。
[*]MySQL 数据库(持久化层):作为兜底,通过读写分离和分库分表承载剩余流量。
不同热度的数据可以按照不同的层级进行存放:
[*]对于访问热度最高的数据, 可以在接入层Nginx的shared_dict(共享字典) 缓存, 此为三级缓存(规模在1GB以内) , 比如秒杀系统中的优惠券详情、 秒杀商品详情信息, 这些信息访问得非常频繁。
[*]对于访问热度没有那么高但也访问频繁的数据, 可以在JVM进程内缓存(如Caffeine) ,这部分的数据规模也不能太大, 大概在1GB以内, 作为一级缓存。
[*]对于访问热度比较一般的数据, 存放到Redis Cluster集群, 作为二级缓存, 这部分的数据规模最大, 可以以10GB为节点单位进行横向扩展。
获取数据的步骤:
优先接入层Nginx字典返回数据,
如果没有在进入服务层从caffeine缓存中获取数据,
caffeine拿不到数据在去缓存层redis中获取数据,
redis拿不到数据在去兜底的mysql中获取数据,
获取到数据后,再把缓存上层的数据回填
参考实现:
// 第一级:Caffeine 本地缓存(JVM 内存,毫秒级访问)
LoadingCache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(1000)// 单实例本地缓存容量(根据机器内存调整)
.expireAfterWrite(30, TimeUnit.SECONDS)// 30 秒自动过期防脏数据
.build(key -> {
// 第二级:Redis 远程缓存(秒级访问)
Object redisValue = redis.get(key);
if (redisValue != null) {
return redisValue;
}
// 第三级:MySQL 兜底(毫秒级访问,但压力大)
Object mysqlValue = mysql.query("SELECT * FROM products WHERE id = ?", key);
// 回填 Redis(设置合理过期时间,避免冷数据长期占用)
if (mysqlValue != null) {
redis.setex(key, 3600, mysqlValue.toString());// 缓存 1 小时
}
return mysqlValue;
});
// 业务调用
public Object getCache(String key) {
try {
return localCache.get(key);// 优先访问本地缓存
} catch (Exception e) {
// 本地缓存异常时,直接访问 Redis
return redis.get(key);
}
}Nginx接入层Lua脚本参考如下:
--- 此脚本的环境:nginx 内部,不是运行在 redis 内部
local errorOut = { respCode = -1, resp_msg = "操作失败", datas = {} };
--导入自定义的基础模块
--local basic = require("luaScript.module.common.basic");
--导入自定义的 dataType 模块
local redisExecutor = require("luaScript.redis.RedisOperator");
local productId = ngx.var;
-- ngx.log(ngx.DEBUG,"productId=" .. productId)
if productId == "" or productId == nil then
errorOut.resp_msg = "商品id不能为空";
ngx.say(cjson.encode(errorOut));
return ;
end
--优先从缓存获取,否则访问上游接口
local product_cache = ngx.shared.product_cache
local productIdCacheKey = "productId_" .. productId
local productCache = product_cache:get(productIdCacheKey)
--ngx.log(ngx.DEBUG,"productCache=" .. productCache)
if productCache == "" or productCache == nil then
ngx.log(ngx.DEBUG,"cache not hited " .. productId)
--回源上游接口,比如Java 后端rest接口
local res = ngx.location.capture("/product-service/product/" .. productId, {
method = ngx.HTTP_GET,
-- args = requestBody ,-- 重要:将请求参数,原样向上游传递
always_forward_body = false, -- 也可以设置为false 仅转发put和post请求方式中的body.
})
if res.status == ngx.HTTP_OK then
--返回上游接口的响应体 body
productCache = res.body;
ngx.log(ngx.DEBUG,"productCache=" .. productCache)
--单位为s
product_cache:set(productIdCacheKey, productCache, 10 * 60 * 60)
end
end
ngx.say(productCache);第五招: 多维预热机制
通过 “定时预热 + 实时预热” 结合,确保潜在热点提前进入 Redis + Caffeine ,避免突发流量击穿缓存。
定时预热(每日凌晨执行)
// 定时预热任务(基于昨日访问日志预热 Top 20 万热点)
@Component
public class DailyPreheatTask {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
// 每日凌晨 2 点执行预热任务
@Scheduled(cron = "0 0 2 * * ?")
public void preheatCache() {
// 从 MySQL 查询昨日 Top 20 万热点
List<Object[]> hotItems = jdbcTemplate.query(
"SELECT item_id FROM access_log WHERE DATE(access_time) = CURDATE() - INTERVAL 1 DAY GROUP BY item_id ORDER BY COUNT(*) DESC LIMIT 200000",
(resultSet, rowNum) -> new Object[]{resultSet.getString("item_id")}
);
// 批量写入 Redis(使用 Pipeline 提升效率)
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Object[] item : hotItems) {
String itemId = (String) item;
// 从 MySQL 获取最新数据(避免缓存旧值)
String productData = jdbcTemplate.queryForObject(
"SELECT * FROM products WHERE id = ?", String.class, itemId);
if (productData != null) {
connection.setEx(itemId.getBytes(), 86400, productData.getBytes());
}
}
return null;
});
System.out.println("定时预热完成,共预热 " + hotItems.size() + " 个热点 Key");
}
}实时预热(应对突发热点)
当京东 HotKey 探测到某 Key 访问量突增(如 10 分钟内访问超 5000 次),立即触发实时Redis +Caffeine 预热:
// HotKey 事件监听(Spring Boot)
@Component
public class HotKeyListener implements HotKeyChangeEvent {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public void onHotKeyAdd(String key) {
// 从 MySQL 获取最新数据
String data = jdbcTemplate.queryForObject(
"SELECT * FROM products WHERE id = ?", String.class, key);
if (data != null) {
// 写入 Redis 并设置长期有效(或较长过期时间)
redisTemplate.opsForValue().set(key, data, 24, TimeUnit.HOURS);
// 记录预热日志
System.out.println("实时预热 Key=" + key + " 成功");
}
}
}五招 之前和之后的压测对比
为验证方案效果,对 “无缓存”“ 方案 、LRU方案 、 LFU + HotKey + 多级缓存方案进行压测对比 ,大致的结果如下:
方案缓存命中率平均延迟(ms)QPS 负载Redis 内存利用率无缓存0%9001000-基础 LRU50%300500060%(17GB)LFU + HotKey+多级缓存85%1002000092%(18.4GB)面试加分项
1、业务隔离
[*]实例隔离 :不同业务线使用独立 Redis 实例(如商品缓存、订单缓存、用户缓存),避免相互影响。
[*]容量隔离 :为核心业务(如商品详情)预留 30% 的 Redis 内存,非核心业务(如活动信息)使用剩余 70%。
[*]流量隔离 :通过 Nginx 负载均衡,将核心业务请求优先路由到热点缓存实例。
2、缓存雪崩防护
缓存雪崩(大量 Key 同时过期导致请求集中压垮 MySQL)可通过 “随机化过期时间 + 互斥锁” 解决:
// 随机过期时间(避免集体失效)
public void setCache(String key, Object value) {
// 基础过期时间 3600 秒(1 小时),±10 分钟随机
int expire = 3600 + new Random().nextInt(600) - 300;// 3300 ~ 3900 秒
redis.setex(key, expire, value.toString());
}
// 互斥锁防击穿(热点 Key 过期时仅允许一个请求回源)
public Object getCache(String key) {
Object value = redis.get(key);
if (value == null) {
// 加锁(仅允许一个线程回源)
String lockKey = "lock:" + key;
boolean isLocked = redis.setnx(lockKey, "1");
if (isLocked) {
try {
// 设置锁过期时间,防止线程异常导致锁无法释放
redis.expire(lockKey, 30);
value = mysql.query("SELECT * FROM products WHERE id = ?", key);// 回源 MySQL
if (value != null) {
redis.setex(key, 3600, value.toString());// 回填缓存
}
} finally {
// 释放锁
redis.del(lockKey);
}
} else {
// 未获取锁的线程等待后重试
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCache(key);
}
}
return value;
}3、热点 Key 分片
单个热点 Key(如 “秒杀商品 ID = 1001”)可能导致 Redis 单节点 QPS 超限,通过分片将请求分散到多个 Key:
// 热点 Key 分片函数(分 10 个片)
public String getShardedKey(String originalKey) {
int shard = originalKey.hashCode() % 10;// 计算分片号(0 ~ 9)
return String.format("%s:shard%d", originalKey, shard);
}
// 业务调用(写入时分散存储,读取时随机访问分片)
public void setHotKey(String originalKey, Object value) {
for (int i = 0; i < 10; i++) {
String shardedKey = String.format("%s:shard%d", originalKey, i);
redis.setex(shardedKey, 3600, value.toString());
}
}
public Object getHotKey(String originalKey) {
int shard = new Random().nextInt(10);// 随机选择分片
String shardedKey = String.format("%s:shard%d", originalKey, shard);
return redis.get(shardedKey);
}4、每日运维
[*]凌晨低峰期分析 :使用 redis-cli --hotkeys 命令主动探测 Redis 中的热点,结合京东 HotKey 的全局热点列表,验证热点一致性。
[*]缓存清理 :定期删除 Redis 中标记为 “非热点” 且过期的 Key,释放内存。
[*]数据校准 :对比 MySQL 的热度评分与 Redis 的热点列表,确保 “热点数据” 在两端一致。
5、监控预警
核心指标 :监控缓存命中率(目标 > 95%)、缓存击穿率(阈值 < 5%)、Redis 内存使用率(阈值 < 80%)。
报警规则
[*]击穿率 > 5%:触发黄色警告,检查热点探测是否失效;
[*]内存使用率 > 80%:触发橙色警告,准备扩容或调整淘汰策略;
[*]单个 Key QPS > 10 万:触发红色警告,立即分片该 Key。
缓存监控与报警示例
// 缓存监控与报警示例
@Component
public class CacheMonitor {
private static final Logger logger = LoggerFactory.getLogger(CacheMonitor.class);
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private AlertService alertService;
// 定时监控缓存指标(每分钟执行一次)
@Scheduled(fixedRate = 60 * 1000)
public void monitorCacheMetrics() {
// 获取缓存命中率
Double hitRate = redisTemplate.execute((RedisCallback<Double>) connection -> {
Long totalCommands = connection.dbSize();
Long hitCommands = connection.executeCommand("INFO statistics".getBytes()).toString()
.lines()
.filter(line -> line.startsWith("keyspace_hits"))
.map(line -> Long.parseLong(line.split(":").trim()))
.findFirst()
.orElse(0L);
return totalCommands > 0 ? hitCommands.doubleValue() / totalCommands : 0.0;
});
// 获取缓存击穿率
Double missRate = redisTemplate.execute((RedisCallback<Double>) connection -> {
Long totalCommands = connection.dbSize();
Long missCommands = connection.executeCommand("INFO statistics".getBytes()).toString()
.lines()
.filter(line -> line.startsWith("keyspace_misses"))
.map(line -> Long.parseLong(line.split(":").trim()))
.findFirst()
.orElse(0L);
return totalCommands > 0 ? missCommands.doubleValue() / totalCommands : 0.0;
});
// 获取 Redis 内存使用率
Double memoryUsageRate = redisTemplate.execute((RedisCallback<Double>) connection -> {
Map<String, String> infoMap = connection.info("memory");
Long usedMemory = Long.parseLong(infoMap.get("used_memory"));
Long maxMemory = Long.parseLong(infoMap.get("maxmemory"));
return maxMemory > 0 ? (usedMemory.doubleValue() / maxMemory) * 100 : 0.0;
});
// 打印监控指标
logger.info("缓存系统监控指标:命中率 = {:.2f}%,击穿率 = {:.2f}%,内存使用率 = {:.2f}%",
hitRate * 100, missRate * 100, memoryUsageRate);
// 根据监控指标触发报警
if (hitRate < 0.95) {
alertService.sendAlert("缓存命中率过低", String.format("当前缓存命中率为 %.2f%%,低于目标值 95%%", hitRate * 100));
}
if (missRate > 0.05) {
alertService.sendAlert("缓存击穿率过高", String.format("当前缓存击穿率为 %.2f%%,高于阈值 5%%", missRate * 100));
}
if (memoryUsageRate > 80) {
alertService.sendAlert("Redis 内存使用率过高", String.format("当前 Redis 内存使用率为 %.2f%%,高于阈值 80%%", memoryUsageRate));
}
}
}
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页:
[1]