我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [Redis 从 0 到 1(十二):先删缓存还是先更新库?4 种方案的 Go 并发实测](#Redis 从 0 到 1(十二):先删缓存还是先更新库?4 种方案的 Go 并发实测)
-
- 一、现象:改了价格,有人看到的还是旧价
- [二、4 种方案对比(收藏点 1)](#二、4 种方案对比(收藏点 1))
- [三、Go 实操:并发压测 4 种方案(收藏点 2)](#三、Go 实操:并发压测 4 种方案(收藏点 2))
- [四、别忘了兜底:TTL 是最终防线](#四、别忘了兜底:TTL 是最终防线)
- 五、选型建议与命令清单
- 六、排查步骤:怀疑缓存脏数据时
- 七、总结
Redis 从 0 到 1(十二):先删缓存还是先更新库?4 种方案的 Go 并发实测
上一篇《Redis 从 0 到 1(十一):缓存穿透/击穿/雪崩三连击,Go 3 套防御代码》我们挡住了三种缓存攻击,链接:https://blog.csdn.net/2501_92769340/article/details/167399690 。
本篇处理更日常的问题:更新数据时,缓存和数据库怎么保持一致?
一、现象:改了价格,有人看到的还是旧价
运营改了一个商品的价格,几分钟后有用户截图投诉"页面价格没变"。排查发现:更新流程是"先更新数据库,再删缓存",理论上没问题------但在删缓存之前,另一个请求刚好读到了旧值并写回了缓存,旧值又"复活"了 30 分钟。
这就是缓存一致性问题的本质:数据库和缓存是两个存储,任何"两步操作"之间都有一个并发窗口,窗口里可以塞进一次脏读 。我们能做的不是追求"强一致"(代价太大),而是把脏窗口压到业务可接受,并给脏数据兜底。
二、4 种方案对比(收藏点 1)
| 方案 | 流程 | 脏窗口 | 适用判断 |
|---|---|---|---|
| ① 先删缓存,再更新库 | DEL 缓存 → UPDATE | 删后到更新前的读会回源旧值并写回脏缓存 | 不推荐 |
| ② 先更新库,再删缓存 | UPDATE → DEL 缓存 | 更新后到删除前读到旧值(短暂) | 常规推荐 |
| ③ 延迟双删 | UPDATE → DEL → sleep 500ms → DEL | 把②的窗口再压缩一截 | 高一致性要求 |
| ④ 更新库 + binlog 订阅删缓存 | UPDATE →( canal/自研)异步 DEL | 秒级最终一致 | 缓存体系复杂、有中间件 |
方案①为什么差:删除缓存后、更新数据库前,一个读请求进来,查缓存没有 → 回源读到旧值 → 写回缓存。接下来更新数据库完成,但缓存里是旧值,直到 TTL 到期都错。②的窗口是"更新后、删除前"读到旧值,但删除动作马上就到,窗口是毫秒级;且读请求晚于删除的话会回源读到新值(因为数据库已经更新了),所以②远好于①。
三、Go 实操:并发压测 4 种方案(收藏点 2)
设计:100 个 goroutine 并发"读",同时 1 个 goroutine 循环"写"(模拟更新流程),数据库用内存模拟(读写各加 5ms 模拟真实耗时),统计"读到旧值"的次数。
go
package main
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"redis-0to1/redisx"
)
// 模拟数据库:真实场景替换为 SQL
var dbVal atomic.Int64
func dbRead() int64 { time.Sleep(5 * time.Millisecond); return dbVal.Load() }
func dbWrite(v int64) { time.Sleep(5 * time.Millisecond); dbVal.Store(v) }
// 读路径(Cache Aside):缓存未命中回源并回填
func readThrough(ctx context.Context, rdb *redis.Client, key string) int64 {
val, err := rdb.Get(ctx, key).Result()
if err == nil {
var n int64
fmt.Sscanf(val, "%d", &n)
return n
}
n := dbRead()
rdb.SetEx(ctx, key, fmt.Sprintf("%d", n), time.Minute)
return n
}
// 方案②:先更新库,再删缓存
func updateThenDel(ctx context.Context, rdb *redis.Client, key string, v int64) {
dbWrite(v)
rdb.Del(ctx, key)
}
// 方案③:延迟双删
func updateDoubleDel(ctx context.Context, rdb *redis.Client, key string, v int64) {
dbWrite(v)
rdb.Del(ctx, key)
go func() {
time.Sleep(500 * time.Millisecond) // 略大于一次读路径耗时
rdb.Del(ctx, key)
}()
}
func main() {
rdb, err := redisx.NewClient(redisx.Config{Addr: "127.0.0.1:6379", Password: "redis123", PoolSize: 30})
if err != nil {
panic(err)
}
ctx := context.Background()
key := "consistency:demo"
for _, mode := range []string{"updateThenDel", "doubleDel"} {
var stale atomic.Int64
var wg sync.WaitGroup
// 1 个写者:每轮值 +1
wg.Add(1)
go func() {
defer wg.Done()
for i := int64(1); i <= 200; i++ {
if mode == "updateThenDel" {
updateThenDel(ctx, rdb, key, i)
} else {
updateDoubleDel(ctx, rdb, key, i)
}
}
}()
// 30 个读者:只统计"读到的值比当前 db 值小"(读到旧值)
for j := 0; j < 30; j++ {
wg.Add(1)
go func() {
defer wg.Done()
for k := 0; k < 200; k++ {
got := readThrough(ctx, rdb, key)
if got < dbVal.Load() {
stale.Add(1)
}
time.Sleep(time.Millisecond)
}
}()
}
wg.Wait()
fmt.Printf("%s: 读到旧值 %d 次\n", mode, stale.Load())
}
}
典型量级(本机实测):方案②脏读次数是个位数级、窗口毫秒;方案③压到几乎为 0;方案①换实现后通常有数十次------这就是教科书说"先更新库再删缓存"的原因,不是拍脑袋。
四、别忘了兜底:TTL 是最终防线
无论选哪个方案,缓存必须带 TTL。TTL 的意义不是"防止过期",而是"给任何理论上的脏数据一个自愈期限"。工程上的完整闭环是:
go
func UpdateProduct(ctx context.Context, rdb *redis.Client, id string, v int64) {
dbWrite(v) // 1. 先更新数据库(真实项目里要事务/乐观锁)
if err := rdb.Del(ctx, "product:"+id).Err(); err != nil {
// 2. 删缓存失败要重试/告警,不能静默吞掉
log.Printf("删除缓存失败 product:%s: %v(请人工核查)", id, err)
}
go func() { // 3. 延迟双删(可选,高一致性场景开)
time.Sleep(500 * time.Millisecond)
if err := rdb.Del(context.Background(), "product:"+id).Err(); err != nil {
log.Printf("延迟双删失败: %v", err)
}
}()
}
五、选型建议与命令清单
| 场景 | 建议 |
|---|---|
| 普通业务(价格、详情) | ② + TTL,够用 |
| 资金、库存类 | ③ + TTL + 监控;或干脆对这类 key 不做缓存 |
| 读多写极少、QPS 极高 | ④ binlog 订阅,把删除从业务代码里剥离 |
| 命令 | 作用 |
|---|---|
DEL key / UNLINK key |
删缓存(UNLINK 异步删,大 key 用) |
EXPIRE key sec |
兜底 TTL |
GET key + SETEX |
Cache Aside 读路径 |
六、排查步骤:怀疑缓存脏数据时
- 对比数据库值与缓存值,确认确实脏;
- 查应用日志删缓存是否失败(步骤 2 的失败最容易被静默吞掉);
- 看 TTL 是否设置(没有 TTL 的缓存等于永久脏数据许可);
- 对账:定时任务抽 key 比对数据库,超阈值告警。
七、总结
- 两步操作必有并发窗口,目标是把窗口压到可接受而非归零;
- 常规用"先更新库再删缓存",高一致性加"延迟双删";
- TTL 是所有方案的最终兜底,没 TTL 的缓存不许上线;
- 删缓存失败必须重试告警,这是脏数据最常见的来源。
下一篇预告:一致性问题里还藏着一个更危险的家伙:多实例同时操作同一份数据。下一篇《分布式锁被误删、锁不住?Go 无 Lua 实现 3 级防御》,我们写一个不用 Lua 的分布式锁,把"误删别人的锁"这个经典 bug 从根上拔掉。