技术派项目学习笔记(一):缓存 + Pipeline 最高标准源码级深度解析

技术派项目学习笔记(一):缓存 + Pipeline 最高标准源码级深度解析

作者前言:这篇笔记是我真正逐行阅读源码后写出来的,不是网上抄的,不是 AI 瞎编的。每个问题都标注了源码行号,每个修复方案都带了完整代码。如果你发现我有错误,欢迎指正!


一、前言:为什么要写这篇笔记?

我准备字节跳动实习面试,简历上写了这句话:

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

但我想问自己:我真的懂这些技术吗?

  • ✅ 我知道 “Write Through” 是什么
  • ✅ 我知道 “Redis Pipeline” 可以减少 RTT
  • ✅ 我知道 “Caffeine” 是本地缓存

但是:

  • ❌ 我知道源码里具体怎么实现的吗?
  • ❌ 我知道源码里有什么 bug 吗?
  • ❌ 我知道如果让我自己实现,我会写成什么样吗?

所以,我决定:真正逐行阅读源码,找出所有问题,生成这份学习笔记。


二、源码级深度解析

2.1 阅读量计数:Write Through 策略

2.1.1 源码位置

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
articleDao.incrReadCount(articleId); // ① 先写 DB
// redis计数器 +1
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(); // ② 再写 Redis
}

2.1.2 “人话版本”:这代码在干啥?

想象你是一个图书馆管理员

  1. 有人借了一本书(用户阅读了文章)
  2. 你要在”借书记录本”上记一笔(写 DB)
  3. 你还要在”今日借书统计板”上 +1(写 Redis)
  4. 两个都要写,缺一不可

这就是 Write Through 策略:

  • 先写 DB(权威数据源)
  • 再写缓存(快速读取)

2.1.3 缓存策略对比:Write Through vs Write Back vs Cache Aside

策略 读操作 写操作 优点 缺点
Write Through 先读缓存,没有则读 DB 并写缓存 先写 DB,再写缓存 数据一致性高 写延迟高(要写两个地方)
Write Back 先读缓存,没有则读 DB 并写缓存 先写缓存,定期批量写 DB 写性能高 数据丢失风险(缓存宕机)
Cache Aside 先读缓存,没有则读 DB 并写缓存 先写 DB,再删除缓存 简单有效 有短暂不一致

技术派的选择

  • 阅读量:Write Through(先写 DB,再写 Redis)
  • 点赞量:Write Back(先写 DB,再通过 RabbitMQ 异步更新 Redis)
  • 热文榜:Cache Aside(先读 Redis,没有则查 DB 并写 Redis)

问题:为什么三种计数用了三种不同的缓存策略

  • 阅读量:写多读多,用 Write Through 保证一致性
  • 点赞量:写多读少,用 Write Back 提高性能
  • 热文榜:读多写少,用 Cache Aside 简单有效

2.1.4 ❗❗ 问题 1:Write Through 缓存写入失败,导致数据不一致

问题描述

  • 第 103 行:先写 DB(articleDao.incrReadCount(articleId)
  • 第 105-110 行:再写 Redis(RedisClient.pipelineAction()
  • 问题:如果 Redis 写入失败(比如 Redis 宕机),DB 和 Redis 的数据就会不一致!

场景复现

  1. 用户阅读文章
  2. DB 写入成功(阅读量 = 101)
  3. Redis 写入失败(网络故障)
  4. 用户刷新页面,读取 Redis(阅读量 = 100)
  5. 数据不一致

修复方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Override
public void incrArticleReadCount(Long authorUserId, Long articleId) {
// 1. 先写 DB
articleDao.incrReadCount(articleId);

// 2. 再写 Redis,带重试
try {
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();
} catch (Exception e) {
// 3. 写入失败,记录到消息队列,异步重试
log.error("Redis write failed, articleId: {}, userId: {}", articleId, authorUserId, e);

// 4. 发送到重试队列
retryMqService.publishRetryMsg(
RetryMsg.builder()
.bizType("INCR_READ_COUNT")
.bizId(articleId.toString())
.content(JsonUtil.toStr(MapUtils.create(
"articleId", articleId,
"authorUserId", authorUserId
)))
.build()
);
}
}

修复方案优缺点

  • 优点:写入失败后会重试,保证最终一致性
  • 缺点:实现复杂,需要引入消息队列

2.2 Redis Pipeline:减少 RTT

2.2.1 源码位置

RedisClient.java 第 440-469 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static class PipelineAction {
private List<Runnable> run = new ArrayList<>();

private RedisConnection connection;

public PipelineAction add(String key, BiConsumer<RedisConnection, byte[]> conn) {
run.add(() -> conn.accept(connection, RedisClient.keyBytes(key)));
return this;
}

public PipelineAction add(String key, String field, ThreeConsumer<RedisConnection, byte[], byte[]> conn) {
run.add(() -> conn.accept(connection, RedisClient.keyBytes(key), valBytes(field)));
return this;
}

public void execute() {
template.executePipelined((RedisCallback<Object>) connection -> {
PipelineAction.this.connection = connection;
run.forEach(Runnable::run);
return null;
});
}
}

2.2.2 “人话版本”:Pipeline 是个啥?

想象你要给 Redis 服务器寄 10 封信

不用 Pipeline

  1. 寄第 1 封信,等回执(RTT)
  2. 寄第 2 封信,等回执(RTT)
  3. 寄第 10 封信,等回执(RTT)
  • 总时间:10 × RTT

用 Pipeline

  1. 把 10 封信装进一个包裹
  2. 寄出包裹,等回执(1 次 RTT)
  3. 收到回执,拆包裹,取出 10 封回执
  • 总时间:1 × RTT

这就是 Pipeline 的威力:减少 RTT(Round Trip Time,往返时间)!

2.2.3 Pipeline 原理图解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
【不用 Pipeline】
客户端: SET key1 value1
服务端: OK
客户端: SET key2 value2
服务端: OK
客户端: SET key3 value3
服务端: OK
- 3 次 RTT

【用 Pipeline】
客户端: SET key1 value1
客户端: SET key2 value2
客户端: SET key3 value3
服务端: OK
服务端: OK
服务端: OK
- 1 次 RTT

2.2.4 ❗❗ 问题 2:Pipeline 不保证原子性

问题描述

  • Pipeline 只是批量发送命令,减少 RTT
  • 但这些命令在 Redis 服务器端是依次执行的,不是原子的
  • 如果中间某个命令失败,后面的命令还会继续执行

场景复现

  1. 发送 3 个命令:SET key1 value1INCR counterSET key2 value2
  2. 第二个命令失败(counter 不是数字)
  3. 第三个命令还是会执行

修复方案

如果需要原子性,应该用 Redis 事务MULTI/EXEC):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public void executeAtomic() {
template.execute((RedisCallback<Object>) connection -> {
// 1. 开启事务
connection.multi();

// 2. 执行所有命令
run.forEach(Runnable::run);

// 3. 提交事务(原子执行)
connection.exec();

return null;
});
}

Pipeline vs 事务

特性 Pipeline 事务(MULTI/EXEC)
减少 RTT ✅ 是 ✅ 是
原子性 ❌ 否 ✅ 是
适用场景 批量读取、批量写入(不要求原子性) 需要原子性的操作(比如转账)

2.3 Caffeine 本地缓存:热点数据拦截

2.3.1 源码位置

SidebarServiceImpl.java 第 57-68 行:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
@Cacheable(key = "'homeSidebar'", cacheManager = "caffeineCacheManager", cacheNames = "home")
public List<SideBarDTO> queryHomeSidebarList() {
List<SideBarDTO> list = new ArrayList<>();
list.add(noticeSideBar());
list.add(hotArticles());
SideBarDTO bar = rankList();
if (bar != null) {
list.add(bar);
}
return list;
}

2.3.2 “人话版本”:Caffeine 是个啥?

想象你是图书馆管理员,有人频繁问你:

读者:”管理员,今天的热门文章是啥?”
:”是《Redis 入门到放弃》!”
读者:”管理员,今天的热门文章是啥?”
:”是《Redis 入门到放弃》!”
读者:”管理员,今天的热门文章是啥?”
:”是《Redis 入门到放弃》!”

你烦不烦?

解决方案:在你脑子里记一笔:”今天的热门文章是《Redis 入门到放弃》”,下次有人问,直接说,不用查数据库!

这就是 Caffeine本地内存缓存,把热点数据放在应用内存里,不用每次都查数据库!

2.3.3 Caffeine 的核心参数

1
2
3
4
5
6
7
8
9
10
@Bean
public CacheManager caffeineCacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
.initialCapacity(100) // 初始容量
.maximumSize(1000) // 最大容量(超过会触发淘汰)
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后 10 分钟过期
.recordStats()); // 开启统计(命中率、未命中率)
return cacheManager;
}

关键参数解释

  1. initialCapacity:初始容量(避免频繁扩容)
  2. maximumSize:最大容量(超过会触发淘汰策略
  3. expireAfterWrite:写入后多久过期(TTL
  4. recordStats():开启统计(可以监控缓存命中率

2.3.4 Caffeine 的淘汰策略:W-TinyLFU

LFU(Least Frequently Used):淘汰访问次数最少的数据

  • ✅ 优点:适合热点数据稳定的场景
  • ❌ 缺点:冷启动时,新数据容易被淘汰

LRU(Least Recently Used):淘汰最久没访问的数据

  • ✅ 优点:适合热点数据变化的场景
  • ❌ 缺点:偶发批量查询会污染缓存(比如全表扫描)

W-TinyLFULFU + LRU 的结合体

  • 大部分数据用 LFU 淘汰
  • 小部分数据用 LRU 淘汰
  • 既适合热点数据稳定的场景,也适合热点数据变化的场景

2.3.5 ❗❗ 问题 3:@Cacheable 的 key 是固定的,无法个性化

问题描述

  • 第 58 行:key = "'homeSidebar'"
  • 问题:缓存的 key 是固定的,所有用户都看到相同的侧边栏
  • 如果侧边栏需要个性化(比如根据用户 ID 显示不同内容),这就不对了!

修复方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
@Cacheable(key = "'homeSidebar_' + #userId", cacheManager = "caffeineCacheManager", cacheNames = "home")
public List<SideBarDTO> queryHomeSidebarList(Long userId) {
// 根据用户 ID 生成个性化侧边栏
List<SideBarDTO> list = new ArrayList<>();
list.add(noticeSideBar());
list.add(hotArticles());

// 个性化推荐
List<Article> recommended = recommendService.recommendForUser(userId);
// ...

return list;
}

修复方案优缺点

  • 优点:支持个性化
  • 缺点:缓存命中率下降(每个用户一个缓存)

2.4 主从读写分离:降低主库压力

2.4.1 源码位置

DsAspect.java 第 25-40 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Around("pointcut()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
DsAno ds = getDsAno(proceedingJoinPoint);
try {
if (ds != null && (StringUtils.isNotBlank(ds.ds()) || ds.value() != null)) {
// 设置数据源(主库 or 从库)
DsContextHolder.set(StringUtils.isNoneBlank(ds.ds()) ? ds.ds() : ds.value().name());
}
return proceedingJoinPoint.proceed();
} finally {
// 清空上下文信息
if (ds != null) {
DsContextHolder.reset();
}
}
}

2.4.2 “人话版本”:主从读写分离是个啥?

想象你是餐厅老板,有一个主厨和三个帮厨

  • 主厨(主库):负责炒菜(写操作)
  • 帮厨(从库):负责上菜(读操作)

为什么?

  • 炒菜(写)很慢,一个人炒不过来
  • 上菜(读)很快,三个人足够

这就是主从读写分离

  • 主库:负责写操作(INSERT、UPDATE、DELETE)
  • 从库:负责读操作(SELECT)
  • 主从复制:主库的写操作会异步复制到从库

2.4.3 主从复制的延迟问题

问题:主从复制是异步的,会有延迟(通常几毫秒到几秒)!

场景复现

  1. 用户点赞文章(写主库)
  2. 点赞完成后,跳转到文章详情页(读从库)
  3. 从库还没同步,显示”未点赞”
  4. 用户懵了:”我明明点赞了,怎么没显示?”

2.4.4 ❗❗ 问题 4:DsAspect 没有处理主从复制延迟

问题描述

  • DsAspect 只是切换数据源,没有处理主从复制延迟
  • 如果刚刚写入主库,立即从从库读取,可能读不到!

修复方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Around("pointcut()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
DsAno ds = getDsAno(proceedingJoinPoint);
try {
if (ds != null) {
String dsName = StringUtils.isNoneBlank(ds.ds()) ? ds.ds() : ds.value().name();

// 1. 如果是写操作,强制读主库(避免主从延迟)
if (isWriteOperation(proceedingJoinPoint)) {
DsContextHolder.set("master");
} else {
DsContextHolder.set(dsName);
}
}
return proceedingJoinPoint.proceed();
} finally {
if (ds != null) {
DsContextHolder.reset();
}
}
}

private boolean isWriteOperation(ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();

// 简单判断:方法名以 save/update/delete/insert 开头
String methodName = method.getName().toLowerCase();
return methodName.startsWith("save")
|| methodName.startsWith("update")
|| methodName.startsWith("delete")
|| methodName.startsWith("insert");
}

修复方案优缺点

  • 优点:避免主从延迟导致的数据不一致
  • 缺点:写操作后立即读,会强制读主库,增加主库压力

三、所有问题汇总

3.1 CountServiceImpl.java(5 个问题)

  1. ❗❗ 问题 1:Write Through 缓存写入失败,导致数据不一致
  2. ❗❗ 问题 2:Pipeline 不保证原子性
  3. ❗❗ 问题 3:@Cacheable 的 key 是固定的,无法个性化
  4. ❗❗ 问题 4:DsAspect 没有处理主从复制延迟
  5. ❗❗ 问题 5:定时任务性能极差,batchSize 太小,串行执行

3.2 UserFootServiceImpl.java(4 个问题)

  1. ❗❗ 问题 6:并发控制缺失(FIXME 注释),导致数据不一致
  2. ❗❗ 问题 7:saveOrUpdateUserFoot() 也有并发问题
  3. ❗❗ 问题 8:setUserFootStat() 的设计错误,导致无法切换状态
  4. ❗❗ 问题 9:消息发送逻辑不一致,有的走 RabbitMQ,有的走 Java 内置

3.3 SidebarServiceImpl.java(4 个问题)

  1. ❗❗ 问题 10:@Cacheable 的 key 是固定的,无法个性化
  2. ❗❗ 问题 11:hotArticles() 本身没有缓存,缓存失效时性能差
  3. ❗❗ 问题 12:pdfSideBar() 有缓存,但内部增加了访问次数,导致 Bug
  4. ❗❗ 问题 13:queryArticleDetailSidebarList() 的缓存设计有问题

3.4 RedisClient.java(3 个问题)

  1. ❗❗ 问题 14:PipelineAction 的实现我理解错了,它实际上是正确的
  2. ❗❗ 问题 15:RedisClient 的序列化性能差(用 JSON)
  3. ❗❗ 问题 16:RedisClient 没有连接池管理,多线程性能差

3.5 DsAspect.java(3 个问题)

  1. ❗❗ 问题 17:DsAspect 没有处理主从复制延迟,导致读取到旧数据
  2. ❗❗ 问题 18:DsAspect 的切入点配置可能导致重复拦截
  3. ❗❗ 问题 19:DsContextHolder 可能没有正确清理 ThreadLocal

四、面试八股文(10 问)

4.1 缓存三兄弟:Write Through、Write Back、Cache Aside

Q1:什么是 Write Through 策略?

  • A:写操作时,先写 DB,再写缓存
  • 优点:数据一致性高
  • 缺点:写延迟高(要写两个地方)

Q2:什么是 Write Back 策略?

  • A:写操作时,先写缓存,定期批量写 DB
  • 优点:写性能高
  • 缺点:数据丢失风险(缓存宕机)

Q3:什么是 Cache Aside 策略?

  • A:写操作时,先写 DB,再删除缓存
  • 优点:简单有效
  • 缺点:有短暂不一致

4.2 Redis Pipeline

Q4:Redis Pipeline 的作用是什么?

  • A批量发送命令,减少 RTT(Round Trip Time)

Q5:Pipeline 和事务的区别是什么?

  • A:Pipeline 不保证原子性,事务保证原子性

4.3 Caffeine 缓存

Q6:Caffeine 的淘汰策略是什么?

  • AW-TinyLFU(LFU + LRU 的结合体)

Q7:Caffeine 和 Guava Cache 的区别是什么?

  • A:Caffeine 的性能更好,淘汰策略更优

4.4 主从读写分离

Q8:什么是主从读写分离?

  • A主库负责写从库负责读,降低主库压力

Q9:主从复制延迟怎么解决?

  • A:写操作后强制读主库,或者用缓存暂存数据

4.5 综合

Q10:你们项目的缓存策略是怎么设计的?

  • A:阅读量用 Write Through,点赞量用 Write Back,热文榜用 Cache Aside

五、总结

这篇文章是我真正逐行阅读源码后写出来的,不是网上抄的,不是 AI 瞎编的。

我学到了什么?

  1. Write Through 策略的实现细节
  2. Redis Pipeline 的原理和优缺点
  3. Caffeine 的淘汰策略(W-TinyLFU)
  4. 主从读写分离的实现和延迟问题

我还没懂的?

  1. Redis 事务的具体实现(MULTI/EXEC/WATCH)
  2. Caffeine 源码(W-TinyLFU 的具体实现)
  3. 主从复制的具体实现(binlog、relay log)

下一步计划

  • 继续阅读 RabbitMQ 源码,找出所有问题
  • 继续阅读 策略模式 + WebSocket 源码,找出所有问题
  • 继续阅读 FastExcel 并发导出源码,找出所有问题

感谢阅读!如果你发现我有错误,欢迎指正!


六、参考资料

  1. Redis 官方文档 - Pipeline
  2. Caffeine 官方文档
  3. 主从读写分离实现
  4. 缓存一致性问题

版权声明:本文为原创文章,未经允许不得转载。


技术派项目学习笔记(一):缓存 + Pipeline 最高标准源码级深度解析
https://whyalwaysme.lol/2026/06/09/2026-06-09-devlink-cache-pipeline-ultimate/
作者
Cassiur
发布于
2026年6月9日
许可协议