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 同步,确保后续更新的一致性。