Redis 双写一致性
一、定义
Redis 与 MySQL 双写一致性指在「缓存 + 数据库」架构中,保证 Redis 缓存数据与 MySQL 数据库数据的最终一致。
二、特点
1. 一致性目标
- 绝大多数业务场景追求「最终一致性」,强一致性需付出性能代价。
2. 核心矛盾
- 读写并发、更新顺序、网络 / 进程异常导致的缓存脏数据。
3. 主流方案
- Cache Aside(先库后删缓存)为基础,结合延迟双删、分布式锁、binlog 异步同步等优化,适配不同一致性要求。
三、业务场景
1. 电商商品信息
- 场景:商品价格、库存、标题等信息同时存储在 MySQL(持久化)和 Redis(高频读取),更新商品信息时需保证双写一致。
- 痛点:若缓存未同步,用户看到过期价格 / 库存,引发客诉或超卖。
2. 用户中心数据
- 场景:用户昵称、头像、手机号等信息,登录后缓存到 Redis,修改信息时需同步。
- 痛点:缓存脏数据导致用户看到旧信息,体验差。
3. 订单状态更新
- 场景:订单支付、发货、完成等状态,高频查询走 Redis,更新时需同步。
- 痛点:状态不一致导致订单流程异常(如已支付但缓存显示未支付)。
四、双写一致性的实现方式
1. 同步直写
(1)定义
写操作时同步更新 MySQL 数据库和 Redis 缓存,保证「数据库更新成功 ↔ 缓存更新成功」的强一致性,核心是「写库 + 写缓存」的原子性。
(2)核心逻辑
- 读流程:优先读缓存,未命中则读库并同步写缓存。
- 写流程:通过事务 / 分布式锁保证「更新 MySQL + 更新 / 删除 Redis」原子执行,两者都成功才算写操作完成,任一失败则回滚。
(3)代码实现
package main
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/go-redis/redis/v8"
_ "github.com/go-sql-driver/mysql"
"time"
)
var (
redisCli = redis.NewClient(&redis.Options{Addr: "localhost:6379", DB: 0})
mysqlCli, _ = sql.Open("mysql", "root:123456@tcp(localhost:3306)/ecom?charset=utf8mb4")
ctx = context.Background()
)
// Product 核心数据结构
type Product struct {
ID int64 `json:"id"`
Stock int64 `json:"stock"`
Price float64 `json:"price"`
}
// SyncWrite 同步直写:更新MySQL+同步更新Redis(原子性)
func SyncWrite(productID int64, newStock int64, newPrice float64) error {
// 1. 开启MySQL事务,保证库更新原子性
tx, err := mysqlCli.Begin()
if err != nil {
return fmt.Errorf("开启事务失败:%w", err)
}
defer tx.Rollback()
// 2. 更新MySQL
_, err = tx.Exec("UPDATE product SET stock=?, price=? WHERE id=?", newStock, newPrice, productID)
if err != nil {
return fmt.Errorf("更新数据库失败:%w", err)
}
// 3. 同步更新Redis缓存(原子性:库更新成功才更缓存)
cacheKey := fmt.Sprintf("product:%d", productID)
product := Product{ID: productID, Stock: newStock, Price: newPrice}
jsonData, _ := json.Marshal(product)
err = redisCli.Set(ctx, cacheKey, jsonData, 30*time.Minute).Err()
if err != nil {
// 缓存更新失败,回滚数据库事务,保证强一致
tx.Rollback()
return fmt.Errorf("更新缓存失败,回滚数据库:%w", err)
}
// 4. 提交事务,写操作完成
if err = tx.Commit(); err != nil {
return fmt.Errorf("提交事务失败:%w", err)
}
return nil
}
// SyncRead 同步直写配套读流程
func SyncRead(productID int64) (*Product, error) {
cacheKey := fmt.Sprintf("product:%d", productID)
// 1. 优先读缓存
val, err := redisCli.Get(ctx, cacheKey).Result()
if err == nil {
var p Product
_ = json.Unmarshal([]byte(val), &p)
return &p, nil
}
if err != redis.Nil {
return nil, fmt.Errorf("读缓存失败:%w", err)
}
// 2. 缓存未命中,读数据库
var p Product
err = mysqlCli.QueryRow("SELECT id, stock, price FROM product WHERE id=?", productID).
Scan(&p.ID, &p.Stock, &p.Price)
if err != nil {
return nil, fmt.Errorf("读数据库失败:%w", err)
}
// 3. 同步写缓存
jsonData, _ := json.Marshal(p)
_ = redisCli.Set(ctx, cacheKey, jsonData, 30*time.Minute).Err()
return &p, nil
}
(4)优点
- 强一致性,无脏数据。
- 逻辑简单,易实现 / 运维。
- 无需兜底(过期时间可选)。
(5)缺点
- 同步阻塞,写性能低(高并发下接口耗时高)。
- 缓存更新失败会导致数据库回滚,降低可用性。
- 依赖 MySQL 事务,分布式场景下扩展差。
(6)适用场景
- 金融交易、核心账户数据等「一致性优先于性能」的场景。
- 写频率低、读频率高的静态数据(如商品分类)。
- 分布式事务成本高,且无法接受最终一致性延迟的场景。
2. 异步缓写
(1)定义
写操作时仅同步更新 MySQL,缓存的更新 / 删除通过「异步线程 / 消息队列 / binlog 解析」完成,核心是「解耦库与缓存的写操作」,追求最终一致性。
(2)核心逻辑
- 基础版:更新 MySQL → 异步 goroutine 删除 / 更新缓存(非阻塞主流程)。
- 进阶版:MySQL binlog → Canal 解析 → 消息队列(Kafka)→ 消费端异步更新 Redis(彻底解耦)。
(3)优点
- 写性能极高(无同步阻塞)。
- 解耦库与缓存,易扩展。
- 主流程无依赖,可用性高。
(4)缺点
- 存在毫秒级延迟,仅保证最终一致性。
- 异步链路故障(如 Kafka 宕机)会导致缓存脏数据。
- 需额外组件(Canal/Kafka),运维复杂度提升。
(5)适用场景
- 电商商品列表、用户信息、内容详情等「性能优先于强一致性」的高并发场景。
- 写频率高、可接受毫秒级数据延迟的场景。
- 希望业务代码与缓存操作解耦的大规模分布式系统。
五、双检加锁
1. 解决的问题
(1)缓存击穿(缓存失效后的雪崩效应)
当缓存过期 / 被删除后,大量并发读请求同时穿透到 MySQL,导致数据库压力飙升,甚至宕机。
(2)并发回写脏数据
缓存失效后,多个读请求同时读 MySQL 并回写缓存,可能出现:线程 A 读 MySQL(旧数据)→ 线程 B 更新 MySQL + 删缓存 → 线程 A 回写旧数据到缓存 → 脏数据产生。
2. 核心流程
第一次检查缓存 → 未命中 → 加分布式锁 → 第二次检查缓存 → 仍未命中 → 读 MySQL → 写缓存 → 释放锁 → 返回数据
↑ ↑
命中则直接返回 命中则释放锁并返回(避免重复回写)
- 第一次检查:快速拦截大部分命中的请求,避免加锁开销。
- 加锁:保证仅有一个请求穿透到 MySQL,避免缓存击穿。
- 第二次检查:防止锁等待期间,其他线程已完成缓存回写,避免重复读库。
六、常见问题
1. 更新顺序错误导致脏数据
(1)先更缓存,后更数据库
问题:缓存更新成功,数据库更新失败。
(2)先更数据库,后更缓存
问题:数据库更新成功,缓存更新失败。
2. 并发读写导致一致性丢失
- 场景:线程 A 更新数据库(未删缓存)→ 线程 B 读取数据(从缓存获取旧数据)→ 线程 A 删缓存。
- 后果:线程 B 把旧数据写回缓存(若有「缓存回写」逻辑),导致脏数据。
3. 缓存更新 / 删除异常
- 场景:网络抖动导致 DEL 命令未执行、Redis 宕机导致删除失败。
- 后果:缓存长期脏数据,直到过期。
4. 主从延迟引发读不一致
- 场景:MySQL 主从同步延迟,Redis 删缓存后,读请求从从库读取旧数据并回写缓存。
- 后果:脏数据重新写入缓存,一致性被破坏。
5. 缓存击穿 / 穿透(间接关联)
- 场景:缓存删除后,大量请求穿透到数据库,若数据库未完成更新,读取旧数据回写缓存。
- 后果:脏数据 + 数据库压力飙升。
七、解决方案
1. 基础方案 - Cache Aside(先更新库后删缓存)
(1)核心逻辑
Cache Aside(缓存旁路策略)。
- 读流程:读 Redis → 缓存命中则返回 → 未命中则读 MySQL → 将数据写入 Redis → 返回。
- 写流程:更新 MySQL → 删除 Redis 缓存(而非更新缓存)。
- 核心优势:避免「更新缓存」的原子性问题,删除缓存后,后续读请求自动从数据库加载最新数据。
(2)代码实现
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/go-redis/redis/v8"
_ "github.com/go-sql-driver/mysql"
"time"
)
// 全局客户端
var (
redisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
})
mysqlDB, _ = sql.Open("mysql", "root:123456@tcp(localhost:3306)/ecommerce?charset=utf8mb4")
ctx = context.Background()
)
// 商品结构体
type Product struct {
ID int64
Price float64
Stock int64
}
// CacheAsideRead 读流程(Cache Aside)
func CacheAsideRead(productID int64) (*Product, error) {
// 1. 读 Redis 缓存
cacheKey := fmt.Sprintf("product:%d", productID)
val, err := redisClient.Get(ctx, cacheKey).Result()
if err == nil {
// 解析缓存数据(简化示例,实际用 JSON/Protobuf 序列化)
var p Product
_ = json.Unmarshal([]byte(val), &p)
return &p, nil
}
if err != redis.Nil {
return nil, fmt.Errorf("读缓存失败:%w", err)
}
// 2. 缓存未命中,读 MySQL
var p Product
err = mysqlDB.QueryRow("SELECT id, price, stock FROM product WHERE id=?", productID).
Scan(&p.ID, &p.Price, &p.Stock)
if err != nil {
return nil, fmt.Errorf("读数据库失败:%w", err)
}
// 3. 将数据写入 Redis(设置过期时间,兜底脏数据)
jsonData, _ := json.Marshal(p)
_ = redisClient.Set(ctx, cacheKey, jsonData, 30*time.Minute).Err()
return &p, nil
}
// CacheAsideWrite 写流程(Cache Aside:先库后删缓存)
func CacheAsideWrite(productID int64, newPrice float64, newStock int64) error {
// 1. 更新 MySQL
tx, err := mysqlDB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec("UPDATE product SET price=?, stock=? WHERE id=?", newPrice, newStock, productID)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
// 2. 删除 Redis 缓存(核心:删而非更,避免更新失败)
cacheKey := fmt.Sprintf("product:%d", productID)
if err = redisClient.Del(ctx, cacheKey).Err(); err != nil {
// 记录日志+告警,保证最终一致性(后续通过过期时间兜底)
fmt.Printf("删缓存失败:%v\n", err)
}
return nil
}
(3)缺陷
- 并发场景下仍可能出现脏数据(如读请求在「删缓存」前读取旧数据并回写)。
- 删缓存失败会导致长期脏数据(需过期时间兜底)。
2. 优化方案 - 延迟双删(解决并发读写脏数据)
(1)核心逻辑(延迟双删)
在「先库后删缓存」基础上,增加「延迟删除」步骤:
- 更新 MySQL → 删除 Redis 缓存 → 延迟 N 毫秒 → 再次删除 Redis 缓存。
- 延迟目的:等待并发读请求完成「读库回写缓存」的操作,再删一次缓存,避免脏数据回写。
(2)代码实现
import "time"
// DelayDoubleDeleteWrite 延迟双删写流程
func DelayDoubleDeleteWrite(productID int64, newPrice float64, newStock int64) error {
// 步骤1:更新 MySQL(同 Cache Aside)
tx, err := mysqlDB.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.Exec("UPDATE product SET price=?, stock=? WHERE id=?", newPrice, newStock, productID)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
cacheKey := fmt.Sprintf("product:%d", productID)
// 步骤2:第一次删缓存
if err = redisClient.Del(ctx, cacheKey).Err(); err != nil {
fmt.Printf("第一次删缓存失败:%v\n", err)
}
// 步骤3:延迟 N 毫秒(建议 500ms~1s,需压测确定)
go func() {
time.Sleep(800 * time.Millisecond)
// 第二次删缓存(兜底)
if err = redisClient.Del(ctx, cacheKey).Err(); err != nil {
fmt.Printf("第二次删缓存失败:%v\n", err)
}
}()
return nil
}
(3)关键参数
- 延迟时间:需大于「读请求从库读取 + 回写缓存」的最大耗时(一般 500ms~1s 适配大部分场景)。
- 异步执行:延迟删除通过 goroutine 异步执行,不阻塞主流程。
(4)缺陷
- 仍无法解决「极端并发 + 网络延迟」导致的脏数据。
- 依赖本地 goroutine,若进程崩溃,第二次删缓存失效。
3. 进阶方案 - 分布式锁 + 延迟双删(保证更新原子性)
(1)核心逻辑
在更新流程中加分布式锁,避免多线程并发更新导致的一致性问题:
- 获取分布式锁 → 更新 MySQL → 删除缓存 → 延迟删缓存 → 释放锁。
- 锁粒度:按资源维度(如商品 ID),避免全量锁。
(2)代码实现
import (
"crypto/rand"
"encoding/base64"
"errors"
"time"
)
// 生成唯一标识(分布式锁用)
func generateUniqueID() string {
b := make([]byte, 16)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}
// Lock 分布式锁加锁(SET NX EX)
func Lock(lockKey string, expire int) (string, bool) {
uniqueID := generateUniqueID()
result, err := redisClient.Set(ctx, lockKey, uniqueID, time.Duration(expire)*time.Second).SetNX().Result()
if err != nil || !result {
return "", false
}
return uniqueID, true
}
// Unlock 分布式锁解锁(Lua 脚本原子操作)
func Unlock(lockKey, uniqueID string) error {
unlockScript := `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`
result, err := redisClient.Eval(ctx, unlockScript, []string{lockKey}, uniqueID).Result()
if err != nil {
return err
}
if res, ok := result.(int64); !ok || res == 0 {
return errors.New("锁标识不匹配")
}
return nil
}
// LockDoubleDeleteWrite 分布式锁+延迟双删
func LockDoubleDeleteWrite(productID int64, newPrice float64, newStock int64) error {
// 1. 获取分布式锁(锁粒度:商品ID)
lockKey := fmt.Sprintf("lock:product:%d", productID)
uniqueID, ok := Lock(lockKey, 10) // 锁超时10s
if !ok {
return errors.New("获取锁失败,更新拒绝")
}
defer Unlock(lockKey, uniqueID) // 释放锁
// 2. 执行延迟双删写流程(同方案2)
return DelayDoubleDeleteWrite(productID, newPrice, newStock)
}
(3)缺陷
- 分布式锁引入性能损耗(高并发场景需评估)。
- 锁超时 / 宕机仍可能导致一致性问题。
4. 终极方案 - binlog 异步同步(Canal + Redis)
(1)核心逻辑
彻底解耦「更新数据库」与「更新缓存」,基于 MySQL binlog 异步同步:
- MySQL 开启 binlog,Canal 伪装成从库读取 binlog。
- Canal 解析 binlog 后,通过消息队列(Kafka/RocketMQ)异步通知 Redis 更新 / 删除缓存。
- 业务代码仅需更新 MySQL,无需关心缓存操作。
(2)代码实现
import (
"encoding/json"
"fmt"
"github.com/confluentinc/confluent-kafka-go/kafka"
)
// Canal 消息消费逻辑(伪代码,实际Canal客户端多为Java,Golang可消费Kafka消息)
func CanalSyncCache() {
// 1. 连接Kafka,消费Canal解析的binlog消息
consumer, err := kafka.NewConsumer(&kafka.ConfigMap{
"bootstrap.servers": "localhost:9092",
"group.id": "cache-sync-group",
"auto.offset.reset": "latest",
})
if err != nil {
panic(err)
}
defer consumer.Close()
// 2. 订阅binlog主题
_ = consumer.SubscribeTopics([]string{"mysql-binlog-product"}, nil)
// 3. 消费消息
for {
msg, err := consumer.ReadMessage(-1)
if err != nil {
fmt.Printf("消费消息失败:%v\n", err)
continue
}
// 4. 解析binlog消息(示例:商品更新)
var binlogMsg struct {
Table string `json:"table"`
OpType string `json:"op_type"` // INSERT/UPDATE/DELETE
Data map[string]interface{} `json:"data"`
}
_ = json.Unmarshal(msg.Value, &binlogMsg)
// 5. 处理缓存(仅处理product表的更新/删除)
if binlogMsg.Table == "product" {
productID := binlogMsg.Data["id"].(string)
cacheKey := fmt.Sprintf("product:%s", productID)
switch binlogMsg.OpType {
case "UPDATE", "DELETE":
// 更新/删除操作:删除缓存
_ = redisClient.Del(ctx, cacheKey).Err()
case "INSERT":
// 插入操作:写入缓存(可选)
jsonData, _ := json.Marshal(binlogMsg.Data)
_ = redisClient.Set(ctx, cacheKey, jsonData, 30*time.Minute).Err()
}
}
}
}
(3)优势
- 彻底解耦:业务代码无需关心缓存操作,降低耦合。
- 最终一致性:binlog 保证数据不丢失,异步同步实现最终一致(解决了延迟双删的延迟问题)。
- 高可用:消息队列削峰填谷,Canal 集群部署避免单点故障。
(4)缺陷
- 引入中间件:增加 Canal、Kafka 等组件,运维复杂度提升。
- 同步延迟:存在毫秒级延迟,不适合强一致性场景。
5. 兜底方案 - 缓存版本号 + 过期时间
(1)核心逻辑
为缓存数据增加版本号,读取时校验版本号,结合过期时间避免长期脏数据:
- MySQL 表增加 version 字段,每次更新 + 1。
- Redis 缓存存储「数据 + 版本号」(如 {“data”: {…}, “version”: 5})。
- 读缓存时校验版本号与数据库一致,不一致则删除缓存重新加载。
(2)代码实现
// VersionedProduct 带版本号的商品结构体
type VersionedProduct struct {
Product Product `json:"product"`
Version int64 `json:"version"`
}
// VersionedRead 带版本号的读流程
func VersionedRead(productID int64) (*Product, error) {
cacheKey := fmt.Sprintf("product:%d", productID)
// 1. 读缓存
val, err := redisClient.Get(ctx, cacheKey).Result()
if err == nil {
var vp VersionedProduct
_ = json.Unmarshal([]byte(val), &vp)
// 2. 校验版本号
var dbVersion int64
err = mysqlDB.QueryRow("SELECT version FROM product WHERE id=?", productID).Scan(&dbVersion)
if err != nil {
return nil, err
}
if vp.Version == dbVersion {
return &vp.Product, nil
} else {
// 版本号不一致,删缓存
_ = redisClient.Del(ctx, cacheKey).Err()
}
}
// 3. 缓存未命中/版本不一致,读库并写入缓存
var p Product
var version int64
err = mysqlDB.QueryRow("SELECT id, price, stock, version FROM product WHERE id=?", productID).
Scan(&p.ID, &p.Price, &p.Stock, &version)
if err != nil {
return nil, err
}
// 4. 写入带版本号的缓存
vp := VersionedProduct{Product: p, Version: version}
jsonData, _ := json.Marshal(vp)
_ = redisClient.Set(ctx, cacheKey, jsonData, 30*time.Minute).Err()
return &p, nil
}
(3)优势
- 兜底所有异常场景,避免长期脏数据。
- 版本号校验轻量,性能损耗低。
(4)缺陷
- 同时读取缓存和数据库,缓存的实际作用降低,且增加了性能损耗。
八、注意事项
1. 优先「删除缓存」而非「更新缓存」
(1)原因
更新缓存易出现「并发更新覆盖」(线程 A/B 同时更新缓存,导致数据错乱),删除缓存后由读请求自动加载最新数据,更简单可靠。
(2)例外
静态数据(如商品分类)可直接更新缓存,无并发风险。
2. 必须设置缓存过期时间
(1)作用
作为所有一致性方案的兜底,即使删缓存失败,过期时间到后自动失效,避免永久脏数据。
(2)建议
核心数据 10-30 分钟,非核心数据 1-5 分钟。
3. 分布式锁粒度要精细
(1)反例
用 lock:product 锁所有商品更新,导致并发性能极低。
(2)正例
用 lock:product:{商品ID} 按商品维度加锁,仅阻塞同一商品的更新。
4. binlog 同步的延迟控制
Canal 同步延迟需控制在 100ms 内,核心业务可通过「双读」(缓存 + 数据库版本号)兜底;避免在 binlog 同步完成前,让用户感知到数据不一致(如订单状态更新后,短暂提示「数据加载中」)。
5. 避免缓存穿透 / 雪崩
缓存删除后,大量请求穿透到数据库,需结合:布隆过滤器、缓存空值、接口限流。
6. 监控与告警
告警场景:删缓存失败次数超阈值、binlog 同步延迟 > 1s、缓存命中率骤降。
九、面试题
1. Redis+MySQL 双写一致性,优先「先更库还是先更缓存」?为什么?
优先「先更库后删缓存」。原因:先更缓存后更库可能导致缓存更新成功但数据库更新失败,出现脏数据;先更库后删缓存,即使缓存删除失败,后续读请求会从库加载最新数据更新缓存(结合过期时间兜底),更可靠。
2. 为什么推荐「删除缓存」而非「更新缓存」?
- 并发更新缓存会导致数据错乱:线程 A 和 B 同时更新同一份数据,A 先读库→B 先读库→B 先更缓存→A 后更缓存,最终缓存为 A 的旧数据。
- 删除缓存更简单可靠:删缓存后,读请求自动从库加载最新数据,天然避免并发更新问题。
3. 延迟双删的延迟时间如何确定?为什么要异步执行?
- 延迟时间:需大于「读请求从 MySQL 读取数据 + 回写 Redis 缓存」的最大耗时(一般 500ms~1s),可通过压测确定。
- 异步执行原因:不阻塞主更新流程,避免接口响应时间变长;主流程仅负责核心的数据库更新,延迟删缓存作为兜底,不影响核心业务。
4. Canal 实现双写一致性的原理?适用于什么场景?
- 原理:MySQL 开启 binlog,Canal 伪装成 MySQL 从库,通过 TCP 连接获取 binlog;Canal 解析 binlog(解析为结构化数据),发送到消息队列;消费端读取消息,更新 / 删除 Redis 缓存。
- 适用场景:高并发、高可用要求的核心业务(如电商商品、订单);追求「最终一致性」,可接受毫秒级同步延迟的场景;希望解耦业务代码与缓存操作的场景。
5. 双写一致性场景下,缓存出现脏数据的常见原因有哪些?如何排查?
- 常见原因:更新顺序错误(先更缓存后更库);并发读写(读请求在删缓存前回写旧数据);删缓存失败(网络抖动、Redis 宕机);MySQL 主从延迟,读从库回写旧数据。
- 排查步骤:检查更新逻辑是否为「先库后删缓存」;查看 Redis 删缓存的日志,是否有失败记录;监控 MySQL 主从同步延迟,确认是否为从库脏数据回写;开启缓存版本号校验,定位脏数据的版本来源。
6. 如何在高并发场景下平衡「一致性」与「性能」?
- 核心原则:绝大多数场景追求「最终一致性」,而非强一致性。
- 具体策略:缓存层面,设置合理过期时间 + 延迟双删,兜底脏数据;锁层面,精细粒度的分布式锁(如商品 ID 维度),避免全量锁;架构层面,核心读请求走缓存,写请求异步化(Canal),解耦读写;兜底层面,缓存版本号 + 数据库乐观锁,避免极端场景的不一致。
7. Redis+MySQL 双写一致性的 CAP 取舍?
- CAP 取舍:优先「可用性(A)+ 分区容错性(P)」,放弃强一致性(C),追求最终一致性。
- 原因:分布式场景下分区容错性(P)是必须的;缓存的核心价值是提升性能(可用性),强一致性需引入分布式锁、2PC 等,性能损耗大;通过过期时间、延迟双删、binlog 同步等方案,可实现最终一致性,满足绝大多数业务需求。
8. 如果 Redis 集群宕机,如何保证双写一致性?
- 临时方案:关闭缓存写入,所有读请求直接走 MySQL,避免缓存 / 数据库数据不一致;接口限流,避免 MySQL 因流量突增崩溃。
- 恢复方案:Redis 集群恢复后,清空所有缓存(避免脏数据);读请求重新从 MySQL 加载数据到 Redis;恢复 binlog 同步,确保后续更新的一致性。