技术派项目学习笔记(一):三种缓存策略深度解析

写在前面:本学习笔记基于技术派(PaiCoding)项目源码逐行解析,深度讲解三种缓存策略(Write Through、Write Back、Cache Aside)的实现细节、优缺点、适用场景。并指出源码里的 5 处严重问题(❗❗ 标记),给出修复方案。适合面试前深度学习,确保”傻子都能懂”。


一、项目背景:高并发互动场景的缓存设计

技术派项目面临的高并发互动场景:

  • 阅读量计数:用户每浏览一篇文章,阅读数 +1
  • 点赞/收藏计数:用户点赞或收藏文章,计数 +1 或 -1
  • 热文榜/排行榜:展示热门文章、活跃用户排行

核心挑战

  • 高频写操作(每秒 thousands 次点赞、阅读)
  • 直接落库压力巨大(MySQL 扛不住)
  • 需要实时展示计数(不能延迟太久)

解决方案:引入多级缓存 + 异步更新策略


二、三种缓存策略深度解析

2.1 Write Through(写通)— 阅读量计数

2.1.1 什么是 Write Through?

Write Through 策略:先写数据库,再写缓存,两者同时更新,保证强一致性。

工作流程

  1. 客户端请求更新数据
  2. 先更新数据库(MySQL)
  3. 再更新缓存(Redis)
  4. 返回成功

优点

  • 强一致性(数据库和缓存数据一致)
  • 实现简单

缺点

  • 写性能较差(需要等待两次写入完成)
  • 如果缓存写入失败,会导致数据不一致

2.1.2 源码解析:CountServiceImpl.incrArticleReadCount()

文件位置paicoding-service/src/main/java/com/github/paicoding/forum/service/statistics/service/impl/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);
// 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();
}

逐行解析

  1. 第 103 行articleDao.incrReadCount(articleId);

    • 先更新数据库!SQL 语句大概是:UPDATE article SET read_count = read_count + 1 WHERE id = ?
    • 这是 Write Through 的核心:先写 DB
  2. 第 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
  3. 第 110 行.execute();

    • 执行 Pipeline,批量提交

为什么阅读量适合 Write Through?

  • 阅读量是只增不减的计数,不需要很高的一致性
  • 但用户刷新页面时,应该能看到最新的阅读量(所以不能只写 DB,不写缓存)
  • Write Through 保证 DB 和缓存都有最新数据

2.1.3 ❗❗ 源码里的问题

问题 1:缓存写入失败,导致数据不一致!

看代码(第 103-111 行)

1
2
articleDao.incrReadCount(articleId);  // DB 写入成功
RedisClient.pipelineAction()...execute(); // 如果这里失败了呢?

场景重现

  1. DB 写入成功(文章阅读量从 100 变成 101)
  2. Redis 写入失败(网络抖动、Redis 宕机)
  3. 用户刷新页面,从 Redis 读到的还是 100(实际应该是 101)
  4. 数据不一致

为什么没有事务回滚?

  • DB 和 Redis 是两个不同的系统,无法用数据库事务回滚
  • 即使 Redis 写入失败,DB 的更新也不会回滚

修复方案 1:重试机制

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
@Override
public void incrArticleReadCount(Long authorUserId, Long articleId) {
// 1. 先更新数据库
articleDao.incrReadCount(articleId);

// 2. 更新缓存,失败重试 3 次
int retry = 3;
while (retry > 0) {
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();
break; // 成功,跳出重试循环
} catch (Exception e) {
retry--;
if (retry == 0) {
// 重试 3 次都失败,记录日志,人工介入
log.error("更新 Redis 缓存失败,articleId: {}, authorUserId: {}", articleId, authorUserId, e);
// 可以选择:1. 写入本地消息表,定时重试;2. 发送到 Kafka,异步重试
} else {
// 等待一段时间后重试(指数退避)
Thread.sleep((long) (Math.pow(2, 3 - retry) * 100));
}
}
}
}

修复方案 2:先更新缓存,再更新数据库(调换顺序)

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void incrArticleReadCount(Long authorUserId, Long articleId) {
// 1. 先更新缓存(Redis 性能好,失败概率低)
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();

// 2. 再更新数据库
articleDao.incrReadCount(articleId);
}

为什么这样更好?

  • Redis 写入失败,可以直接返回错误,不更新 DB
  • DB 写入失败,可以捕获异常,但 Redis 已经写入了(可以用定时任务修正)

但这样也有问题:如果 DB 写入失败,Redis 已经写入了,也需要修正。

最佳实践最终一致性

  • 先更新缓存(Redis)
  • 再更新数据库(MySQL)
  • 如果 DB 写入失败,记录日志,定时任务修正 Redis

2.2 Write Back(写回)— 点赞量计数

2.2.1 什么是 Write Back?

Write Back 策略:只写缓存,不立即写数据库,延迟批量写回数据库。

工作流程

  1. 客户端请求更新数据
  2. 只更新缓存(Redis)
  3. 异步批量更新数据库(延迟一段时间)

优点

  • 写性能极高(只写缓存,不写 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
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@Override
public void favorArticleComment(DocumentTypeEnum documentType, Long documentId, Long authorId, Long userId, OperateTypeEnum operateTypeEnum) {
// fixme 这里没有做并发控制,在大并发场景下,可能出现查询出来的数据,与db中数据不一致的场景
// fixme 解决方案:自旋等待的分布式锁 or 事务 + 悲观锁
// fixme 考虑到这个足迹的准确性影响并不大,留待有缘人进行修正

// 查询是否有该足迹;有则更新,没有则插入
UserFootDO readUserFootDO = userFootDao.getByDocumentAndUserId(documentId, documentType.getCode(), userId);
boolean dbChanged = false;
if (readUserFootDO == null) {
readUserFootDO = new UserFootDO();
readUserFootDO.setUserId(userId);
readUserFootDO.setDocumentId(documentId);
readUserFootDO.setDocumentType(documentType.getCode());
readUserFootDO.setDocumentUserId(authorId);
setUserFootStat(readUserFootDO, operateTypeEnum);
userFootDao.save(readUserFootDO);
dbChanged = true;
} else if (setUserFootStat(readUserFootDO, operateTypeEnum)) {
readUserFootDO.setUpdateTime(new Date());
userFootDao.updateById(readUserFootDO);
dbChanged = true;
}

if (!dbChanged) {
// 幂等,直接返回
return;
}

// 点赞、收藏两种操作时,需要发送异步消息,用于生成消息通知、更新文章/评论的相关计数统计、更新用户的活跃积分
NotifyTypeEnum notifyType = OperateTypeEnum.getNotifyType(operateTypeEnum);
if (notifyType == null) {
// 不需要发送通知的场景,直接返回
return;
}

// 点赞消息走 RabbitMQ,其它走 Java 内置消息机制
if (notifyType.equals(NotifyTypeEnum.PRAISE) && rabbitmqService.enabled()) {
rabbitmqService.publishMsg(
CommonConstants.EXCHANGE_NAME_DIRECT,
BuiltinExchangeType.DIRECT,
CommonConstants.QUERE_KEY_PRAISE,
JsonUtil.toStr(readUserFootDO));
} else {
MsgNotifyHelper.publish(notifyType, readUserFootDO);
}
}

逐行解析

  1. 第 92-94 行:FIXME 注释(❗❗ 源码里的问题)

    • “这里没有做并发控制,在大并发场景下,可能出现查询出来的数据,与db中数据不一致的场景”
    • 说明作者知道有并发问题,但没修复
  2. 第 97 行userFootDao.getByDocumentAndUserId(...)

    • 先查 DB,看用户是否点过赞
    • 如果没点过赞(readUserFootDO == null),插入新记录
    • 如果点过赞,更新记录(userFootDao.updateById()
  3. 第 128-133 行rabbitmqService.publishMsg(...)

    • 发送消息到 RabbitMQ,通知消费端更新 Redis 计数
    • 消息内容:UserFootDO(包含点赞状态)
  4. 消费端(需要找到 RabbitMQ Consumer)

    • 消费端收到消息后,更新 Redis 里的点赞计数(HINCRBY article_statistic_info:{articleId} praise_count 1
    • 这就是 Write Back 策略:DB 先写,Redis 异步更新

为什么点赞量适合 Write Back?

  • 点赞是高频操作,如果每次都更新 DB + Redis,性能压力大
  • 点赞计数不需要强一致性(用户看不到瞬间的变化,也能接受)
  • 通过 RabbitMQ 异步更新 Redis,提高吞吐量

2.2.3 ❗❗ 源码里的问题

问题 2:并发控制缺失,导致数据不一致!

看代码(第 92-94 行)

1
2
// fixme 这里没有做并发控制,在大并发场景下,可能出现查询出来的数据,与db中数据不一致的场景
// fixme 解决方案:自旋等待的分布式锁 or 事务 + 悲观锁

场景重现

  1. 用户快速点击点赞按钮 2 次(第一次点赞,第二次取消点赞)
  2. 线程 A 查询 DB:praise_stat = 0(没点赞)
  3. 线程 B 查询 DB:praise_stat = 0(没点赞)
  4. 线程 A 更新 DB:praise_stat = 1(点赞)
  5. 线程 B 更新 DB:praise_stat = 1(点赞,应该是 0 才对)
  6. 最终结果:DB 里是 1,但实际应该是 0(用户取消了点赞)

修复方案 1:分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
public void favorArticleComment(DocumentTypeEnum documentType, Long documentId, Long authorId, Long userId, OperateTypeEnum operateTypeEnum) {
String lockKey = "lock:user_foot:" + userId + ":" + documentId;
RLock lock = redisson.getLock(lockKey);
try {
lock.lock(5, TimeUnit.SECONDS); // 获取分布式锁,超时 5 秒

// 查询是否有该足迹;有则更新,没有则插入
UserFootDO readUserFootDO = userFootDao.getByDocumentAndUserId(documentId, documentType.getCode(), userId);
// ... 后续逻辑
} finally {
lock.unlock(); // 释放锁
}
}

修复方案 2:数据库悲观锁

1
2
3
4
5
public void favorArticleComment(DocumentTypeEnum documentType, Long documentId, Long authorId, Long userId, OperateTypeEnum operateTypeEnum) {
// 使用 SELECT ... FOR UPDATE 加行锁
UserFootDO readUserFootDO = userFootDao.getByDocumentAndUserIdForUpdate(documentId, documentType.getCode(), userId);
// ... 后续逻辑
}

修复方案 3:乐观锁(推荐)

user_foot 表加 version 字段,更新时带上版本号:

1
2
UPDATE user_foot SET praise_stat = 1, version = version + 1 
WHERE id = ? AND version = ?

如果更新失败(版本号不匹配),重试。


2.3 Cache Aside(旁路缓存)— ZSet 热文榜

2.3.1 什么是 Cache Aside?

Cache Aside 策略:读时先读缓存,缓存没有则读数据库并写入缓存;写时先写数据库,再删除缓存

读流程

  1. 先读缓存(Redis)
  2. 缓存有,直接返回
  3. 缓存没有,读数据库
  4. 把数据库结果写入缓存

写流程

  1. 先更新数据库
  2. 再删除缓存(不是更新缓存!)

为什么删除缓存,而不是更新缓存?

  • 避免复杂的缓存更新逻辑
  • 避免并发场景下,缓存和 DB 数据不一致

2.3.2 源码解析:SidebarServiceImpl.queryHomeSidebarList()

文件位置paicoding-service/src/main/java/com/github/paicoding/forum/service/sidebar/service/SidebarServiceImpl.java

源码(第 58-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;
}

逐行解析

  1. 第 58 行@Cacheable(key = "'homeSidebar'", cacheManager = "caffeineCacheManager", cacheNames = "home")

    • Spring Cache 注解,表示这个方法的结果会被缓存
    • key = "'homeSidebar'":缓存 key 是 home::homeSidebar
    • cacheManager = "caffeineCacheManager":使用 Caffeine 本地缓存
  2. 第 59-68 行:方法体

    • 如果缓存有,直接返回缓存结果(不执行方法体)
    • 如果缓存没有,执行方法体,把结果写入缓存

这是 Cache Aside 吗?

  • 是的!但用的是 Caffeine 本地缓存,不是 Redis
  • @Cacheable 就是 Cache Aside 模式的实现:读时先读缓存,没有则读 DB 并写入缓存

热文榜的数据从哪来?

  • hotArticles() 方法(第 128-132 行):
    1
    2
    3
    4
    private SideBarDTO hotArticles() {
    PageListVo<SimpleArticleDTO> vo = articleReadService.queryHotArticlesForRecommend(PageParam.newPageInstance(1, 8));
    // ...
    }
  • queryHotArticlesForRecommend() 内部会用 Redis ZSet 查询热文榜(按阅读量、点赞量排序)

2.3.3 ZSet 热文榜的 Cache Aside 实现

假设 queryHotArticlesForRecommend() 内部实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public PageListVo<SimpleArticleDTO> queryHotArticlesForRecommend(PageParam pageParam) {
// 1. 先读 Redis ZSet
String key = "article:hot";
Set<String> articleIds = RedisClient.zRevRange(key, 0, 7); // 取前 8 名

if (articleIds.isEmpty()) {
// 2. 缓存没有,读数据库
List<ArticleDO> articles = articleDao.queryHotArticles(pageParam);

// 3. 写入 Redis ZSet
articles.forEach(article -> {
RedisClient.zAdd(key, article.getId().toString(), article.getReadCount() + article.getPraiseCount());
});

// 4. 返回结果
return convertToPageListVo(articles);
}

// 5. 缓存有,直接返回
return convertToPageListVo(articleIds);
}

这就是 Cache Aside 模式

  • 读时先读缓存(Redis ZSet)
  • 缓存没有,读数据库
  • 把数据库结果写入缓存

三、Redis Pipeline 批量提交深度解析

3.1 什么是 Pipeline?

Pipeline 是 Redis 的批量执行机制:把多个命令一次性发给 Redis,减少 RTT(Round Trip Time)

普通模式

1
2
3
4
客户端 -> Redis: HINCRBY article:1 read_count 1
Redis -> 客户端: OK
客户端 -> Redis: HINCRBY user:100 read_count 1
Redis -> 客户端: OK
  • 2 次 RTT(往返时间)

Pipeline 模式

1
2
客户端 -> Redis: HINCRBY article:1 read_count 1\nHINCRBY user:100 read_count 1
Redis -> 客户端: OK\nOK
  • 1 次 RTT(批量发送)

3.2 源码解析:RedisClient.PipelineAction

文件位置paicoding-core/src/main/java/com/github/paicoding/forum/core/cache/RedisClient.java

源码(第 440-475 行)

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
public static PipelineAction pipelineAction() {
return new PipelineAction();
}

/**
* redis 管道执行的封装链路
*/
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;
});
}
}

逐行解析

  1. 第 440-442 行pipelineAction() 工厂方法

    • 返回一个新的 PipelineAction 对象
  2. 第 447-451 行PipelineAction

    • run:存储要执行的命令(Runnable 列表)
    • connection:Redis 连接(在 execute() 里注入)
  3. 第 452-455 行add(key, conn) 方法

    • 添加一个命令(只传 key)
    • 示例:connection -> connection.del(keyBytes)
  4. 第 457-460 行add(key, field, conn) 方法

    • 添加一个命令(传 key 和 field)
    • 示例:(connection, key, value) -> connection.hIncrBy(key, value, 1)
  5. 第 462-468 行execute() 方法

    • 调用 template.executePipelined(),批量执行所有命令
    • PipelineAction.this.connection = connection:把连接注入到 PipelineAction
    • run.forEach(Runnable::run):执行所有命令

3.3 ❗❗ 源码里的问题

问题 3:Pipeline 不保证原子性!

看代码(第 462-468 行)

1
2
3
4
5
6
7
public void execute() {
template.executePipelined((RedisCallback<Object>) connection -> {
PipelineAction.this.connection = connection;
run.forEach(Runnable::run);
return null;
});
}

问题

  • Pipeline 只是批量发送命令,不保证原子性
  • 如果第 1 个命令成功,第 2 个命令失败,第 1 个命令不会回滚

场景重现

1
2
3
4
RedisClient.pipelineAction()
.add("article:1", "read_count", (connection, key, value) -> connection.hIncrBy(key, value, 1)) // 成功
.add("article:2", "read_count", (connection, key, value) -> connection.hIncrBy(key, value, 1)) // 失败(article:2 不存在)
.execute();

结果article:1read_count 已经 +1,但 article:2 的没更新。

修复方案:使用 Redis 事务MULTI/EXEC

1
2
3
4
5
6
7
8
9
public void executeWithTransaction() {
template.execute((RedisCallback<Object>) connection -> {
connection.multi(); // 开启事务
PipelineAction.this.connection = connection;
run.forEach(Runnable::run);
connection.exec(); // 提交事务
return null;
});
}

但这样会影响性能(事务需要等待所有命令执行完)。

最佳实践

  • 如果命令之间没有依赖关系,用 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
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
34
35
36
37
@Aspect
public class DsAspect {
/**
* 切入点, 拦截类上、方法上有注解的方法,用于切换数据源
*/
@Pointcut("@annotation(com.github.paicoding.forum.core.dal.DsAno) || @within(com.github.paicoding.forum.core.dal.DsAno)")
public void pointcut() {
}

@Around("pointcut()")
public Object around(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
DsAno ds = getDsAno(proceedingJoinPoint);
try {
if (ds != null && (StringUtils.isNotBlank(ds.ds()) || ds.value() != null)) {
// 当上下文中没有时,则写入线程上下文,应该用哪个DB
DsContextHolder.set(StringUtils.isNoneBlank(ds.ds()) ? ds.ds() : ds.value().name());
}
return proceedingJoinPoint.proceed();
} finally {
// 清空上下文信息
if (ds != null) {
DsContextHolder.reset();
}
}
}

private DsAno getDsAno(ProceedingJoinPoint proceedingJoinPoint) {
MethodSignature signature = (MethodSignature) proceedingJoinPoint.getSignature();
Method method = signature.getMethod();
DsAno ds = method.getAnnotation(DsAno.class);
if (ds == null) {
// 获取类上的注解
ds = (DsAno) proceedingJoinPoint.getSignature().getDeclaringType().getAnnotation(DsAno.class);
}
return ds;
}
}

逐行解析

  1. 第 21-23 行@Pointcut("@annotation(...) || @within(...)")

    • 切入点:拦截有 @DsAno 注解的方法或类
  2. 第 25-40 行@Around("pointcut()")

    • AOP 环绕通知:在方法执行前后切换数据源
  3. 第 29-32 行DsContextHolder.set(...)

    • 把数据源名称写入 ThreadLocal
    • 后续 DAO 层会从 ThreadLocal 里读取数据源名称,选择对应的数据库连接
  4. 第 33 行proceedingJoinPoint.proceed()

    • 执行目标方法(比如 articleDao.incrReadCount(articleId)
  5. 第 35-39 行finally { DsContextHolder.reset(); }

    • 方法执行完,清空 ThreadLocal(避免内存泄漏)

4.3 如何使用 @DsAno 注解?

示例

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class ArticleServiceImpl implements ArticleService {
@DsAno("master") // 写操作,用主库
public void saveArticle(ArticleDO article) {
articleDao.save(article);
}

@DsAno("slave") // 读操作,用从库
public ArticleDO getArticleById(Long id) {
return articleDao.getById(id);
}
}

原理

  • @DsAno("master"):把 "master" 写入 ThreadLocal
  • DAO 层执行 SQL 时,从 ThreadLocal 读取数据源名称,选择对应的数据库连接

五、面试八股文

5.1 缓存策略对比

策略 优点 缺点 适用场景
Write Through 强一致性 写性能差 阅读量计数
Write Back 写性能高 弱一致性,数据丢失风险 点赞量计数
Cache Aside 实现简单 缓存失效问题 热文榜、排行榜

5.2 Redis Pipeline 的优点和局限性

优点

  • 减少 RTT,提高吞吐量
  • 适合批量写操作

局限性

  • 不保证原子性(需要用事务)
  • 如果 Pipeline 里的命令太多,会阻塞 Redis(需要分批)

5.3 主从读写分离的优点和局限性

优点

  • 提高读性能(多个从库分担读压力)
  • 提高可用性(主库挂了,从库还能读)

局限性

  • 主从复制延迟(从库数据可能落后主库几秒)
  • 写操作只能走主库(单点故障风险)

六、总结

技术派项目的缓存设计

  1. 阅读量计数:Write Through + Redis Pipeline
  2. 点赞量计数:Write Back + RabbitMQ 异步更新
  3. 热文榜:Cache Aside + Redis ZSet
  4. 主从读写分离:AOP + ThreadLocal

源码里的问题(❗❗)

  1. Write Through 缓存写入失败,导致数据不一致
  2. Write Back 并发控制缺失,导致数据不一致
  3. Pipeline 不保证原子性
  4. 主从复制延迟,导致读取到旧数据
  5. RabbitMQ 消息丢失风险(需要 Confirm 机制 + 死信队列)

修复方案

  • 重试机制(缓存写入失败)
  • 分布式锁 / 乐观锁(并发控制)
  • Redis 事务(保证原子性)
  • 延迟双删(解决主从复制延迟)
  • RabbitMQ Confirm + 死信队列(消息可靠性)

下一篇预告:《技术派项目学习笔记(二):RabbitMQ 异步解耦深度解析》


参考资料


最后更新:2026-06-08 21:30:00


技术派项目学习笔记(一):三种缓存策略深度解析
https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-cache-strategies-learn/
作者
Cassiur
发布于
2026年6月8日
许可协议