技术派缓存+Pipeline面试题深度解析(22问·附源码)

技术派缓存 + Pipeline 面试题深度解析(22 问·附源码)

简历原文:针对高并发互动场景(如点赞、评论、阅读),引入 Caffeine 本地缓存拦截热点聚合数据(侧边栏、热文榜),利用 Redis Hash + hIncr 原子操作聚合用户/文章的点赞数、评论数、阅读数等高频写计数,并通过 Pipeline 批量提交减少 RTT,有效降低直接落库压力

下面每道题都贴出项目源码,确保你答出来的东西是”真的做过”,而不是背八股。


一、Caffeine 本地缓存(Q1~Q5)


Q1:为什么用 Caffeine 本地缓存,而不全用 Redis?本地缓存有什么优缺点?

答:

全用 Redis 不是不行,但每个请求都要走一次网络 IO,对于”侧边栏”这种每个页面都加载、内容变化不频繁的数据来说,太浪费了。

项目里的做法(SidebarServiceImpl.java):

1
2
3
4
5
6
7
8
9
// 首页侧边栏:公告 + 热门文章 + 排行榜
// 每个用户每次刷新首页都要查,QPS 很高
@Cacheable(key = "'homeSidebar'", cacheManager = "caffeineCacheManager", cacheNames = "home")
public List<SideBarDTO> queryHomeSidebarList() { ... }

// 文章详情页侧边栏:PDF推荐 + 作者相关文章
// 同一篇文章被多人阅读,缓存命中率很高
@Cacheable(key = "'sideBar_' + #articleId", cacheManager = "caffeineCacheManager", cacheNames = "article")
public List<SideBarDTO> queryArticleDetailSidebarList(Long author, Long articleId) { ... }

对比:

Caffeine(本地缓存) Redis(分布式缓存)
速度 微秒级(JVM 内存直接读) 毫秒级(要走网络)
共享 每个实例各自一份,不共享 所有实例共享
容量 受 JVM 堆内存限制 可以很大(单独服务器)
一致性 多实例可能不一致 只有一份,天然一致

项目里的取舍:侧边栏数据变化不频繁(公告几天才换一次,热门文章 5 分钟更新一次就够了),即使短暂不一致也完全不影响核心功能,所以用 Caffeine 省掉大量 Redis 网络 IO,是值得的。


Q2:Caffeine 的淘汰策略是 W-TinyLFU,讲一下它和 LRU、LFU 的区别?为什么 W-TinyLFU 更适合这个场景?

答:

先讲三个策略分别是啥:

LRU(最近最少使用)

  • 思想:”最近用过的,大概率还会再用”
  • 实现:维护一个链表,访问了就移到链表头,淘汰链表尾
  • 致命缺陷扫描式访问会瞬间把热点数据全部挤出去。比如你批量遍历了 100 万条数据,LRU 会把真正热的数据全部淘汰掉。

LFU(最不经常使用)

  • 思想:”访问频率高的,大概率还会再用”
  • 实现:给每个 Key 维护一个访问计数器,淘汰计数最小的
  • 致命缺陷新数据很难挤进缓存(新数据的计数是 0 或 1,即使它很热,也要等很久才能积累足够高的计数)

W-TinyLFU(Caffeine 用的)

  • 结合了 LRU 和 LFU 的优点
  • Count-Min Sketch(计数最小草图) 来估算访问频率(省内存)
  • Tiny LRU 来记录最近访问(解决新数据的问题)
  • 淘汰时:先比较访问频率,频率差不多的再用 LRU 兜底

为什么适合侧边栏场景

  • 热门文章(热点数据)访问频率高,W-TinyLFU 会保留它们
  • 如果某天热门文章突然变了(比如出了篇爆款),新文章访问频率快速上升,W-TinyLFU 也能很快把它加进缓存(Tiny LRU 部分保证了新数据有机会)

Q3:服务部署了 3 个实例,每个实例都有 Caffeine 本地缓存,数据不一致怎么办?

答:

这确实是本地缓存的天然缺陷。项目里的处理策略是:允许不一致,因为侧边栏数据不一致的影响极小

具体分析:

  1. 首页侧边栏homeSidebar):3 个实例各自缓存,A 实例缓存了旧的热门文章列表,B 实例缓存了新的,用户刷新页面时被 Nginx 随机路由到 A 或 B,看到的内容可能差一两条。但这完全不影响核心功能(读文章),只是推荐稍有不精准,可以接受。

  2. 文章详情页侧边栏sideBar_文章ID):同理,PDF 推荐和作者相关文章列表可能各实例略有不同,但影响极小。

如果要解决怎么办? 有几种方案:

  • 方案 1:缓存时间设短一点(比如 1 分钟),让不一致窗口尽量小
  • 方案 2:用 Redis 发布订阅,某个实例缓存失效时,通知其他实例也失效(复杂度高,项目里没做)
  • 方案 3:干脆不用本地缓存,全部走 Redis(牺牲一点性能,换一致性)

项目里选择方案 1:Caffeine 设置 expireAfterWrite = 5 分钟,最多不一致 5 分钟。


Q4:Caffeine 你设置了多大的堆内存?如果缓存数据量超过了设置的最大值,会 OOM 吗?

答:

Caffeine 的 maximumSize 参数控制最大条目数,当条目数超过这个值时,Caffeine 会主动淘汰(根据 W-TinyLFU 策略),不会 OOM

项目里的 Caffeine 配置(ForumCoreAutoConfig.java 或类似配置类):

1
2
3
4
5
6
7
8
9
@Bean("caffeineCacheManager")
public CacheManager caffeineCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000) // 最多缓存 1000 个 Key
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后 5 分钟过期
.recordStats()); // 开启统计(命中率等)
return manager;
}

计算一下内存占用

  • 每个缓存 Key:homeSidebarsideBar_123 等,平均约 20 字节
  • 每个缓存 Value:侧边栏数据,序列化后约 2~5 KB
  • 1000 个条目 × 5 KB = 约 5 MB

5 MB 对于现代 JVM(一般分配 1~2 GB 堆内存)来说完全不是问题。就算设置 maximumSize = 10000,也才 50 MB,仍然安全。

真正要注意的是:如果 Value 是个很大的对象(比如把整个文章内嵌进缓存),maximumSize 就要设小一点,或者改用 maximumWeight(按权重淘汰,比如按 Value 的字节数计算权重)。


Q5:@Cacheable 注解你用了吗?类内部方法调用 @Cacheable 方法,缓存会生效吗?为什么?怎么解决?

答:

项目里确实用了 @CacheableSidebarServiceImpl.java):

1
2
3
@Override
@Cacheable(key = "'homeSidebar'", cacheManager = "caffeineCacheManager", cacheNames = "home")
public List<SideBarDTO> queryHomeSidebarList() { ... }

类内部调用会失效,这是 Spring AOP 的经典坑。原因:

@Cacheable 的功能是通过 AOP 代理 实现的。Spring 容器启动时,给 SidebarServiceImpl 包了一层代理对象(CGLIB 代理,继承自 SidebarServiceImpl)。外部调用 queryHomeSidebarList() 时,实际先进入了代理对象的 CacheInterceptor,它先查缓存,缓存没有才调用真实方法。

类内部调用 this.queryHomeSidebarList() 时,this 指向的是原始对象,不是代理对象,所以 AOP 拦截器根本不会被触发,缓存自然失效。

项目里其实踩了这个坑,注意看这段代码(SidebarServiceImpl.java 第 146~147 行):

1
2
3
4
5
6
7
8
@Override
@Cacheable(key = "'sideBar_' + #articleId", cacheManager = "caffeineCacheManager", cacheNames = "article")
public List<SideBarDTO> queryArticleDetailSidebarList(Long author, Long articleId) {
List<SideBarDTO> list = new ArrayList<>(2);
// 不能直接使用 pdfSideBar() 的方式调用,会导致缓存不生效
list.add(SpringUtil.getBean(SidebarServiceImpl.class).pdfSideBar());
// ...
}

注意这个注释:“不能直接使用 pdfSideBar() 的方式调用,会导致缓存不生效”

pdfSideBar() 方法也加了 @Cacheable

1
2
@Cacheable(key = "'sideBar'", cacheManager = "caffeineCacheManager", cacheNames = "article")
public SideBarDTO pdfSideBar() { ... }

如果在 queryArticleDetailSidebarList 里直接写 this.pdfSideBar(),缓存会失效。所以项目里用了 SpringUtil.getBean(SidebarServiceImpl.class).pdfSideBar()从 Spring 容器里取代理对象,再通过代理对象调用,这样 AOP 才会生效。

SpringUtil.getBean() 的原理:从 ApplicationContext 里取 Bean,取出来的就是 Spring 管理的代理对象(如果这类有 AOP 增强的话)。


二、Redis Hash + hIncr(Q6~Q9)


Q6:文章点赞数、评论数、阅读数,为什么用 Redis Hash 存,而不是每个计数用独立的 String Key?

答:

这是 Redis 内存优化 的经典问题。

方案 A:每个计数一个 String Key

1
2
3
4
article:123:likes = 42
article:123:comments = 8
article:123:reads = 1024
article:123:collections = 5

4 个 Key,每个 Key 在 Redis 里都有元数据开销(约 90 字节,包括 Key 字符串、过期时间、类型信息等)。4 个 Key 就是 4 × 90 = 360 字节。

如果有 10 万篇文章,就是 10 万 × 4 × 90 ≈ 36 MB 的纯元数据开销(还不算实际存储的值)。

方案 B:一个 Hash 存所有计数

1
2
3
4
5
Key: article_statistic_123
Field: praiseCount 42
Field: commentCount 8
Field: readCount 1024
Field: collectCount 5

只有一个 Key,元数据开销只有一份(约 90 字节)。

而且,当 Hash 里的字段数比较少的时候,Redis 会用 ziplist(压缩列表) 编码,极其省内存。ziplist 是一个紧凑的二进制数组,没有额外的指针开销。

项目里的实际代码(CountConstants.java 定义常量,CountServiceImpl.java 使用):

1
2
3
4
5
6
7
8
9
10
11
12
// 读取文章统计信息(从 Redis Hash 一次性拿全部 4 个计数)
@Override
public ArticleFootCountDTO queryArticleStatisticInfo(Long articleId) {
Map<String, Integer> ans = RedisClient.hGetAll(
CountConstants.ARTICLE_STATISTIC_INFO + articleId, Integer.class);
ArticleFootCountDTO info = new ArticleFootCountDTO();
info.setPraiseCount(ans.getOrDefault(CountConstants.PRAISE_COUNT, 0));
info.setCollectionCount(ans.getOrDefault(CountConstants.COLLECTION_COUNT, 0));
info.setReadCount(ans.getOrDefault(CountConstants.READ_COUNT, 0));
info.setCommentCount(ans.getOrDefault(CountConstants.COMMENT_COUNT, 0));
return info;
}

一个 hGetAll 拿到全部 4 个计数,只用了 1 次网络请求,比 4 次 get 省 3 次 RTT。


Q7:hIncrBy 是原子操作,Redis 为什么能保证原子性?

答:

Redis 是单线程模型(指处理客户端命令的核心模块是单线程的),所有命令都是排队执行的,不可能有两个命令同时执行。

hIncrBy 的内部执行流程(Redis 源码层面):

1
2
3
4
5
1. 根据 Key 找到对应的 Hash 数据结构
2. 根据 Field 找到对应的 value(字符串形式的整数)
3. 把 value 转成 long,加上增量(比如 +1)
4. 把结果写回 Hash
5. 返回结果

这 5 步在 Redis 单线程模型下是不可分割的——在执行这 5 步的过程中,不可能有另一个客户端的命令插进来。

对比:如果不用 hIncrBy,而是先在 Java 里读再写,会出什么问题?

1
2
3
4
// ❌ 错误写法(有并发问题)
Integer count = RedisClient.hGet(key, "praiseCount", Integer.class);
count = count + 1;
RedisClient.hSet(key, "praiseCount", count);

两个线程同时执行这段代码:

1
2
3
4
线程 A:读到 count = 42
线程 B:读到 count = 42
线程 A:写回 43
线程 B:写回 43 ← 错了!应该是 44,但结果是 43

hIncrBy,这两个 hIncrBy 会排队执行,结果一定是 44,不可能出错。


Q8:如果我要同时给一篇文章的点赞数 +1,给作者的总获赞数 +1,这是两个 hIncrBy,怎么保证这两个操作要么同时成功要么同时失败?

答:

先说结论:项目里没有保证这两个操作的事务性,这是有意为之的设计取舍。

看项目实际代码(CountServiceImpl.java 第 101~111 行):

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public void incrArticleReadCount(Long authorUserId, Long articleId) {
// db 层的计数 +1(直接落 MySQL)
articleDao.incrReadCount(articleId);
// redis 计数器 +1(Pipeline 批量执行)
RedisClient.pipelineAction()
.add(CountConstants.ARTICLE_STATISTIC_INFO + articleId, CountConstants.READ_COUNT,
(connection, key, value) -> connection.hIncrBy(key, value, 1))
.add(CountConstants.USER_STATISTIC_INFO + authorUserId, CountConstants.READ_COUNT,
(connection, key, value) -> connection.hIncrBy(key, value, 1))
.execute();
}

这里有两个写操作:

  1. articleDao.incrReadCount(articleId) → MySQL 文章阅读数 +1
  2. Pipeline 里的两个 hIncrBy → Redis 文章阅读数 +1,作者阅读数 +1

这几个操作之间没有任何事务保证。可能出现:

  • MySQL 写成功了,Redis 写失败了 → Redis 计数偏少
  • Redis 第一个 hIncrBy 成功,第二个失败了 → 数据不一致

为什么不解决? 因为阅读数、点赞数这种计数,不需要强一致性。哪怕 Redis 里的计数和 MySQL 里的差个几次,对用户来说完全无感知。项目里通过定时对账(每天凌晨 4:15 全量从 MySQL 重新统计,覆盖 Redis)来保证最终一致性。

如果真的需要强一致性怎么办? 有几种方案:

  1. 用 Lua 脚本:把多个 Redis 命令写进一个 Lua 脚本,Redis 保证整个脚本原子执行(脚本执行期间不会被其他命令打断)
  2. 用 Redis 事务(MULTI/EXEC):但不推荐,因为事务里的命令不支持动态计算结果(比如要先读再写)
  3. 先更新 MySQL,再删除 Redis 缓存(Cache-Aside 模式):确保读请求时缓存没有,会从 MySQL 读最新的

Q9:Redis Hash 什么时候用 ziplist 编码,什么时候转成 hashtable 编码?

答:

Redis 的 Hash 数据结构有两种编码:

  • ziplist(压缩列表):内存紧凑,但查找需要遍历,O(N)
  • hashtable(哈希表):用真正的哈希表,查找 O(1),但每个 entry 有指针开销

转换阈值(Redis 配置):

1
2
hash-max-ziplist-entries 512   # Hash 里字段数超过 512 个,转成 hashtable
hash-max-ziplist-value 64 # 任意 field 或 value 超过 64 字节,转成 hashtable

项目里的 Hash 结构:

1
2
3
4
5
Key: article_statistic_123
Field: praiseCount → "42" (约 12 字节)
Field: commentCount → "8"
Field: readCount → "1024"
Field: collectCount → "5"

只有 4 个字段,每个 value 都是短字符串(最长也就是阅读能力到 “1000000” 才 7 字节),远远低于 512 和 64 的阈值,所以永远用的是 ziplist 编码,非常省内存。

如果文章内容也存 Redis(大 Value),会怎样? 假设把文章正文(几 KB)存进 Hash 的某个 field,超过 64 字节,Redis 会把整个 Hash 转成 hashtable 编码,内存开销会大很多。所以大 Value 不应该放在 Hash 里,应该直接用 String 结构存,或者存进文件系统/对象存储。


三、Pipeline(Q10~Q13)


Q10:Pipeline 和事务(MULTI/EXEC)有什么区别?Pipeline 能保证原子性吗?

答:

核心区别:Pipeline 只是”批量发送命令”,不是事务;MULTI/EXEC 才是真正的事务。

Pipeline MULTI/EXEC 事务
目的 减少 RTT(网络往返时间) 保证多个命令原子执行
原子性 不保证(命令是一条条执行的,只是打包发送) 保证(所有命令一起执行,不会被其他客户端的命令打断)
错误处理 某条命令失败不影响其他命令 如果 EXEC 前某条命令入队失败,整个事务拒绝执行
适用场景 批量写(不在乎中间状态) 需要”全部成功或全部失败”的场景

Pipeline 的执行过程

1
2
3
4
5
6
7
8
不用 Pipeline:
客户端 → hIncrBy → Redis(执行)→ 返回结果 ← 客户端 (1 RTT)
客户端 → hIncrBy → Redis(执行)→ 返回结果 ← 客户端 (1 RTT)
...共 2 次 RTT

用 Pipeline:
客户端 → [hIncrBy, hIncrBy] → Redis(逐条执行)→ [结果1, 结果2] ← 客户端
...共 1 次 RTT

注意:Redis 仍然是逐条执行 Pipeline 里的命令的,不是”原子执行”。如果其他客户端在这两个 hIncrBy 之间发了一个命令,那个命令会插在中间执行。

项目里用 Pipeline 但不要求事务性,是因为:两个 hIncrBy 之间插入别的命令,对最终结果没有影响(点赞数最终会是对的,只是中间状态可能短暂不一致,但没人会读到中间状态)。


Q11:你说 Pipeline 减少 RTT,RTT 是什么?本地 Redis 和跨机房 Redis 的 RTT 大概是多少?

答:

RTT(Round-Trip Time)= 客户端发一个请求到收到响应所花费的时间

1
2
客户端发送命令 ──(网络延迟)──→ Redis 执行命令 ──(网络延迟)──→ 客户端收到结果
↑_______________________ RTT ________________________________↓

RTT 的大小:

场景 RTT 大概值
客户端和 Redis 在同一台机器(localhost) 0.05 ~ 0.1 ms
客户端和 Redis 在同一机房(内网) 0.5 ~ 2 ms
客户端和 Redis 跨机房(比如北京→上海) 20 ~ 50 ms
客户端和 Redis 在不同国家 100+ ms

Pipeline 能省多少时间?

假设跨机房 RTT = 30 ms,要执行 10 个 hIncrBy

1
2
3
不用 Pipeline:10 × 30 ms = 300 ms
用 Pipeline1 × 30 ms = 30 ms
省了 270 ms!

项目里 Redis 是本地或同机房的,RTT 约 1 ms,Pipeline 打包 2 个命令,省了 1 ms,提升不大,但习惯是好的(如果以后 Redis 迁到独立服务器,就有明显收益了)。


Q12:Pipeline 一次性发多少条命令合适?发太多了会出什么问题?

答:

Pipeline 一次性发送的命令数量不是越多越好,有两个限制:

1. Redis 输出缓冲区溢出

Pipeline 的所有命令和响应都放在 Redis 的输出缓冲区里。如果一次性发 10 万条命令,响应结果可能把 Redis 的内存撑爆。

Redis 配置里有:

1
client-output-buffer-limit normal 0 0 0  # normal 客户端(非 slave、非 pub/sub)默认不限制

但实际中,如果输出缓冲区太大,Redis 会强制断开这个客户端连接。

2. 客户端等待时间太长

Pipeline 是同步阻塞的:发送之后,要等所有结果回来才能继续。如果一次性发 1 万条命令,可能要等几百毫秒,调用线程就被阻塞了,影响并发处理能力。

经验值

  • 一般建议 每次 Pipeline 打包 10~100 条命令
  • 项目里只打包了 2 条(article_statisticuser_statistic 各一个 hIncrBy),非常保守,完全不会有问题

如果要批量执行很多命令(比如初始化 1 万篇文章的计数),应该分批 Pipeline

1
2
3
4
5
6
7
8
9
// 每 100 条一批,分 100 批执行
for (int i = 0; i < 10000; i += 100) {
var pipeline = RedisClient.pipelineAction();
for (int j = i; j < Math.min(i + 100, 10000); j++) {
long articleId = articleIds.get(j);
pipeline.add(...);
}
pipeline.execute(); // 一批一批执行,不阻塞太久
}

Q13:Pipeline 和 Redis 的事务(MULTI/EXEC)一起用,会有什么问题?

答:

可以一起用,但要注意语义。

1
2
3
4
MULTI
hIncrBy article_statistic_123 praiseCount 1
hIncrBy user_statistic_456 praiseCount 1
EXEC

上面这段用事务保证了原子性,但没有减少 RTT(MULTI、hIncrBy、hIncrBy、EXEC 是 4 次 RTT)。

Pipeline + MULTI/EXEC 一起用

1
2
3
4
5
6
7
8
Pipeline:
MULTI
hIncrBy article_statistic_123 praiseCount 1
hIncrBy user_statistic_456 praiseCount 1
EXEC
→ 一次性发给 Redis,只花 1 次 RTT
→ Redis 收到后,按顺序执行 MULTI → hIncrBy → hIncrBy → EXEC
EXEC 执行时保证原子性

这样既减少了 RTT,又保证了原子性。

但项目里没有这样做,原因是:

  1. 点赞/阅读计数不需要强一致性(前面 Q8 已经解释过)
  2. 用事务会让代码更复杂(要处理 EXEC 的结果)
  3. Pipeline 已经够用了

四、高频写计数 + 落库(Q14~Q17)


Q14:计数是只存 Redis,还是也要落 MySQL?如果 Redis 宕机了,数据会不会丢?

答:

项目里的设计是:MySQL 是源头,Redis 是缓存(加速读)。

具体流程(CountServiceImpl.java 第 101~111 行):

1
2
3
4
5
6
7
8
9
public void incrArticleReadCount(Long authorUserId, Long articleId) {
// ① 先更新 MySQL(文章表的阅读数 +1)
articleDao.incrReadCount(articleId);
// ② 再更新 Redis(Pipeline 批量 +1)
RedisClient.pipelineAction()
.add(ARTICLE_STATISTIC_INFO + articleId, READ_COUNT, ...)
.add(USER_STATISTIC_INFO + authorUserId, READ_COUNT, ...)
.execute();
}

如果 Redis 宕机了

  • MySQL 里的数据还在(文章表里有 read_count 字段)
  • 只是 Redis 里的缓存没了,下次读的时候会从 MySQL 重新加载
  • 但项目里没有做”Redis 没有时从 MySQL 加载”的逻辑queryArticleStatisticInfo 直接读 Redis,没有 fallback),所以如果 Redis 宕机,统计信息会显示 0

更好的设计(项目里可以改进的地方):

  • 先更新 MySQL,再删除 Redis 缓存(Cache-Aside 模式)
  • 读的时候:先读 Redis,没有就读 MySQL,再写回 Redis
  • 这样 Redis 宕机时,系统仍然可以正常工作(只是慢一点)

Q15:Redis 的计数多久同步一次到 MySQL?是定时批量同步,还是每次写 Redis 同时写 MySQL?

答:

项目里是每次写 Redis 同时写 MySQLarticleDao.incrReadCount(articleId) 在 Pipeline 之前就执行了)。

但 MySQL 里的计数不是实时的(有专门的定时任务做全量同步)。

CountServiceImpl.java 第 116~133 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 每天 4:15 执行定时任务,全量刷新用户的统计信息
@Scheduled(cron = "0 15 4 * * ?")
public void autoRefreshAllUserStatisticInfo() {
Long now = System.currentTimeMillis();
log.info("开始自动刷新用户统计信息");
Long userId = 0L;
int batchSize = 20;
while (true) {
List<Long> userIds = userDao.scanUserId(userId, batchSize);
userIds.forEach(this::refreshUserStatisticInfo);
if (userIds.size() < batchSize) {
break;
} else {
userId = userIds.get(batchSize - 1);
}
}
log.info("结束自动刷新用户统计信息,共耗时: {}ms", System.currentTimeMillis() - now);
}

refreshUserStatisticInfo 方法(第 142~165 行):

1
2
3
4
5
6
7
8
public void refreshUserStatisticInfo(Long userId) {
// 从 MySQL 重新统计用户的文章点赞数、收藏数、阅读数
ArticleFootCountDTO count = userFootDao.countArticleByUserId(userId);
// ...
// 用 hMSet 全量覆盖 Redis 里的数据
RedisClient.hMSet(CountConstants.USER_STATISTIC_INFO + userId,
MapUtils.create(PRAISE_COUNT, count.getPraiseCount(), ...));
}

所以架构是

  • 写路径:每次用户操作 → 同时写 MySQL + Redis(Redis 用 Pipeline 加速)
  • 读路径:优先读 Redis,Redis 没有才读 MySQL(但实际 Redis 一般都有数据)
  • 兜底:每天凌晨 4:15 全量从 MySQL 重新统计,覆盖 Redis(解决 Redis 数据和 MySQL 不一致的问题)

Q16:如果采用”先更新数据库,再删除缓存”的 Cache-Aside 模式,在高并发下会有什么问题?

答:

Cache-Aside 模式(读多写少的场景推荐用这个):

1
2
3
4
5
6
7
8
写操作:
1. 更新 MySQL
2. 删除 Redis 缓存

读操作:
1. 读 Redis,有就直接返回
2. Redis 没有,读 MySQL
3. 把 MySQL 的结果写进 Redis

高并发下的问题:并发写 + 读,导致缓存里是旧数据。

时序:

1
2
3
4
5
时间线:
T1: 线程 A 更新 MySQL(name 从"张三"改成"李四"
T2: 线程 A 删除 Redis 缓存(成功)
T3: 线程 B 读 Redis,发现没有
T4: 线程 B 读 MySQL(读到的已经是"李四"了)→ 但假设 T1 还没提交事务呢?

更经典的并发问题(脏数据入缓存):

1
2
3
4
5
6
7
T1: 线程 A 更新 MySQL:name: "张三""李四"
T2: 线程 B 读 Redis → 没有
T3: 线程 B 读 MySQL → 读到 "张三"(因为线程 A 的事务还没提交!)
T4: 线程 A 提交 MySQL 事务
T5: 线程 A 删除 Redis 缓存
T6: 线程 B 把读到的 "张三" 写进 Redis
→ 结果:Redis 里是旧数据 "张三",MySQL 里是新数据 "李四"

解决方案

  1. 延迟双删:更新 MySQL 后,延迟一段时间(比如 500ms)再删一次缓存(给读请求足够时间完成)
  2. 用消息队列异步删除缓存:更新 MySQL 后发消息,消费者异步删除缓存(保证顺序)
  3. 用 Canal 监听 MySQL binlog,自动删除/更新缓存(最优雅,但复杂度高)

Q17:如果 Redis 里的计数和 MySQL 里的计数对不上,你有没有做过对账?怎么做的?

答:

项目里做了定时对账autoRefreshAllUserStatisticInfo,每天凌晨 4:15 执行)。

但这个方法有个问题:它是全量覆盖,不是增量对账。也就是说,如果 Redis 里的数据错了,凌晨 4:15 会被纠正;但如果 MySQL 里的数据错了,这个方法会把错误的数据同步到 Redis。

更好的对账方案

  1. 记录每次更新的日志(binlog 或业务日志),定期用日志重新计算一遍,和 Redis/MySQL 里的数据对比
  2. 用定时任务做增量对账:记录上次对账的时间,只对账这段时间内有更新的数据
  3. 用 Redis 的 DUMP 命令 + MySQL 查询做抽样对账(不需要全量,随机抽 1000 个用户/文章对比即可)

项目里目前的做法(全量覆盖)对于点赞数、阅读数这种非精确计数是够用的,但对于账户余额、积分这种精确计数,就需要更严格的对账机制了。


五、高并发场景设计(Q18~Q20)


Q18:假设你的文章爆了,1 秒内 1 万个人同时点赞同一篇文章,你的系统能扛住吗?瓶颈在哪里?

答:

先算一下 QPS:

1 秒内 1 万次点赞 = 10000 QPS 写操作

瓶颈分析(顺着请求链路找):

① 前端 → Nginx:不是瓶颈,Nginx 能扛 5 万+ QPS。

② Nginx → Spring Boot:Spring Boot 内嵌 Tomcat,默认最大线程数 200。10000 QPS 意味着每个线程 1 秒内要处理 50 个请求,每个请求要在 20ms 内处理完,勉强能撑,但线程池会打满,后续请求排队。

解决:调大 Tomcat 线程池(server.tomcat.threads.max = 500),或者横向扩展(部署多个 Spring Boot 实例,用 Nginx 负载均衡)。

③ Spring Boot → MySQL:点赞要写 user_foot 表(INSERT 或 UPDATE)。MySQL 的单机写入能力约 2000~5000 QPS(取决于配置和索引)。10000 QPS 会打爆 MySQL。

解决

  • RabbitMQ 异步削峰:点赞请求先发进 MQ,消费者慢慢写 MySQL(项目里已经用了 RabbitMQ 处理点赞消息)
  • 但最终 MySQL 还是要写,MQ 只能削峰,不能消灭写量。如果 1 秒内真的有 1 万次点赞,消费者还是要 10~30 秒才能处理完(取决于消费者并发数)

④ Spring Boot → RedishIncrBy 是 Redis 单线程执行的,Redis 的单机写 QPS 约 5 万~10 万,10000 QPS 完全不是问题。

结论

  • Redis 不是瓶颈(10 万 QPS 才到瓶颈)
  • MySQL 是瓶颈(需要 MQ 异步削峰,或者做分库分表)
  • 如果用 MQ 异步写 MySQL,点赞数在 MySQL 里会有延迟(比如用户点赞后立刻看”我的点赞列表”,可能看不到刚点的赞,因为还没消费完)

Q19:热点 Key 问题:如果某篇文章特别火,它的点赞计数 Key 被高频访问,Redis 单线程模型会不会成为瓶颈?

答:

Redis 单线程不是瓶颈,除非 QPS 到了 10 万+。

Redis 的单线程指的是处理命令的线程只有一个,但它处理命令极快(内存操作,微秒级)。官方数据:Redis 单机 QPS 能到 5 万~10 万

所以如果热点文章的点赞 QPS 是 1 万,完全不是问题。

但如果真的到了 10 万+ QPS(比如微博热搜级别的流量),怎么办?

方案 1:本地累加 + 定时批量上报

1
2
客户端(Spring Boot)本地用 AtomicLong 累加点赞数
每累计 100 次,或者每隔 1 秒,批量上报 Redis

这样 Redis 的 QPS 从 10 万降到 1000(或者更少),但实时性会差一点(最多延迟 1 秒)。

方案 2:Key 分片

1
2
3
4
5
6
7
8
不用一个 Key 存点赞数,而是拆成 10Key
article_praise_123_shard_0
article_praise_123_shard_1
...
article_praise_123_shard_9

点赞时随机选一个 shard 累加
查询时把 10 个 shard 的结果加起来

这样 10 万 QPS 被分散到 10 个 Key,每个 Key 只有 1 万 QPS。

方案 3:用 Redis Cluster

Redis Cluster 有 16384 个槽,不同 Key 分布在不同节点上,天然的分片。但如果热点 Key 只有一个(比如某篇爆款文章),它只在一个节点上,其他节点帮不上忙。

方案 2(本地累加)+ 方案 3(Redis Cluster) 是微博、知乎等大厂的实际做法。


Q20:你这个设计里,点赞、评论、阅读都是写 Redis,Redis 的写入吞吐量能到多少?如果达到了瓶颈怎么扩容?

答:

Redis 单机写入吞吐量

  • 简单命令(SETHINCRBY):5 万~10 万 QPS(官方 benchmark)
  • 大 Value(超过 1 KB):会降到 1 万~3 万 QPS

项目里的操作是 HINCRBY(简单命令),所以瓶颈在 5 万+ QPS。

达到瓶颈后的扩容方案

① 垂直扩容:用更好的 CPU、更大的内存(但单机总有上限)

② 水平扩容:Redis Cluster

1
2
3
4
33 从的 Redis Cluster:
主节点 1:槽 0~5460
主节点 2:槽 5461~10922
主节点 3:槽 10923~16383

不同的 Key 会分布在不同主节点上,写吞吐量理论上能线性扩展(3 个主节点 → 15 万~30 万 QPS)。

但热点 Key 问题(Q19 提到的)在 Cluster 模式下仍然存在:如果某个 Key 特别热,它只在一个节点上,其他节点分担不了。

③ 应用层缓存(再多一层 Caffeine)

1
请求 → Caffeine(本地累加)→ 每隔 N 次或 N 秒 → Redis → MySQL

这样大部分写请求根本不打到 Redis,彻底解决瓶颈。


六、开放设计题(Q21~Q22)


Q21:如果让你重新设计这个计数系统,支持日活 1000 万的用户规模,你会怎么做?

答:

日活 1000 万,假设平均每人点赞/阅读 10 次,每天 1 亿次写操作,平均 QPS = 1 亿 / 86400 秒 ≈ 1160 QPS,峰值 QPS 可能是平均值的 10 倍 = 1 万~2 万 QPS

这个量级,项目里现有的设计(MySQL + Redis Hash + Pipeline)已经不够用了,需要重构:

架构设计(V2 版本):

1
2
3
4
5
6
7
8
9
10
11
12
用户点赞

API 网关(限流、鉴权)

点赞服务(无状态,可横向扩展)
↓ 写本地内存累加器(Caffeine/Guava AtomicLong)
↓ 每 100 次 或 每 1

Redis(分片存储,Cluster 模式)
↓ 每隔 5 秒 或 每累计 1000

MySQL(只做持久化,不提供实时读)

关键设计点

  1. 应用层做本地累加:不每次都写 Redis,先在本机内存里累加,定期批量上报。这样 1 万 QPS 的写请求,可能只有 100 QPS 真正打到 Redis。

  2. Redis 用 Cluster + Key 分片:每个文章的计数拆成 10~100 个分片,彻底解决热点 Key 问题。

  3. MySQL 只做持久化,不做实时读:用户的”点赞数”从 Redis 读,不从 MySQL 读。MySQL 只是”备份”,万一 Redis 全毁了,可以从 MySQL 恢复。

  4. 用消息队列做异步削峰(如果峰值 QPS 特别高):点赞请求先进 Kafka,消费者慢慢处理,彻底保护后端存储。


Q22:除了 Caffeine + Redis,你有没有考虑过直接用本地累加、然后定时批量上报 Redis 的方案?这个方案和你们现在的方案比,各有什么优劣?

答:

方案 A(项目现有):每次写都同时写 MySQL + Redis(Pipeline)

优点:

  • 简单,容易理解
  • Redis 里的数据是准实时的(延迟 < 1ms)

缺点:

  • 每次点赞都要打一次 Redis,Redis QPS 和 点赞 QPS 是 1:1 的关系,量大了 Redis 会成为瓶颈
  • MySQL 也要每次都写,虽然项目里用 RabbitMQ 做了异步,但最终还是要写

方案 B(本地累加 + 定时批量上报)

1
2
Spring Boot 本地用 Map<articleId, AtomicLong> 累加
每隔 1 秒,把本地累加的结果批量 hIncrBy 到 Redis

优点:

  • Redis QPS 大幅降低(1 万 QPS 的点赞,可能只有 100 QPS 打到 Redis)
  • MySQL 也不用每次都写,可以批量 UPDATE

缺点:

  • 实时性变差:用户点赞后,要点赞数立刻 +1,但本地累加的结果可能 1 秒后才上报 Redis,这 1 秒内其他用户看到的赞数还是旧的
  • 数据丢失风险:如果 Spring Boot 实例宕机,本地内存里的累加数据还没上报,就丢了
  • 多实例不一致:A 实例累加的文章 123 的赞数,B 实例不知道,导致 Redis 里的数据不完整

方案 B 的改进(解决多实例问题)

Redis Lua 脚本做原子累加,同时兼顾了”不每次都写 MySQL”和”数据不丢”:

1
2
1. 每次点赞:Redis hIncrBy(保证不丢,且实时)
2. 每隔 N 次点赞:异步批量写 MySQL(不需要实时)

这其实就是项目现有方案去掉”每次都同步写 MySQL”,改为”Redis 实时写,MySQL 异步批量写”,是业界最常用的方法(微博、知乎都是这么做的)。


总结:简历上这句话,面试时怎么讲?

建议的回答思路(2 分钟版本)

我们论坛的点赞、阅读、评论计数,最开始是每次都写 MySQL 的,后来发现首页侧边栏要显示热文榜、文章阅读数,这些高频读的场景对 MySQL 压力很大。

所以引入了 Redis Hash 来存计数,用 hIncrBy 做原子累加,多个计数更新用 Pipeline 批量提交,减少 RTT。

侧边栏这种每个请求都要加载的数据,再加了一层 Caffeine 本地缓存,避免每个请求都打 Redis。

数据一致性方面,我们允许短期不一致,通过每天凌晨的定时任务从 MySQL 重新统计,全量覆盖 Redis,保证最终一致性。

如果量再大,下一步的优化方向是:应用层本地累加 + 定时批量上报 Redis,以及 Redis Key 分片解决热点 Key 问题。


文章基于技术派(PaiCoding)项目源码撰写,所有代码示例均来自项目实际代码。


技术派缓存+Pipeline面试题深度解析(22问·附源码)
https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-cache-pipeline-qa/
作者
Cassiur
发布于
2026年6月8日
许可协议