Redis 从 0 到 1(十二):先删缓存还是先更新库?4 种方案的 Go 并发实测

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

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 读路径

六、排查步骤:怀疑缓存脏数据时

  1. 对比数据库值与缓存值,确认确实脏;
  2. 查应用日志删缓存是否失败(步骤 2 的失败最容易被静默吞掉);
  3. 看 TTL 是否设置(没有 TTL 的缓存等于永久脏数据许可);
  4. 对账:定时任务抽 key 比对数据库,超阈值告警。

七、总结

  1. 两步操作必有并发窗口,目标是把窗口压到可接受而非归零;
  2. 常规用"先更新库再删缓存",高一致性加"延迟双删";
  3. TTL 是所有方案的最终兜底,没 TTL 的缓存不许上线;
  4. 删缓存失败必须重试告警,这是脏数据最常见的来源。

下一篇预告:一致性问题里还藏着一个更危险的家伙:多实例同时操作同一份数据。下一篇《分布式锁被误删、锁不住?Go 无 Lua 实现 3 级防御》,我们写一个不用 Lua 的分布式锁,把"误删别人的锁"这个经典 bug 从根上拔掉。

相关推荐
牧瀬クリスだ1 小时前
Redis String深度解析:从底层到五大实战场景
数据库·redis·缓存
利威尔xu1 小时前
Go IM 源码实战(7):断线三天重连,水位算错就漏消息
redis·后端·微服务·面试·golang
独泪了无痕11 小时前
Spring Data Redis 快速上手
spring boot·redis·后端
用户529589316685016 小时前
三马代驾实战:抢单池的 Redis 锁 + RabbitMQ 异步消费怎么做
redis
代码丰17 小时前
跨系统传输大 JSON:直接压缩,还是把数据放进 Redis?
redis·后端·json
devilnumber19 小时前
Redis 数据类型 + 项目落地场景(面试版,全面、实用,后端常用)
数据库·redis·面试
靳某某。20 小时前
Ubuntu安装php8.4 及redis ngin
linux·redis·ubuntu
Zhang~Ling21 小时前
Redis 基础教程:从常用命令到实际业务场景
数据库·redis·缓存
FungLeo1 天前
成为全栈·Go 后端篇·用 Go 重写分层架构:领域包、装配根与最小抽象
golang·go·领域驱动设计·gorm·分层架构·成为全栈