Redis + MySQL 缓存一致性:旁路缓存、删除策略与异常时序

文章发布时间:

最后更新时间:

Redis + MySQL 缓存一致性:旁路缓存、删除策略与异常时序

在后端系统里,Redis + MySQL 是非常典型的组合:MySQL 负责持久化,Redis 负责提升读取性能。但只要一个数据同时存在于数据库和缓存里,就一定会遇到一个问题:更新数据时,如何避免 Redis 和 MySQL 不一致?

这篇文章主要从实际开发角度讲清楚三个问题:

  1. 旁路缓存 Cache-Aside 是怎么工作的;
  2. 为什么更新数据时通常选择“先更新 MySQL,再删除 Redis”;
  3. 删除失败、并发读写、延迟双删这些异常时序应该怎么处理。

核心原则:MySQL 是数据源头,Redis 是派生缓存。缓存可以短暂不一致,但不能长期保存旧数据。


一、旁路缓存 Cache-Aside

最常见的缓存模型是 Cache-Aside Pattern,也叫旁路缓存。它的核心思想是:应用服务自己管理缓存,读数据时先查 Redis,缓存没有再查 MySQL,然后把结果写回 Redis。

Go实现的伪代码 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func GetUser(ctx context.Context, userID int64) (*User, error) {
key := fmt.Sprintf("user:%d", userID)

val, err := rdb.Get(ctx, key).Result()
if err == nil {
var user User
if json.Unmarshal([]byte(val), &user) == nil {
return &user, nil
}
}

user, err := userRepo.FindByID(ctx, userID)
if err != nil {
return nil, err
}

data, _ := json.Marshal(user)
_ = rdb.Set(ctx, key, data, 10*time.Minute).Err()
return user, nil
}

这里有两个细节:

  • Redis 查询失败时,一般不要直接让业务失败,因为 Redis 是缓存,不是主存储;
  • 写缓存时要设置 TTL,避免旧缓存永久存在。

二、更新数据时,为什么不是更新缓存?

很多人第一反应是:既然 MySQL 更新了,那我直接把 Redis 也更新成新值不就好了?

实际项目里更推荐:

1
先更新 MySQL,再删除 Redis

而不是:

1
先更新 MySQL,再更新 Redis

原因主要有三个。

第一,缓存可能不是单表数据。比如商品详情页缓存,可能包含商品表、图片表、SKU 表、评论统计等多个来源。数据库更新后,很难准确知道应该同步更新哪些缓存 key。

第二,并发写时容易出现旧值覆盖新值。比如两个请求同时更新同一个用户,A 先更新数据库,B 后更新数据库,但 A 最后才写 Redis,就可能把旧数据写回缓存。

第三,删除缓存更简单。缓存被删除后,下一次读取会重新查询 MySQL,并把最新数据加载到 Redis。

这里借用一个大佬的图来说明


三、为什么不能先删 Redis,再更新 MySQL?

如果写成下面这个顺序:

1
2
DEL Redis
UPDATE MySQL

在高并发下会有问题。

存储 数据状态
MySQL 新数据
Redis 旧数据

问题出在:写请求删除缓存后,还没来得及更新数据库;读请求进来发现缓存不存在,就查到了数据库里的旧值,并把旧值重新写回 Redis。

所以,一般不推荐“先删缓存,再更新数据库”。


四、先更新 MySQL,再删除 Redis 就绝对安全吗?

也不是。它只是更合理、风险更低。

极端情况下,仍然可能出现下面的时序:

1
2
3
4
5
6
7
8
9
10
11
sequenceDiagram
participant R as 读请求
participant W as 写请求
participant Cache as Redis
participant DB as MySQL

R->>Cache: 读取缓存,未命中
R->>DB: 查询旧数据
W->>DB: 更新 MySQL 为新数据
W->>Cache: 删除 Redis
R->>Cache: 把旧数据写入 Redis

这种情况的发生概率较低,但不是不存在。它说明缓存一致性不是只看代码顺序,还要看并发时序。

解决这类问题,常见做法是 延迟双删


五、延迟双删

延迟双删的思路是:更新数据库后先删一次缓存,过一小段时间再删一次缓存。第二次删除的作用,是清理并发读请求可能重新写入的旧缓存。

延迟时间不是固定答案,要根据业务接口耗时来定。一般需要覆盖一次读请求“查数据库 + 写缓存”的时间窗口。


六、删除 Redis 失败怎么办?

真正上线后,不能假设 Redis 删除一定成功。可能会遇到网络抖动、Redis 超时、连接池耗尽等问题。

如果 MySQL 已经更新成功,但 Redis 删除失败,就会出现旧缓存继续存在的问题。

所以在Redis缓存删除的时候需要加上判断条件来确保一致性

如果项目规模不大,可以用 MySQL 本地任务表兜底:

1
2
3
4
5
6
7
8
9
CREATE TABLE cache_delete_task (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
cache_key VARCHAR(255) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
retry_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

应用更新数据时,把业务更新和删除任务写入同一个 MySQL 事务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func UpdateUserInTx(ctx context.Context, userID int64, req UpdateUserRequest) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := updateUser(tx, userID, req); err != nil {
return err
}

task := CacheDeleteTask{
CacheKey: fmt.Sprintf("user:%d", userID),
Status: "PENDING",
NextRetryAt: time.Now(),
}
return tx.Create(&task).Error
})
}

后台任务定期扫描 PENDING 状态任务,执行 Redis 删除,失败就增加重试次数。


七、进阶方案:订阅 MySQL Binlog 做缓存失效

如果系统里有多个服务都会修改同一张表,单靠每个服务手动删除缓存很容易漏。这时可以考虑 CDC,也就是 Change Data Capture。

常见链路是:

1
2
3
4
5
6
7
flowchart LR
A[业务服务] --> B[MySQL]
B --> C[Binlog]
C --> D[Debezium / Canal]
D --> E[MQ / Kafka]
E --> F[缓存失效消费者]
F --> G[Redis DEL]

这种方式的优点是缓存失效逻辑集中,不依赖某一个业务服务是否记得删除缓存。缺点是架构复杂度更高,需要处理消息延迟、重复消费和失败重试。

所以小项目可以先用“更新 MySQL + 删除 Redis + TTL + 删除失败重试”;中大型系统再考虑 Binlog / CDC 方案。


八、TTL 兜底

不管采用哪种方案,缓存都应该设置 TTL。

Redis 的 EXPIRE 可以给 key 设置过期时间,过期后 key 会自动删除;TTL 可以查看 key 剩余生存时间。

1
2
3
SET user:1 '{"id":1,"name":"Tom"}'
EXPIRE user:1 600
TTL user:1

在代码里:

1
_ = rdb.Set(ctx, key, data, 10*time.Minute).Err()

为了避免大量 key 同时过期,可以给 TTL 加一点随机抖动:

1
2
3
4
func CacheTTL(base time.Duration) time.Duration {
jitter := time.Duration(rand.Intn(60)) * time.Second
return base + jitter
}

TTL 不能保证强一致,但能保证即使删除缓存失败,旧数据也不会永久存在。