Redis 和 MySQL 如何保证数据一致性?从业务方案到底层原理完整讲解

在后端开发中,只要项目里同时用了:

text 复制代码
MySQL + Redis

几乎一定会遇到一个问题:

数据到底以谁为准?如果数据库更新成功了,但 Redis 里还是旧数据怎么办?

这个问题看起来简单,但真正往下深挖,会牵扯到:

text 复制代码
缓存更新策略
并发竞争
事务
binlog
redo log
主从复制
Redis 单线程执行模型
消息队列
CDC
最终一致性

很多面试题会直接问:

Redis 和 MySQL 怎么保证数据一致性?

如果只回答:

text 复制代码
延迟双删

其实远远不够。

真正完整的答案应该分成几个层次:

text 复制代码
第一层:先理解为什么不一致
第二层:掌握 Cache Aside
第三层:理解"先删缓存还是先更新数据库"
第四层:解决高并发脏数据回写
第五层:理解延迟双删
第六层:用消息队列提高可靠性
第七层:用 Binlog + CDC 做真正工程化同步
第八层:下沉到 MySQL 和 Redis 底层

下面我们一步一步来。


一、先从最简单的场景说起

假设有一张商品表:

sql 复制代码
CREATE TABLE product (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    price DECIMAL(10, 2) NOT NULL,
    stock INT NOT NULL,
    updated_at DATETIME NOT NULL
);

有一条数据:

text 复制代码
id = 1

name = iPhone

price = 5999

stock = 100

为了减少数据库压力,我们把它放进 Redis。

text 复制代码
key:

product:1

value:

json 复制代码
{
  "id": 1,
  "name": "iPhone",
  "price": 5999,
  "stock": 100
}

查询流程:

text 复制代码
客户端
  ↓
Redis
  ↓
有数据
  ↓
直接返回

如果 Redis 没有:

text 复制代码
客户端
  ↓
Redis Miss
  ↓
MySQL
  ↓
查到数据
  ↓
写入 Redis
  ↓
返回

这就是最经典的:

text 复制代码
Cache Aside Pattern

旁路缓存模式。


二、最简单的查询代码

以 Go 为例:

go 复制代码
func GetProduct(
	ctx context.Context,
	id int64,
) (*Product, error) {

	key := fmt.Sprintf(
		"product:%d",
		id,
	)

	// 1. 查 Redis
	cacheValue, err :=
		rdb.Get(
			ctx,
			key,
		).Result()

	if err == nil {

		var product Product

		err = json.Unmarshal(
			[]byte(cacheValue),
			&product,
		)

		if err == nil {
			return &product, nil
		}
	}

	// 2. Redis 没有,查 MySQL
	var product Product

	err = db.WithContext(ctx).
		First(
			&product,
			id,
		).Error

	if err != nil {
		return nil, err
	}

	// 3. 回写 Redis
	data, _ :=
		json.Marshal(product)

	rdb.Set(
		ctx,
		key,
		data,
		10*time.Minute,
	)

	return &product, nil
}

这个逻辑本身没有问题。

真正的问题出现在:

text 复制代码
更新数据

的时候。


三、最危险的写法:同时更新数据库和缓存

很多初学者会这样写:

go 复制代码
func UpdateProductPrice(
	ctx context.Context,
	id int64,
	price float64,
) error {

	// 更新 MySQL
	err := db.WithContext(ctx).
		Model(&Product{}).
		Where(
			"id = ?",
			id,
		).
		Update(
			"price",
			price,
		).Error

	if err != nil {
		return err
	}

	// 更新 Redis
	key :=
		fmt.Sprintf(
			"product:%d",
			id,
		)

	return rdb.HSet(
		ctx,
		key,
		"price",
		price,
	).Err()
}

看起来很合理:

text 复制代码
MySQL 更新一次
Redis 更新一次

但是高并发下很容易出问题。


四、并发下为什么会出现脏缓存?

假设现在:

text 复制代码
price = 100

两个请求同时修改价格。

请求 A:

text 复制代码
price = 200

请求 B:

text 复制代码
price = 300

执行顺序可能是:

text 复制代码
A 更新 MySQL = 200

B 更新 MySQL = 300

B 更新 Redis = 300

A 更新 Redis = 200

最终结果:

text 复制代码
MySQL = 300

Redis = 200

发生数据不一致。

注意:

数据库最终是新值。

但缓存被一个较早的请求覆盖成旧值。

所以一般不推荐:

text 复制代码
更新数据库 + 更新缓存

更推荐:

text 复制代码
更新数据库 + 删除缓存

五、为什么"删除缓存"比"更新缓存"更好?

假设数据库更新成功之后:

text 复制代码
DELETE product:1

下一次查询时:

text 复制代码
Redis Miss

↓

查询 MySQL

↓

拿到最新数据

↓

重新缓存

这种模式更加简单。

也就是:

text 复制代码
写:

更新数据库
+
删除缓存

读:

text 复制代码
读缓存
↓
没有
↓
查数据库
↓
写缓存

这是非常经典的:

text 复制代码
Cache Aside

六、问题来了:到底先删缓存还是先更新数据库?

这是这个问题最关键的一部分。

有两种方案。

方案一:

text 复制代码
先删 Redis

再更新 MySQL

方案二:

text 复制代码
先更新 MySQL

再删 Redis

哪种更好?

我们一个一个分析。


七、方案一:先删除缓存,再更新数据库

代码:

go 复制代码
func UpdateProduct(
	ctx context.Context,
	id int64,
	price float64,
) error {

	key :=
		fmt.Sprintf(
			"product:%d",
			id,
		)

	// 1. 先删缓存
	if err :=
		rdb.Del(
			ctx,
			key,
		).Err(); err != nil {

		return err
	}

	// 2. 再更新数据库
	return db.WithContext(ctx).
		Model(&Product{}).
		Where(
			"id = ?",
			id,
		).
		Update(
			"price",
			price,
		).Error
}

看起来没问题。

但考虑下面这个并发情况。

初始:

text 复制代码
MySQL = 100

Redis = 100

请求 A 修改:

text 复制代码
100 → 200

执行:

text 复制代码
A 删除 Redis

然后 A 还没来得及更新 MySQL。

这时请求 B 来查询。

Redis 没有数据:

text 复制代码
B Redis Miss

于是 B 查询 MySQL:

text 复制代码
MySQL 还是 100

B 把旧数据写回 Redis:

text 复制代码
Redis = 100

然后请求 A:

text 复制代码
更新 MySQL = 200

最终:

text 复制代码
MySQL = 200

Redis = 100

数据不一致。

完整时序:

text 复制代码
请求 A                  请求 B

DEL Redis
                         GET Redis
                         Miss

                         SELECT MySQL
                         得到 100

                         SET Redis 100

UPDATE MySQL 200

最终:

text 复制代码
Redis:100
MySQL:200

这就是为什么:

text 复制代码
先删缓存,再更新数据库

存在明显风险。


八、方案二:先更新数据库,再删除缓存

推荐:

text 复制代码
UPDATE MySQL

↓

DELETE Redis

代码:

go 复制代码
func UpdateProduct(
	ctx context.Context,
	id int64,
	price float64,
) error {

	// 1. 更新数据库
	err := db.WithContext(ctx).
		Model(&Product{}).
		Where(
			"id = ?",
			id,
		).
		Update(
			"price",
			price,
		).Error

	if err != nil {
		return err
	}

	// 2. 删除 Redis
	key :=
		fmt.Sprintf(
			"product:%d",
			id,
		)

	return rdb.Del(
		ctx,
		key,
	).Err()
}

这也是实际业务中最常见的一种实现。


九、为什么先更新数据库更安全?

假设:

text 复制代码
MySQL = 100

Redis = 100

请求 A:

text 复制代码
UPDATE MySQL = 200

接着:

text 复制代码
DEL Redis

即使此时有读请求:

大部分情况下最多只会在一个很短的时间窗口内读到旧缓存。

删除缓存以后:

text 复制代码
Redis Miss

↓

MySQL

↓

200

缓存会恢复正确。


十、但"先更新数据库,再删缓存"真的绝对安全吗?

不是。

我们来看一个极端并发场景。

初始:

text 复制代码
MySQL = 100

Redis 没有缓存

请求 A 是查询请求。

请求 B 是更新请求。

执行顺序:

text 复制代码
A 查询 Redis

Miss

然后:

text 复制代码
A 查询 MySQL

得到 100

但是 A 还没有写缓存。

此时 B:

text 复制代码
UPDATE MySQL = 200

DEL Redis

因为 Redis 本来就没有:

text 复制代码
DEL 什么都没删

然后 A:

text 复制代码
SET Redis = 100

最终:

text 复制代码
MySQL = 200

Redis = 100

还是出现了不一致。

完整时序:

text 复制代码
请求 A                    请求 B

GET Redis
Miss

SELECT MySQL
得到 100

                           UPDATE MySQL 200

                           DEL Redis

SET Redis 100

这就是:

text 复制代码
旧查询结果晚于更新请求写入缓存

十一、这时候就出现了"延迟双删"

经典思路:

text 复制代码
删除缓存

↓

更新数据库

↓

等待一小段时间

↓

再次删除缓存

也就是:

text 复制代码
DEL

UPDATE

Sleep

DEL

代码:

go 复制代码
func UpdateProduct(
	ctx context.Context,
	id int64,
	price float64,
) error {

	key :=
		fmt.Sprintf(
			"product:%d",
			id,
		)

	// 第一次删除缓存
	_ = rdb.Del(
		ctx,
		key,
	).Err()

	// 更新数据库
	err := db.WithContext(ctx).
		Model(&Product{}).
		Where(
			"id = ?",
			id,
		).
		Update(
			"price",
			price,
		).Error

	if err != nil {
		return err
	}

	// 延迟
	time.Sleep(
		500 * time.Millisecond,
	)

	// 第二次删除缓存
	_ = rdb.Del(
		ctx,
		key,
	).Err()

	return nil
}

第二次删除的作用就是:

text 复制代码
把并发读请求重新写入的旧缓存再次删掉

十二、为什么要"延迟"?

假设读请求:

text 复制代码
Redis Miss
↓
查 MySQL
↓
得到旧值
↓
准备写缓存

更新请求:

text 复制代码
删缓存
↓
更新数据库
↓
等待

这段等待时间就是给之前已经发出去的:

text 复制代码
旧查询

足够时间完成。

然后:

text 复制代码
第二次 DEL

把它重新写进去的旧值删掉。


十三、延迟多久合适?

这个没有固定答案。

不要死记:

text 复制代码
500ms

或者:

text 复制代码
1 秒

合理的思路是:

text 复制代码
延迟时间

>

正常情况下:

一次数据库查询
+
业务逻辑
+
Redis 回写

例如你的接口正常:

text 复制代码
MySQL 查询 30ms

网络 20ms

业务处理 50ms

Redis SET 10ms

那么可以考虑:

text 复制代码
300ms ~ 1s

具体还是要看系统。


十四、为什么不建议直接 Sleep?

如果你是 Web 服务:

go 复制代码
time.Sleep(
    1 * time.Second,
)

意味着这个请求线程或者 Goroutine 要多等 1 秒。

虽然 Go 的 Goroutine 很轻量,但从业务设计上依然不够优雅。

更好的方法是:

text 复制代码
异步延迟任务

比如:

text 复制代码
Redis 延迟队列

RocketMQ 延迟消息

RabbitMQ TTL + DLX

Kafka + 调度器

定时任务

十五、使用 MQ 实现延迟双删

更新:

go 复制代码
func UpdateProduct(
	ctx context.Context,
	id int64,
	price float64,
) error {

	key :=
		fmt.Sprintf(
			"product:%d",
			id,
		)

	// 第一次删除
	_ = rdb.Del(
		ctx,
		key,
	)

	// 更新数据库
	err := db.WithContext(ctx).
		Model(&Product{}).
		Where(
			"id = ?",
			id,
		).
		Update(
			"price",
			price,
		).Error

	if err != nil {
		return err
	}

	// 发延迟消息
	msg := CacheDeleteMessage{
		Key: key,
	}

	return mq.SendDelay(
		"cache-delete",
		msg,
		1*time.Second,
	)
}

消费者:

go 复制代码
func ConsumeCacheDelete(
	msg CacheDeleteMessage,
) error {

	return rdb.Del(
		context.Background(),
		msg.Key,
	).Err()
}

这样请求线程不需要:

go 复制代码
Sleep

十六、真正棘手的问题:数据库成功,Redis 删除失败

现在考虑:

text 复制代码
UPDATE MySQL 成功

然后:

text 复制代码
DEL Redis 失败

比如:

text 复制代码
Redis 网络抖动

Redis 宕机

连接超时

客户端异常

最终:

text 复制代码
MySQL = 新数据

Redis = 旧数据

这才是真正工程上最重要的问题。

简单代码:

go 复制代码
db.Update(...)

rdb.Del(...)

无法保证:

text 复制代码
MySQL

和

Redis

同时成功。


十七、为什么不能用 MySQL 事务包住 Redis?

很多人会想:

go 复制代码
tx := db.Begin()

tx.Update(...)

rdb.Del(...)

tx.Commit()

是不是就行?

不行。

因为:

text 复制代码
MySQL Transaction

只能控制:

text 复制代码
MySQL

Redis 并不属于这个事务。

你做不到:

text 复制代码
BEGIN

UPDATE MySQL

DEL Redis

COMMIT 两者

MySQL 和 Redis 是两个独立系统。

这已经是:

text 复制代码
分布式事务

问题了。


十八、为什么本地事务无法覆盖 Redis?

MySQL 的事务范围大概是:

text 复制代码
BEGIN

↓

SQL

↓

InnoDB

↓

Redo Log

↓

Undo Log

↓

COMMIT

Redis:

text 复制代码
客户端

↓

TCP

↓

Redis Server

↓

DEL key

两条完全独立的执行链。

所以:

text 复制代码
db.Commit()

无法回滚:

text 复制代码
Redis DEL

同样:

text 复制代码
Redis DEL

也无法控制 MySQL rollback。


十九、解决方法一:删除缓存失败重试

最简单工程方案:

go 复制代码
func DeleteCacheWithRetry(
	ctx context.Context,
	key string,
) error {

	var lastErr error

	for i := 0; i < 3; i++ {

		err :=
			rdb.Del(
				ctx,
				key,
			).Err()

		if err == nil {
			return nil
		}

		lastErr = err

		time.Sleep(
			time.Duration(
				i+1,
			) *
				100 *
				time.Millisecond,
		)
	}

	return lastErr
}

更新:

go 复制代码
err := updateMySQL()

if err != nil {
    return err
}

err =
    DeleteCacheWithRetry(
        ctx,
        key,
    )

这可以提高成功率。

但是它依然不是 100% 可靠。

因为:

text 复制代码
服务突然宕机

怎么办?


二十、解决方法二:消息队列

思路:

text 复制代码
更新 MySQL

↓

发送 MQ

↓

消费者删除 Redis

消费者删除失败:

text 复制代码
重试

代码:

go 复制代码
func UpdateProduct(
	ctx context.Context,
	product *Product,
) error {

	err :=
		db.WithContext(ctx).
			Save(
				product,
			).Error

	if err != nil {
		return err
	}

	msg :=
		CacheInvalidationMessage{
			Key:
				fmt.Sprintf(
					"product:%d",
					product.ID,
				),
		}

	return mq.Publish(
		"cache-delete",
		msg,
	)
}

消费者:

go 复制代码
func Consume(
	msg CacheInvalidationMessage,
) error {

	err :=
		rdb.Del(
			context.Background(),
			msg.Key,
		).Err()

	if err != nil {
		return err
	}

	return nil
}

MQ 可以通过:

text 复制代码
ACK

Retry

Dead Letter Queue

保证缓存删除最终完成。


二十一、但这里还有一个问题

如果:

text 复制代码
MySQL UPDATE 成功

↓

服务崩溃

↓

MQ 还没发送

怎么办?

还是丢消息。

时序:

text 复制代码
UPDATE MySQL ✅

进程 Crash 💥

MQ Publish ❌

数据还是不一致。

于是出现更可靠的设计:

text 复制代码
Transactional Outbox

二十二、本地消息表方案

我们在 MySQL 里建一张消息表:

sql 复制代码
CREATE TABLE outbox_event (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    event_type VARCHAR(50) NOT NULL,
    aggregate_id BIGINT NOT NULL,
    payload JSON NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    retry_count INT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL
);

然后:

text 复制代码
更新业务数据

+

插入消息表

放在同一个 MySQL 事务。


二十三、事务消息代码

go 复制代码
func UpdateProduct(
	ctx context.Context,
	product *Product,
) error {

	return db.WithContext(ctx).
		Transaction(
			func(
				tx *gorm.DB,
			) error {

				// 1. 更新业务表
				err :=
					tx.Save(
						product,
					).Error

				if err != nil {
					return err
				}

				// 2. 写 Outbox
				event :=
					OutboxEvent{
						EventType:
							"PRODUCT_UPDATED",

						AggregateID:
							product.ID,

						Payload:
							fmt.Sprintf(
								`{"id":%d}`,
								product.ID,
							),

						Status: 0,
					}

				if err :=
					tx.Create(
						&event,
					).Error;
					err != nil {

					return err
				}

				return nil
			},
		)
}

因为:

text 复制代码
product

和

outbox_event

都在 MySQL。

所以:

text 复制代码
要么同时提交

要么同时回滚

二十四、后台任务扫描 Outbox

go 复制代码
func DispatchOutbox() {

	for {

		var events []OutboxEvent

		db.Where(
			"status = ?",
			0,
		).
			Limit(100).
			Find(
				&events,
			)

		for _, event :=
			range events {

			err :=
				publishEvent(
					event,
				)

			if err != nil {

				db.Model(
					&event,
				).
					Update(
						"retry_count",
						gorm.Expr(
							"retry_count + 1",
						),
					)

				continue
			}

			db.Model(
				&event,
			).
				Update(
					"status",
					1,
				)
		}

		time.Sleep(
			1 * time.Second,
		)
	}
}

然后 MQ Consumer:

go 复制代码
func HandleProductUpdated(
	event ProductUpdatedEvent,
) error {

	key :=
		fmt.Sprintf(
			"product:%d",
			event.ID,
		)

	return rdb.Del(
		context.Background(),
		key,
	).Err()
}

这样可靠性就比:

text 复制代码
UPDATE

+

DEL

高很多。


二十五、还有更优雅的方案:直接监听 MySQL Binlog

这就是很多大型系统喜欢的:

text 复制代码
CDC

全称:

text 复制代码
Change Data Capture

变化数据捕获。

基本思路:

text 复制代码
业务代码

只更新 MySQL

不关心 Redis。

例如:

sql 复制代码
UPDATE product
SET price = 6999
WHERE id = 1;

MySQL 会写:

text 复制代码
Binlog

然后 CDC 系统监听:

text 复制代码
Binlog

↓

检测 product 更新

↓

删除 Redis

二十六、架构会变成这样

text 复制代码
业务服务
   │
   │ UPDATE
   ▼
MySQL
   │
   │ Binlog
   ▼
CDC
   │
   ├── Canal
   ├── Debezium
   └── Maxwell
   │
   ▼
MQ
   │
   ▼
Cache Consumer
   │
   ▼
Redis DEL

业务代码非常简单:

go 复制代码
func UpdateProduct(
	product Product,
) error {

	return db.Save(
		&product,
	).Error
}

缓存同步完全解耦。


二十七、Binlog 到底是什么?

MySQL 执行:

sql 复制代码
UPDATE product
SET price = 6999
WHERE id = 1;

除了修改 InnoDB 数据页,还会记录:

text 复制代码
Binary Log

也就是:

text 复制代码
binlog

binlog 主要记录:

text 复制代码
数据库发生了什么修改

常用于:

text 复制代码
MySQL 主从复制

数据恢复

CDC

审计

二十八、Binlog 有哪些格式?

主要有:

text 复制代码
STATEMENT

ROW

MIXED

STATEMENT

记录 SQL:

sql 复制代码
UPDATE product
SET price = 6999
WHERE id = 1;

ROW

记录行变化。

比如:

text 复制代码
Before:

id = 1
price = 5999

变成:

text 复制代码
After:

id = 1
price = 6999

做 CDC 一般更适合:

text 复制代码
ROW

因为它能明确知道:

text 复制代码
哪一行

从什么值

变成什么值

二十九、Canal 为什么能监听 MySQL?

MySQL 主从复制本身就是:

text 复制代码
Master
  │
  │ Binlog
  ▼
Slave

Canal 本质上:

text 复制代码
伪装成一个 MySQL Slave

然后向 MySQL Master 请求:

text 复制代码
Binlog Event

流程大致:

text 复制代码
Canal
  │
  │ register slave
  ▼
MySQL
  │
  │ dump binlog
  ▼
Canal

Canal 收到:

text 复制代码
UPDATE product

以后解析:

text 复制代码
table = product

type = UPDATE

id = 1

然后就可以:

text 复制代码
DEL product:1

三十、CDC Consumer 伪代码

go 复制代码
func HandleBinlogEvent(
	event BinlogEvent,
) error {

	if event.Database !=
		"shop" {

		return nil
	}

	if event.Table !=
		"product" {

		return nil
	}

	switch event.Type {

	case "UPDATE",
		"DELETE":

		id :=
			event.After[
				"id",
			]

		key :=
			fmt.Sprintf(
				"product:%v",
				id,
			)

		return rdb.Del(
			context.Background(),
			key,
		).Err()
	}

	return nil
}

这样:

text 复制代码
MySQL

是唯一数据源

Redis 只是:

text 复制代码
派生数据

这是一种非常重要的系统设计思想。


三十一、Redis 和 MySQL 一致性的核心思想

一定要记住:

不要试图让 Redis 和 MySQL 成为两个平级的"真相来源"。

正确设计应该是:

text 复制代码
MySQL

Source of Truth

Redis:

text 复制代码
Cache

可以丢。

可以重建。

MySQL 才是最终依据。

所以:

text 复制代码
缓存不一致

↓

删掉

↓

重新从 MySQL 构建

这比:

text 复制代码
不断想办法同步两个数据库

简单得多。


三十二、接下来往底层看:MySQL UPDATE 到底发生了什么?

执行:

sql 复制代码
UPDATE product
SET price = 6999
WHERE id = 1;

从 InnoDB 视角看,大概会经历:

text 复制代码
SQL Layer

↓

Parser

↓

Optimizer

↓

Executor

↓

InnoDB

↓

Buffer Pool

↓

Undo Log

↓

Redo Log

↓

Binlog

↓

Commit

三十三、Buffer Pool 是什么?

MySQL 并不是每次:

sql 复制代码
SELECT

或者:

sql 复制代码
UPDATE

都直接读写磁盘。

InnoDB 有一个非常重要的组件:

text 复制代码
Buffer Pool

可以理解成:

text 复制代码
MySQL 自己的内存缓存

例如磁盘:

text 复制代码
product.ibd

中的页面会加载进:

text 复制代码
Buffer Pool

UPDATE 时:

text 复制代码
先改内存 Page

这个页面就会变成:

text 复制代码
Dirty Page

脏页。

以后后台线程再刷盘。


三十四、为什么数据库提交不需要每次直接刷数据页?

因为有:

text 复制代码
Redo Log

假设:

text 复制代码
UPDATE product

修改了 Buffer Pool。

如果突然断电:

text 复制代码
内存数据消失

怎么办?

Redo Log 会记录:

text 复制代码
对数据页做了什么修改

数据库启动后:

text 复制代码
Redo Recovery

重新恢复。

所以:

text 复制代码
先写日志

以后慢慢刷数据页

就是经典的:

text 复制代码
WAL

Write Ahead Logging。


三十五、Undo Log 又是什么?

比如事务:

sql 复制代码
BEGIN;

UPDATE account
SET balance = 900
WHERE id = 1;

原来:

text 复制代码
balance = 1000

为了支持:

text 复制代码
ROLLBACK

InnoDB 需要知道旧值。

于是记录:

text 复制代码
Undo Log

类似:

text 复制代码
balance 原来 = 1000

如果事务回滚:

text 复制代码
1000 ← 恢复

Undo 还和:

text 复制代码
MVCC

密切相关。


三十六、MySQL Commit 为什么涉及两阶段提交?

MySQL 里存在两个重要日志:

text 复制代码
InnoDB Redo Log

Server Binlog

两者必须保持一致。

否则可能出现:

text 复制代码
Redo 有

Binlog 没有

或者:

text 复制代码
Binlog 有

Redo 没有

这会导致:

text 复制代码
主库

和

从库

数据不一致。

所以 MySQL 使用:

text 复制代码
Two Phase Commit

思想协调。


三十七、简化后的事务提交流程

可以抽象成:

text 复制代码
UPDATE

↓

修改 Buffer Pool

↓

写 Undo

↓

写 Redo Prepare

↓

写 Binlog

↓

Redo Commit

大致时序:

text 复制代码
InnoDB                MySQL Server

Redo Prepare
                         │
                         ▼
                     Write Binlog
                         │
                         ▼
Redo Commit

这就是两阶段提交。


三十八、源码级简化逻辑

下面不是 MySQL 某个版本的原始代码,而是为了理解其提交路径写的"源码级伪代码"。

cpp 复制代码
int trx_commit(
    Transaction* trx
) {

    // 1. 写 redo prepare
    redo_prepare(trx);

    // 2. Server 层写 binlog
    if (
        write_binlog(trx)
        != SUCCESS
    ) {

        rollback(trx);

        return ERROR;
    }

    // 3. redo commit
    redo_commit(trx);

    trx->state =
        COMMITTED;

    return SUCCESS;
}

为什么要这样?

假设:

text 复制代码
Redo Prepare 成功

Binlog 写成功

机器宕机

Redo Commit 还没执行

MySQL 恢复时可以看:

text 复制代码
Binlog 是否存在对应事务

如果存在:

text 复制代码
提交

如果没有:

text 复制代码
回滚

从而保证:

text 复制代码
Redo

和

Binlog

逻辑一致。


三十九、这和 Redis 一致性有什么关系?

关系非常大。

因为 CDC 依赖:

text 复制代码
Binlog

只有当 MySQL 真正把事务提交以后:

text 复制代码
Binlog Event

才成为可靠的数据变化来源。

然后:

text 复制代码
CDC
↓
Redis

所以相比业务代码直接:

text 复制代码
db.Update()

rdb.Del()

Binlog 驱动方案更加解耦。


四十、再来看 Redis 底层

Redis 的一个核心特征是:

text 复制代码
大部分命令执行阶段是串行的

例如:

text 复制代码
SET

GET

DEL

不会在命令逻辑内部被另一个普通命令随意插进去。

这就是为什么:

text 复制代码
单个 Redis 命令

通常具备原子性。


四十一、Redis DEL 底层大概发生了什么?

执行:

bash 复制代码
DEL product:1

从逻辑层面大致是:

text 复制代码
Client

↓

Command Parser

↓

Command Lookup

↓

DEL Command

↓

Dictionary Lookup

↓

Delete Key

↓

Reply

源码级简化:

c 复制代码
void delCommand(
    client *c
) {

    int deleted = 0;

    for (
        int i = 1;
        i < c->argc;
        i++
    ) {

        if (
            dbDelete(
                c->db,
                c->argv[i]
            )
        ) {

            deleted++;
        }
    }

    addReplyLongLong(
        c,
        deleted
    );
}

数据库删除的核心可以抽象成:

c 复制代码
int dbDelete(
    redisDb *db,
    robj *key
) {

    if (
        dictDelete(
            db->dict,
            key
        )
        == DICT_OK
    ) {

        return 1;
    }

    return 0;
}

真实 Redis 源码会比这复杂,包括:

text 复制代码
过期字典

引用释放

lazyfree

通知

统计

等等。

但核心思想就是:

text 复制代码
Redis DB

↓

Hash Dictionary

↓

删除 Key

四十二、Redis 里的 Key 到底存在哪里?

Redis 内部数据库可以简化成:

c 复制代码
typedef struct redisDb {

    dict *dict;

    dict *expires;

} redisDb;

其中:

text 复制代码
dict

保存:

text 复制代码
Key → Value

而:

text 复制代码
expires

保存:

text 复制代码
Key → Expire Time

也就是说:

text 复制代码
Redis 本质上大量依赖 Hash Table

查询:

text 复制代码
O(1)

左右。


四十三、为什么 DEL 很快?

因为:

text 复制代码
dictDelete

本质通常是 Hash Table 查找。

例如:

text 复制代码
product:1

计算:

text 复制代码
hash(product:1)

定位 bucket。

然后删除。

所以:

text 复制代码
GET
SET
DEL

大部分都非常快。

这也是缓存能承受大量 QPS 的原因。


四十四、但是 Redis 单线程不代表整个 Redis 只有一个线程

这是一个常见误区。

Redis 经典模型是:

text 复制代码
命令执行核心串行

但现代 Redis 在:

text 复制代码
网络 IO
异步释放
持久化
后台任务

方面可能使用多个线程。

真正应该记住的是:

Redis 绝大多数命令的数据结构修改逻辑按顺序执行,因此单个命令可以天然保持很强的原子性。


四十五、缓存一致性问题其实和 Redis 单线程关系不大

有人会说:

text 复制代码
Redis 单线程

所以不会数据不一致

这是错误的。

问题不在:

text 复制代码
Redis 内部多个线程竞争

而在:

text 复制代码
MySQL

Redis

多个客户端

之间的分布式并发顺序。

比如:

text 复制代码
请求 A

请求 B

操作的系统是:

text 复制代码
MySQL + Redis

整个流程不是原子的。

Redis 自己单线程,也无法解决:

text 复制代码
跨系统一致性

四十六、为什么 Redis 事务也解决不了?

Redis 有:

text 复制代码
MULTI

EXEC

例如:

bash 复制代码
MULTI

DEL product:1

EXEC

但依然无法把:

sql 复制代码
UPDATE product

放进同一个 Redis 事务。

所以:

text 复制代码
Redis Transaction

不能解决:

text 复制代码
MySQL + Redis

跨系统事务。


四十七、如果真的要求强一致怎么办?

如果业务真的要求:

text 复制代码
任何时刻

Redis 和 MySQL 必须完全一致

那最简单的答案可能是:

text 复制代码
不要用缓存

因为缓存本质上就是:

text 复制代码
数据副本

一旦有两个副本:

text 复制代码
一致性复杂度就上来了

对于:

text 复制代码
账户余额

扣款

库存最终扣减

订单状态

不要把 Redis 缓存当作最终数据源。

例如账户:

sql 复制代码
UPDATE account
SET balance =
    balance - 100
WHERE id = 1
AND balance >= 100;

最终账务应以:

text 复制代码
MySQL / 账务数据库

为准。

Redis 可以:

text 复制代码
做查询缓存

但不能简单替代最终账本。


四十八、库存是不是可以放 Redis?

可以。

但要区分:

text 复制代码
缓存库存

和:

text 复制代码
扣库存系统

比如秒杀:

text 复制代码
Redis DECR

用于:

text 复制代码
高性能预扣库存

然后:

text 复制代码
MQ
↓
MySQL

最终落库。

这已经不是:

text 复制代码
普通缓存一致性

而是:

text 复制代码
Redis 作为业务状态组件

属于另外一种架构设计。


四十九、缓存穿透、击穿、雪崩和一致性的关系

做 Cache Aside 时,还需要处理三个经典问题:

text 复制代码
缓存穿透

缓存击穿

缓存雪崩

五十、缓存穿透

请求一个不存在的数据:

text 复制代码
product:999999999

Redis 没有:

text 复制代码
Miss

MySQL 也没有:

text 复制代码
Miss

每次请求都打数据库。

解决:

text 复制代码
缓存空值

比如:

go 复制代码
if errors.Is(
    err,
    gorm.ErrRecordNotFound,
) {

    rdb.Set(
        ctx,
        key,
        "__NULL__",
        1*time.Minute,
    )

    return nil, nil
}

查询:

go 复制代码
if value ==
    "__NULL__" {

    return nil, nil
}

也可以用:

text 复制代码
Bloom Filter

五十一、缓存击穿

某个热点 Key:

text 复制代码
product:1

突然过期。

同时:

text 复制代码
10000 个请求

进来。

都:

text 复制代码
Redis Miss

然后同时查询 MySQL。

可能直接把数据库打爆。

解决方法之一:

text 复制代码
SingleFlight

五十二、Go 使用 singleflight

go 复制代码
var group singleflight.Group

func GetProduct(
	ctx context.Context,
	id int64,
) (*Product, error) {

	key :=
		fmt.Sprintf(
			"product:%d",
			id,
		)

	value, err :=
		rdb.Get(
			ctx,
			key,
		).Result()

	if err == nil {

		var p Product

		json.Unmarshal(
			[]byte(value),
			&p,
		)

		return &p, nil
	}

	result, err, _ :=
		group.Do(
			key,
			func() (
				interface{},
				error,
			) {

				// 双重检查
				value, err :=
					rdb.Get(
						ctx,
						key,
					).Result()

				if err == nil {

					var p Product

					json.Unmarshal(
						[]byte(value),
						&p,
					)

					return &p, nil
				}

				var p Product

				if err :=
					db.First(
						&p,
						id,
					).Error;
					err != nil {

					return nil, err
				}

				data, _ :=
					json.Marshal(
						p,
					)

				rdb.Set(
					ctx,
					key,
					data,
					10*time.Minute,
				)

				return &p, nil
			},
		)

	if err != nil {
		return nil, err
	}

	return result.(*Product),
		nil
}

这样:

text 复制代码
10000 个请求

↓

只有一个请求查 MySQL

↓

其他等待结果

五十三、缓存雪崩

假设:

text 复制代码
100 万个 Key

全部设置:

text 复制代码
10 分钟过期

正好同一时间批量写进去。

10 分钟以后:

text 复制代码
同时过期

大量请求打到 MySQL。

解决:

text 复制代码
TTL 随机化

例如:

go 复制代码
ttl :=
	10*time.Minute +
	time.Duration(
		rand.Intn(300),
	)*
		time.Second

即:

text 复制代码
10 分钟

+

0~5 分钟随机值

避免同时失效。


五十四、业务里最终应该选什么方案?

如果是普通后台系统:

text 复制代码
用户信息

商品详情

文章详情

配置数据

建议:

text 复制代码
Cache Aside

+

先更新数据库

+

再删除缓存

足够了。


五十五、中等规模系统

如果:

text 复制代码
流量高

缓存很多

删除失败不能接受

可以使用:

text 复制代码
MySQL

+

MQ

+

缓存删除重试

或者:

text 复制代码
Transactional Outbox

五十六、大型系统

可以考虑:

text 复制代码
MySQL

↓

Binlog

↓

Canal / Debezium

↓

Kafka

↓

Cache Invalidation Service

↓

Redis

业务层只处理:

text 复制代码
数据库

缓存变成:

text 复制代码
异步派生状态

这是非常漂亮的一种架构。


五十七、推荐架构

最终推荐理解成下面这套:

text 复制代码
                       ┌──────────────┐
                       │   Client     │
                       └──────┬───────┘
                              │
                              ▼
                      ┌───────────────┐
                      │ Application   │
                      └──────┬────────┘
                             │
               ┌─────────────┴──────────────┐
               │                            │
               ▼                            ▼
          Redis GET                     MySQL UPDATE
               │                            │
               │                            ▼
               │                         Binlog
               │                            │
               │                            ▼
               │                         CDC
               │                            │
               │                            ▼
               │                           MQ
               │                            │
               │                            ▼
               │                    Cache Consumer
               │                            │
               └────────────────────────────┤
                                            ▼
                                       Redis DEL

五十八、最重要的一点:不要追求"理论上的绝对同步"

很多时候所谓:

text 复制代码
Redis 和 MySQL 保证一致性

真正目标并不是:

text 复制代码
任何一个纳秒

两个系统都完全一样

现实系统更常见的是:

text 复制代码
最终一致性

也就是允许:

text 复制代码
几十毫秒

几百毫秒

几秒

的短暂不一致。

但最终必须恢复:

text 复制代码
Redis == MySQL

或者更准确地说:

text 复制代码
Redis 最终反映 MySQL 的最新状态

五十九、一致性其实是在做取舍

分布式系统永远在几个目标之间权衡:

text 复制代码
性能

一致性

可用性

复杂度

成本

如果不用 Redis:

text 复制代码
一致性简单

性能下降

用了 Redis:

text 复制代码
性能提高

一致性复杂

加入 MQ:

text 复制代码
可靠性提高

系统复杂度提高

加入 CDC:

text 复制代码
解耦更好

基础设施更复杂

所以:

text 复制代码
没有绝对最好的方案

只有适合业务规模的方案

六十、面试时应该怎么回答?

如果面试官问:

Redis 和 MySQL 如何保证数据一致性?

你可以按照下面这个逻辑回答。

第一步:

text 复制代码
MySQL 作为最终数据源,
Redis 只作为缓存。

第二步:

text 复制代码
使用 Cache Aside 模式。

查询:

text 复制代码
Redis

↓

Miss

↓

MySQL

↓

回写 Redis

更新:

text 复制代码
UPDATE MySQL

↓

DELETE Redis

而不是:

text 复制代码
UPDATE Redis

第三步:

考虑并发。

text 复制代码
高并发下仍然可能出现旧查询覆盖新缓存。

可以通过:

text 复制代码
延迟双删

或者:

text 复制代码
异步删除

降低风险。

第四步:

考虑删除失败。

text 复制代码
MQ 重试

Transactional Outbox

确保删除操作最终执行。

第五步:

大型系统可以通过:

text 复制代码
Binlog

+

Canal / Debezium

+

MQ

实现缓存失效。

最后:

text 复制代码
目标通常不是强一致,

而是保证最终一致性。

六十一、最后从源码层面总结整个流程

假设执行:

sql 复制代码
UPDATE product
SET price = 6999
WHERE id = 1;

MySQL 内部:

text 复制代码
SQL Parser

↓

Optimizer

↓

Executor

↓

InnoDB

↓

Buffer Pool 修改 Page

↓

Undo Log

↓

Redo Prepare

↓

Binlog

↓

Redo Commit

然后:

text 复制代码
Binlog

↓

CDC

↓

MQ

↓

Redis DEL

Redis:

text 复制代码
Client Command

↓

Event Loop

↓

DEL

↓

Hash Table Lookup

↓

Delete Key

↓

释放 Value

下一次读:

text 复制代码
GET product:1

↓

Redis Miss

↓

SELECT MySQL

↓

6999

↓

SET Redis

↓

缓存恢复

整个 Redis + MySQL 一致性的本质其实就是:

不要尝试让两个独立系统同时完成一次原子修改,而应该明确 MySQL 是数据真相来源,Redis 是可以删除和重新构建的副本,再通过缓存失效、重试、消息队列或 Binlog CDC 保证 Redis 最终追上 MySQL。

如果只需要记住一句话,可以记:

text 复制代码
MySQL 负责正确。

Redis 负责快。

缓存过期或不可信时,删掉重建。

高可靠场景,用 Binlog 驱动缓存失效。

这才是 Redis 和 MySQL 数据一致性的核心。

相关推荐
大牧师1 小时前
TypeORM 学习教程
数据库·sql·学习·node.js·orm·nest.js·typeorm
IvorySQL1 小时前
PostgreSQL 日报| PGQ 功能发现严重缺陷(9 月 4 日)
数据库·postgresql
后台模板学习1 小时前
用 IM 即时聊天项目一次讲清消息去重算法的踩坑与解决方案
java·数据库·spring
imbackneverdie1 小时前
国内有哪些比较全面的生物医学相关数据库?
大数据·数据库·人工智能·ai·信息可视化·aigc·科研
数字智核2 小时前
2026昆山工厂空压机突然停机怎么办?找谁抢修
服务器·网络·数据库
AAA代码批发商2 小时前
Days 39 Linux C 开发之 SQLite 数据库完整学习笔记
linux·c语言·数据库
笑梦无境2 小时前
mysql的安装及配置(3)
数据库·mysql·adb
电商API_180079052472 小时前
跨境1688代采商家,如何借助API打通供应链,实现效率跃迁
大数据·数据库·网络爬虫
小雪崩2 小时前
嵌入式学习 day40:数据库
数据库·学习