技术派项目学习笔记(一):三种缓存策略深度解析
写在前面:本学习笔记基于技术派(PaiCoding)项目源码逐行解析,深度讲解三种缓存策略(Write Through、Write Back、Cache Aside)的实现细节、优缺点、适用场景。并指出源码里的 5 处严重问题(❗❗ 标记),给出修复方案。适合面试前深度学习,确保”傻子都能懂”。
一、项目背景:高并发互动场景的缓存设计
技术派项目面临的高并发互动场景:
- 阅读量计数:用户每浏览一篇文章,阅读数 +1
- 点赞/收藏计数:用户点赞或收藏文章,计数 +1 或 -1
- 热文榜/排行榜:展示热门文章、活跃用户排行
核心挑战:
- 高频写操作(每秒 thousands 次点赞、阅读)
- 直接落库压力巨大(MySQL 扛不住)
- 需要实时展示计数(不能延迟太久)
解决方案:引入多级缓存 + 异步更新策略
二、三种缓存策略深度解析
2.1 Write Through(写通)— 阅读量计数
2.1.1 什么是 Write Through?
Write Through 策略:先写数据库,再写缓存,两者同时更新,保证强一致性。
工作流程:
- 客户端请求更新数据
- 先更新数据库(MySQL)
- 再更新缓存(Redis)
- 返回成功
优点:
- 强一致性(数据库和缓存数据一致)
- 实现简单
缺点:
- 写性能较差(需要等待两次写入完成)
- 如果缓存写入失败,会导致数据不一致
2.1.2 源码解析:CountServiceImpl.incrArticleReadCount()
文件位置:paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/CountServiceImpl.java
源码(第 101-111 行):
1 | |
逐行解析:
第 103 行:
articleDao.incrReadCount(articleId);- 先更新数据库!SQL 语句大概是:
UPDATE article SET read_count = read_count + 1 WHERE id = ? - 这是 Write Through 的核心:先写 DB
- 先更新数据库!SQL 语句大概是:
第 105-110 行:
RedisClient.pipelineAction()...- 再更新缓存!使用 Redis Pipeline 批量执行两个
HINCRBY命令 - 第一个:
HINCRBY article_statistic_info:{articleId} read_count 1 - 第二个:
HINCRBY user_statistic_info:{authorUserId} read_count 1 - 为什么用 Pipeline?减少 RTT(Round Trip Time),两个命令一次性发给 Redis
- 再更新缓存!使用 Redis Pipeline 批量执行两个
第 110 行:
.execute();- 执行 Pipeline,批量提交
为什么阅读量适合 Write Through?
- 阅读量是只增不减的计数,不需要很高的一致性
- 但用户刷新页面时,应该能看到最新的阅读量(所以不能只写 DB,不写缓存)
- Write Through 保证 DB 和缓存都有最新数据
2.1.3 ❗❗ 源码里的问题
问题 1:缓存写入失败,导致数据不一致!
看代码(第 103-111 行):
1 | |
场景重现:
- DB 写入成功(文章阅读量从 100 变成 101)
- Redis 写入失败(网络抖动、Redis 宕机)
- 用户刷新页面,从 Redis 读到的还是 100(实际应该是 101)
- 数据不一致!
为什么没有事务回滚?
- DB 和 Redis 是两个不同的系统,无法用数据库事务回滚
- 即使 Redis 写入失败,DB 的更新也不会回滚
修复方案 1:重试机制
1 | |
修复方案 2:先更新缓存,再更新数据库(调换顺序)
1 | |
为什么这样更好?
- Redis 写入失败,可以直接返回错误,不更新 DB
- DB 写入失败,可以捕获异常,但 Redis 已经写入了(可以用定时任务修正)
但这样也有问题:如果 DB 写入失败,Redis 已经写入了,也需要修正。
最佳实践:最终一致性
- 先更新缓存(Redis)
- 再更新数据库(MySQL)
- 如果 DB 写入失败,记录日志,定时任务修正 Redis
2.2 Write Back(写回)— 点赞量计数
2.2.1 什么是 Write Back?
Write Back 策略:只写缓存,不立即写数据库,延迟批量写回数据库。
工作流程:
- 客户端请求更新数据
- 只更新缓存(Redis)
- 异步批量更新数据库(延迟一段时间)
优点:
- 写性能极高(只写缓存,不写 DB)
- 减少数据库压力
缺点:
- 弱一致性(缓存和 DB 可能不一致)
- 数据丢失风险(缓存宕机,数据还没写回 DB)
2.2.2 源码解析:UserFootServiceImpl.favorArticleComment()
文件位置:paicoding-service/src/main/java/com/github/paicoding/forum/service/user/service/userfoot/UserFootServiceImpl.java
源码(第 90-137 行):
1 | |
逐行解析:
第 92-94 行:FIXME 注释(❗❗ 源码里的问题)
- “这里没有做并发控制,在大并发场景下,可能出现查询出来的数据,与db中数据不一致的场景”
- 说明作者知道有并发问题,但没修复
第 97 行:
userFootDao.getByDocumentAndUserId(...)- 先查 DB,看用户是否点过赞
- 如果没点过赞(
readUserFootDO == null),插入新记录 - 如果点过赞,更新记录(
userFootDao.updateById())
第 128-133 行:
rabbitmqService.publishMsg(...)- 发送消息到 RabbitMQ,通知消费端更新 Redis 计数
- 消息内容:
UserFootDO(包含点赞状态)
消费端(需要找到 RabbitMQ Consumer)
- 消费端收到消息后,更新 Redis 里的点赞计数(
HINCRBY article_statistic_info:{articleId} praise_count 1) - 这就是 Write Back 策略:DB 先写,Redis 异步更新
- 消费端收到消息后,更新 Redis 里的点赞计数(
为什么点赞量适合 Write Back?
- 点赞是高频操作,如果每次都更新 DB + Redis,性能压力大
- 点赞计数不需要强一致性(用户看不到瞬间的变化,也能接受)
- 通过 RabbitMQ 异步更新 Redis,提高吞吐量
2.2.3 ❗❗ 源码里的问题
问题 2:并发控制缺失,导致数据不一致!
看代码(第 92-94 行):
1 | |
场景重现:
- 用户快速点击点赞按钮 2 次(第一次点赞,第二次取消点赞)
- 线程 A 查询 DB:
praise_stat = 0(没点赞) - 线程 B 查询 DB:
praise_stat = 0(没点赞) - 线程 A 更新 DB:
praise_stat = 1(点赞) - 线程 B 更新 DB:
praise_stat = 1(点赞,应该是 0 才对) - 最终结果:DB 里是 1,但实际应该是 0(用户取消了点赞)
修复方案 1:分布式锁
1 | |
修复方案 2:数据库悲观锁
1 | |
修复方案 3:乐观锁(推荐)
在 user_foot 表加 version 字段,更新时带上版本号:
1 | |
如果更新失败(版本号不匹配),重试。
2.3 Cache Aside(旁路缓存)— ZSet 热文榜
2.3.1 什么是 Cache Aside?
Cache Aside 策略:读时先读缓存,缓存没有则读数据库并写入缓存;写时先写数据库,再删除缓存。
读流程:
- 先读缓存(Redis)
- 缓存有,直接返回
- 缓存没有,读数据库
- 把数据库结果写入缓存
写流程:
- 先更新数据库
- 再删除缓存(不是更新缓存!)
为什么删除缓存,而不是更新缓存?
- 避免复杂的缓存更新逻辑
- 避免并发场景下,缓存和 DB 数据不一致
2.3.2 源码解析:SidebarServiceImpl.queryHomeSidebarList()
文件位置:paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarServiceImpl.java
源码(第 58-68 行):
1 | |
逐行解析:
第 58 行:
@Cacheable(key = "'homeSidebar'", cacheManager = "caffeineCacheManager", cacheNames = "home")- Spring Cache 注解,表示这个方法的结果会被缓存
key = "'homeSidebar'":缓存 key 是home::homeSidebarcacheManager = "caffeineCacheManager":使用 Caffeine 本地缓存
第 59-68 行:方法体
- 如果缓存有,直接返回缓存结果(不执行方法体)
- 如果缓存没有,执行方法体,把结果写入缓存
这是 Cache Aside 吗?
- 是的!但用的是 Caffeine 本地缓存,不是 Redis
@Cacheable就是 Cache Aside 模式的实现:读时先读缓存,没有则读 DB 并写入缓存
热文榜的数据从哪来?
hotArticles()方法(第 128-132 行):1
2
3
4private SideBarDTO hotArticles() {
PageListVo<SimpleArticleDTO> vo = articleReadService.queryHotArticlesForRecommend(PageParam.newPageInstance(1, 8));
// ...
}queryHotArticlesForRecommend()内部会用 Redis ZSet 查询热文榜(按阅读量、点赞量排序)
2.3.3 ZSet 热文榜的 Cache Aside 实现
假设 queryHotArticlesForRecommend() 内部实现:
1 | |
这就是 Cache Aside 模式:
- 读时先读缓存(Redis ZSet)
- 缓存没有,读数据库
- 把数据库结果写入缓存
三、Redis Pipeline 批量提交深度解析
3.1 什么是 Pipeline?
Pipeline 是 Redis 的批量执行机制:把多个命令一次性发给 Redis,减少 RTT(Round Trip Time)。
普通模式:
1 | |
- 2 次 RTT(往返时间)
Pipeline 模式:
1 | |
- 1 次 RTT(批量发送)
3.2 源码解析:RedisClient.PipelineAction
文件位置:paicoding-core/src/main/java/com/github/paicoding/forum/core/cache/RedisClient.java
源码(第 440-475 行):
1 | |
逐行解析:
第 440-442 行:
pipelineAction()工厂方法- 返回一个新的
PipelineAction对象
- 返回一个新的
第 447-451 行:
PipelineAction类run:存储要执行的命令(Runnable 列表)connection:Redis 连接(在execute()里注入)
第 452-455 行:
add(key, conn)方法- 添加一个命令(只传 key)
- 示例:
connection -> connection.del(keyBytes)
第 457-460 行:
add(key, field, conn)方法- 添加一个命令(传 key 和 field)
- 示例:
(connection, key, value) -> connection.hIncrBy(key, value, 1)
第 462-468 行:
execute()方法- 调用
template.executePipelined(),批量执行所有命令 PipelineAction.this.connection = connection:把连接注入到PipelineActionrun.forEach(Runnable::run):执行所有命令
- 调用
3.3 ❗❗ 源码里的问题
问题 3:Pipeline 不保证原子性!
看代码(第 462-468 行):
1 | |
问题:
- Pipeline 只是批量发送命令,不保证原子性
- 如果第 1 个命令成功,第 2 个命令失败,第 1 个命令不会回滚
场景重现:
1 | |
结果:article:1 的 read_count 已经 +1,但 article:2 的没更新。
修复方案:使用 Redis 事务(MULTI/EXEC)
1 | |
但这样会影响性能(事务需要等待所有命令执行完)。
最佳实践:
- 如果命令之间没有依赖关系,用 Pipeline(性能好)
- 如果命令之间有依赖关系,用事务(保证原子性)
四、主从读写分离深度解析
4.1 为什么需要主从读写分离?
问题:
- 单台 MySQL 扛不住高并发(写操作和读操作都在一台机器上)
- 写操作少,读操作多(读写比例 1:10 甚至 1:100)
解决方案:
- 主库(Master):负责写操作
- 从库(Slave):负责读操作
- 主库数据同步到从库(MySQL 主从复制)
4.2 源码解析:DsAspect
文件位置:paicoding-core/src/main/java/com/github/paicoding/forum/core/dal/DsAspect.java
源码(第 16-52 行):
1 | |
逐行解析:
第 21-23 行:
@Pointcut("@annotation(...) || @within(...)")- 切入点:拦截有
@DsAno注解的方法或类
- 切入点:拦截有
第 25-40 行:
@Around("pointcut()")- AOP 环绕通知:在方法执行前后切换数据源
第 29-32 行:
DsContextHolder.set(...)- 把数据源名称写入 ThreadLocal
- 后续 DAO 层会从 ThreadLocal 里读取数据源名称,选择对应的数据库连接
第 33 行:
proceedingJoinPoint.proceed()- 执行目标方法(比如
articleDao.incrReadCount(articleId))
- 执行目标方法(比如
第 35-39 行:
finally { DsContextHolder.reset(); }- 方法执行完,清空 ThreadLocal(避免内存泄漏)
4.3 如何使用 @DsAno 注解?
示例:
1 | |
原理:
@DsAno("master"):把"master"写入 ThreadLocal- DAO 层执行 SQL 时,从 ThreadLocal 读取数据源名称,选择对应的数据库连接
五、面试八股文
5.1 缓存策略对比
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Write Through | 强一致性 | 写性能差 | 阅读量计数 |
| Write Back | 写性能高 | 弱一致性,数据丢失风险 | 点赞量计数 |
| Cache Aside | 实现简单 | 缓存失效问题 | 热文榜、排行榜 |
5.2 Redis Pipeline 的优点和局限性
优点:
- 减少 RTT,提高吞吐量
- 适合批量写操作
局限性:
- 不保证原子性(需要用事务)
- 如果 Pipeline 里的命令太多,会阻塞 Redis(需要分批)
5.3 主从读写分离的优点和局限性
优点:
- 提高读性能(多个从库分担读压力)
- 提高可用性(主库挂了,从库还能读)
局限性:
- 主从复制延迟(从库数据可能落后主库几秒)
- 写操作只能走主库(单点故障风险)
六、总结
技术派项目的缓存设计:
- 阅读量计数:Write Through + Redis Pipeline
- 点赞量计数:Write Back + RabbitMQ 异步更新
- 热文榜:Cache Aside + Redis ZSet
- 主从读写分离:AOP + ThreadLocal
源码里的问题(❗❗):
- Write Through 缓存写入失败,导致数据不一致
- Write Back 并发控制缺失,导致数据不一致
- Pipeline 不保证原子性
- 主从复制延迟,导致读取到旧数据
- RabbitMQ 消息丢失风险(需要 Confirm 机制 + 死信队列)
修复方案:
- 重试机制(缓存写入失败)
- 分布式锁 / 乐观锁(并发控制)
- Redis 事务(保证原子性)
- 延迟双删(解决主从复制延迟)
- RabbitMQ Confirm + 死信队列(消息可靠性)
下一篇预告:《技术派项目学习笔记(二):RabbitMQ 异步解耦深度解析》
参考资料:
- 技术派项目源码:https://github.com/itwanger/paicoding
- Redis 官方文档:https://redis.io/docs/
- Spring Cache 官方文档:https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#cache
最后更新:2026-06-08 21:30:00