在后端开发中,只要项目里同时用了:
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 数据一致性的核心。