Redis 和 MySQL 如何保证数据一致性?从缓存更新到延迟双删完整讲解

在后端系统里,MySQL + Redis 几乎是最常见的一套组合。

MySQL 负责持久化数据,Redis 负责缓存热点数据,提高接口性能。

但只要同时使用 Redis 和 MySQL,就绕不开一个经典问题:

Redis 和 MySQL 的数据不一致怎么办?

比如数据库里的商品价格已经从 100 修改成 120,Redis 里却还是 100

用户访问接口时,如果读取 Redis,就可能继续看到旧价格。

这篇文章就从实际开发角度,把 Redis 和 MySQL 数据一致性问题完整讲清楚,并给出大量代码案例。


一、为什么会出现数据不一致?

假设我们有一个商品详情接口:

text 复制代码
用户请求
   ↓
Redis
   ↓ 未命中
MySQL

通常读数据的逻辑是:

java 复制代码
public Product getProduct(Long id) {

    Product product = redis.get("product:" + id);

    if (product != null) {
        return product;
    }

    product = mysql.selectById(id);

    redis.set("product:" + id, product);

    return product;
}

这种模式叫:

text 复制代码
Cache Aside

也叫:

text 复制代码
旁路缓存模式

正常读取流程:

text 复制代码
1. 查询 Redis

2. Redis 有数据
      ↓
   直接返回

3. Redis 没数据
      ↓
   查询 MySQL

4. MySQL 查询成功
      ↓
   写入 Redis

5. 返回结果

看起来非常简单。

真正麻烦的是:

数据修改的时候怎么办?


二、最容易想到的方案:先更新 MySQL,再更新 Redis

很多刚接触缓存的开发者会这样写:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    redisTemplate.opsForValue().set(
        "product:" + product.getId(),
        product
    );
}

流程:

text 复制代码
更新 MySQL
   ↓
更新 Redis

看起来非常合理。

但它其实存在并发问题。


三、为什么"更新数据库 + 更新缓存"会出问题?

假设数据库最开始:

text 复制代码
price = 100

现在有两个线程同时更新:

text 复制代码
线程 A:

price = 120


线程 B:

price = 150

正常情况下:

text 复制代码
A 更新 MySQL:120

B 更新 MySQL:150

A 更新 Redis:120

B 更新 Redis:150

最终:

text 复制代码
MySQL = 150
Redis = 150

没有问题。

但是并发情况下执行顺序可能变成:

text 复制代码
A 更新 MySQL:120

B 更新 MySQL:150

B 更新 Redis:150

A 更新 Redis:120

最终变成:

text 复制代码
MySQL = 150

Redis = 120

数据不一致。

而且 Redis 中这个错误数据可能长期存在。

所以:

修改数据时,一般不推荐"更新数据库 + 更新缓存"。

更常见的方式是:

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

四、为什么通常是删除缓存,而不是更新缓存?

例如商品:

json 复制代码
{
  "id": 1001,
  "name": "MacBook Pro",
  "price": 12999,
  "sales": 1000,
  "stock": 20
}

缓存中的商品数据,可能不是简单从一张表查出来的。

有可能需要:

text 复制代码
product
+
product_category
+
product_brand
+
product_stock
+
promotion

最后拼装:

java 复制代码
ProductDetail detail = new ProductDetail();

detail.setProduct(product);
detail.setCategory(category);
detail.setBrand(brand);
detail.setStock(stock);
detail.setPromotion(promotion);

如果每次数据库更新,都重新计算缓存,会非常麻烦。

所以更合理的思想是:

数据库更新时,只负责删除缓存。

下一次查询时,再重新从数据库加载。

也就是:

text 复制代码
修改:

MySQL
 ↓
删除 Redis


读取:

Redis
 ↓ miss
MySQL
 ↓
重建 Redis

这就是经典的:

text 复制代码
Cache Aside Pattern

五、方案一:先删除 Redis,再更新 MySQL

代码:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    String key = "product:" + product.getId();

    redisTemplate.delete(key);

    productMapper.updateById(product);
}

流程:

text 复制代码
删除 Redis
   ↓
更新 MySQL

这个方案有没有问题?

有。

而且是一个很经典的并发问题。


六、先删缓存为什么会出现脏数据?

假设当前数据:

text 复制代码
MySQL:

price = 100

Redis:

price = 100

现在线程 A 修改价格:

text 复制代码
100 → 120

线程 A:

text 复制代码
删除 Redis

但是此时:

text 复制代码
MySQL 还没有更新

与此同时线程 B 查询商品。

执行顺序可能变成:

text 复制代码
线程 A:删除 Redis

线程 B:查询 Redis
        ↓
        Redis miss

线程 B:查询 MySQL
        ↓
        price = 100

线程 B:把 100 写入 Redis

线程 A:更新 MySQL
        ↓
        price = 120

最终:

text 复制代码
MySQL = 120

Redis = 100

数据不一致。

整个过程:

text 复制代码
时间 →

线程A:

删除缓存
               更新数据库120


线程B:

       查询缓存miss
       查询数据库100
       写缓存100

最后缓存中的 100 可能一直存在。

所以一般不推荐:

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

七、方案二:先更新 MySQL,再删除 Redis

这是目前实际项目里最常见的方案。

代码:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    redisTemplate.delete(
        "product:" + product.getId()
    );
}

执行顺序:

text 复制代码
更新 MySQL

↓

删除 Redis

为什么这个方案更好?

因为即使发生并发,出现永久脏数据的概率会小很多。


八、为什么"先更新数据库,再删缓存"更安全?

假设:

text 复制代码
MySQL = 100

Redis = 100

线程 A 查询商品。

线程 B 修改商品。

可能出现:

text 复制代码
线程 A 查询 Redis
        ↓
      得到100


线程 B 更新 MySQL
        ↓
      120


线程 B 删除 Redis

线程 A 这次请求仍然可能拿到旧值:

text 复制代码
100

但是之后:

text 复制代码
Redis 已经被删除

下一次请求:

text 复制代码
Redis miss

↓

查询 MySQL

↓

得到 120

↓

重新写 Redis

最终:

text 复制代码
MySQL = 120

Redis = 120

所以这里产生的是:

短暂不一致。

而不是:

长时间缓存脏数据。

这也是实际工程中通常能够接受的。


九、标准的 Cache Aside 实现

查询:

java 复制代码
public Product getProduct(Long id) {

    String key = "product:" + id;

    Product cache = redisCache.get(key);

    if (cache != null) {
        return cache;
    }

    Product product = productMapper.selectById(id);

    if (product == null) {
        return null;
    }

    redisCache.set(
        key,
        product,
        30,
        TimeUnit.MINUTES
    );

    return product;
}

更新:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    redisCache.delete(
        "product:" + product.getId()
    );
}

删除:

java 复制代码
@Transactional
public void deleteProduct(Long id) {

    productMapper.deleteById(id);

    redisCache.delete(
        "product:" + id
    );
}

这是最基本的版本。


十、问题又来了:Redis 删除失败怎么办?

假设流程:

text 复制代码
更新 MySQL
   ↓
删除 Redis

但是 Redis 突然网络异常:

text 复制代码
MySQL 更新成功

Redis 删除失败

最终:

text 复制代码
MySQL:

price = 120


Redis:

price = 100

又不一致了。

例如:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    redisTemplate.delete(
        "product:" + product.getId()
    );
}

如果:

java 复制代码
redisTemplate.delete()

抛异常。

数据库事务应该怎么办?

很多人会想到:

text 复制代码
Redis 删除失败

↓

MySQL 回滚

但问题是:

Redis 和 MySQL 本身不属于同一个事务。


十一、为什么 @Transactional 管不了 Redis?

例如:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    redisTemplate.delete("product:" + product.getId());

}

@Transactional 通常只能管理:

text 复制代码
MySQL transaction

比如:

sql 复制代码
BEGIN;

UPDATE product
SET price = 120
WHERE id = 1001;

COMMIT;

但是 Redis:

text 复制代码
DEL product:1001

是另一个独立系统。

MySQL 并不知道 Redis 有没有执行成功。

Redis 也不知道 MySQL 有没有提交成功。

所以本质上这是一个:

text 复制代码
分布式数据一致性问题

十二、更隐藏的问题:事务还没有提交,就删除缓存了

这也是实际开发中特别容易踩坑的问题。

代码:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    redisTemplate.delete(
        "product:" + product.getId()
    );

}

你以为执行顺序是:

text 复制代码
更新数据库
↓
提交事务
↓
删除缓存

其实可能是:

text 复制代码
UPDATE MySQL

↓

DELETE Redis

↓

COMMIT MySQL

因为:

java 复制代码
@Transactional

真正的 COMMIT 往往发生在方法执行结束之后。

于是可能出现:

text 复制代码
线程 A:

UPDATE MySQL = 120

DELETE Redis

此时事务还没有 commit

线程 B 恰好查询:

text 复制代码
Redis miss

于是查数据库。

但是因为事务隔离:

text 复制代码
线程 B 读取到旧数据100

然后:

text 复制代码
Redis SET 100

最后:

text 复制代码
线程 A COMMIT

最终:

text 复制代码
MySQL = 120

Redis = 100

这其实是一个非常重要的问题。


十三、解决方案:事务提交后再删除 Redis

Spring 提供了事务同步机制。

可以在事务真正提交成功以后,再删除 Redis。

例如:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    String key = "product:" + product.getId();

    TransactionSynchronizationManager.registerSynchronization(
        new TransactionSynchronization() {

            @Override
            public void afterCommit() {

                redisTemplate.delete(key);

            }

        }
    );
}

执行流程变成:

text 复制代码
UPDATE MySQL

↓

COMMIT

↓

DELETE Redis

相比:

text 复制代码
UPDATE

↓

DELETE Redis

↓

COMMIT

更加安全。


十四、封装一个 AfterCommit 工具

实际项目里不可能每次都写一大坨:

java 复制代码
TransactionSynchronizationManager

可以封装:

java 复制代码
public class TransactionUtils {

    public static void afterCommit(Runnable runnable) {

        if (
            TransactionSynchronizationManager
                .isSynchronizationActive()
        ) {

            TransactionSynchronizationManager
                .registerSynchronization(
                    new TransactionSynchronization() {

                        @Override
                        public void afterCommit() {

                            runnable.run();

                        }

                    }
                );

        } else {

            runnable.run();

        }

    }

}

业务代码:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    String key = "product:" + product.getId();

    TransactionUtils.afterCommit(() -> {

        redisTemplate.delete(key);

    });

}

代码会干净很多。


十五、方案三:延迟双删

这是 Redis 数据一致性里经常会听到的一个词:

text 复制代码
延迟双删

它的基本思想是:

text 复制代码
第一次删除缓存

↓

更新数据库

↓

等待一段时间

↓

第二次删除缓存

流程:

text 复制代码
DEL Redis

↓

UPDATE MySQL

↓

sleep

↓

DEL Redis

代码:

java 复制代码
public void updateProduct(Product product) {

    String key = "product:" + product.getId();

    redisTemplate.delete(key);

    productMapper.updateById(product);

    try {

        Thread.sleep(500);

    } catch (InterruptedException e) {

        Thread.currentThread().interrupt();

    }

    redisTemplate.delete(key);

}

为什么要删两次?

第一次:

text 复制代码
删除旧缓存

第二次:

text 复制代码
删除并发期间可能重新写进去的旧缓存

十六、延迟双删的完整并发过程

假设:

text 复制代码
Redis = 100

MySQL = 100

线程 A 修改:

text 复制代码
100 → 120

线程 B 查询。

执行:

text 复制代码
A:删除 Redis

B:查询 Redis miss

B:查询 MySQL = 100

A:更新 MySQL = 120

B:Redis SET = 100

此时:

text 复制代码
Redis = 100
MySQL = 120

但线程 A 稍微等待:

text 复制代码
500ms

然后再次:

text 复制代码
DEL Redis

最终:

text 复制代码
Redis = empty
MySQL = 120

下次查询:

text 复制代码
Redis miss

↓

MySQL = 120

↓

SET Redis = 120

数据恢复一致。


十七、不要真的 Thread.sleep

虽然:

java 复制代码
Thread.sleep(500);

非常容易理解。

但线上项目通常不建议这么做。

因为 Web 请求线程是非常宝贵的。

假设:

text 复制代码
Tomcat 线程 = 200

每个请求都睡:

text 复制代码
500ms

并发一高:

text 复制代码
大量线程全部 sleep

吞吐量会明显下降。

所以更合理的是:

text 复制代码
异步延迟删除

例如:

java 复制代码
executor.schedule(
    () -> redisTemplate.delete(key),
    500,
    TimeUnit.MILLISECONDS
);

十八、ScheduledExecutorService 实现延迟双删

定义:

java 复制代码
@Configuration
public class CacheConfig {

    @Bean
    public ScheduledExecutorService cacheExecutor() {

        return Executors.newScheduledThreadPool(4);

    }

}

业务代码:

java 复制代码
@Service
public class ProductService {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Resource
    private ProductMapper productMapper;

    @Resource
    private ScheduledExecutorService cacheExecutor;

    @Transactional
    public void updateProduct(Product product) {

        String key = "product:" + product.getId();

        redisTemplate.delete(key);

        productMapper.updateById(product);

        cacheExecutor.schedule(
            () -> redisTemplate.delete(key),
            500,
            TimeUnit.MILLISECONDS
        );

    }

}

但是这里仍然存在一个问题:

text 复制代码
应用重启怎么办?

如果:

text 复制代码
任务刚创建

↓

Java 服务宕机

第二次删除任务就丢了。

所以对于重要业务,我们还需要:

text 复制代码
消息队列

十九、方案四:通过消息队列保证最终一致性

例如:

text 复制代码
MySQL
+
RocketMQ
+
Redis

流程:

text 复制代码
更新 MySQL

↓

发送 MQ

↓

消费者删除 Redis

结构:

text 复制代码
              ┌──────────┐
              │  MySQL   │
              └────┬─────┘
                   │
                   │
                   ▼
              ┌──────────┐
              │    MQ    │
              └────┬─────┘
                   │
                   ▼
              ┌──────────┐
              │  Redis   │
              └──────────┘

例如定义消息:

java 复制代码
@Data
public class CacheDeleteMessage {

    private String key;

}

更新商品:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    CacheDeleteMessage message =
        new CacheDeleteMessage();

    message.setKey(
        "product:" + product.getId()
    );

    rocketMQTemplate.convertAndSend(
        "cache-delete-topic",
        message
    );

}

消费者:

java 复制代码
@Component
@RocketMQMessageListener(
    topic = "cache-delete-topic",
    consumerGroup = "cache-delete-consumer"
)
public class CacheDeleteConsumer
    implements RocketMQListener<CacheDeleteMessage> {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void onMessage(CacheDeleteMessage message) {

        redisTemplate.delete(
            message.getKey()
        );

    }

}

二十、MQ 失败怎么办?

这时又出现一个经典问题。

假设:

text 复制代码
MySQL 更新成功

↓

发送 MQ 失败

那么 Redis 仍然没有删除。

所以简单写:

java 复制代码
updateMysql();

sendMq();

依然不能严格保证一致性。

我们需要解决:

text 复制代码
数据库事务
+
MQ 消息

的一致性。

一种非常实用的方式就是:

本地消息表


二十一、本地消息表方案

我们在 MySQL 里增加一张表:

sql 复制代码
CREATE TABLE cache_event (

    id BIGINT PRIMARY KEY AUTO_INCREMENT,

    event_type VARCHAR(50) NOT NULL,

    cache_key VARCHAR(255) NOT NULL,

    status TINYINT NOT NULL DEFAULT 0,

    retry_count INT NOT NULL DEFAULT 0,

    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,

    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP

);

其中:

text 复制代码
status = 0

表示未处理


status = 1

表示已处理

更新业务数据时,同时插入事件:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    CacheEvent event = new CacheEvent();

    event.setEventType("DELETE");

    event.setCacheKey(
        "product:" + product.getId()
    );

    event.setStatus(0);

    cacheEventMapper.insert(event);

}

因为:

text 复制代码
product

和

cache_event

都属于同一个 MySQL。

所以可以进入同一个事务:

text 复制代码
BEGIN

↓

UPDATE product

↓

INSERT cache_event

↓

COMMIT

要么:

text 复制代码
全部成功

要么:

text 复制代码
全部失败

二十二、后台任务处理缓存删除事件

定时扫描:

java 复制代码
@Scheduled(fixedDelay = 1000)
public void processCacheEvent() {

    List<CacheEvent> events =
        cacheEventMapper.selectPendingEvents(100);

    for (CacheEvent event : events) {

        try {

            redisTemplate.delete(
                event.getCacheKey()
            );

            cacheEventMapper.markSuccess(
                event.getId()
            );

        } catch (Exception e) {

            cacheEventMapper.incrementRetry(
                event.getId()
            );

        }

    }

}

SQL:

sql 复制代码
SELECT *
FROM cache_event
WHERE status = 0
ORDER BY id
LIMIT 100;

执行成功:

sql 复制代码
UPDATE cache_event
SET status = 1
WHERE id = ?;

失败:

sql 复制代码
UPDATE cache_event
SET retry_count = retry_count + 1
WHERE id = ?;

这样即使:

text 复制代码
Redis 临时宕机

也没关系。

任务之后还会继续重试。

最终:

text 复制代码
Redis 缓存一定会被删除

这就是:

text 复制代码
最终一致性

二十三、更成熟的方案:Outbox Pattern

本地消息表其实就是一个非常经典的架构模式:

text 复制代码
Transactional Outbox Pattern

简单理解:

把"需要通知其他系统的事件",先和业务数据一起写进数据库。

例如:

text 复制代码
业务事务
        ↓
┌──────────────────────┐
│        MySQL         │
│                      │
│ product              │
│                      │
│ outbox_event         │
└──────────────────────┘
        ↓
   Outbox Worker
        ↓
       MQ
        ↓
     Consumer
        ↓
      Redis

例如表:

sql 复制代码
CREATE TABLE outbox_event (

    id BIGINT PRIMARY KEY AUTO_INCREMENT,

    aggregate_type VARCHAR(100),

    aggregate_id VARCHAR(100),

    event_type VARCHAR(100),

    payload JSON,

    status VARCHAR(20),

    created_at DATETIME DEFAULT CURRENT_TIMESTAMP

);

写业务:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

    OutboxEvent event = new OutboxEvent();

    event.setAggregateType("PRODUCT");

    event.setAggregateId(
        product.getId().toString()
    );

    event.setEventType(
        "PRODUCT_UPDATED"
    );

    event.setPayload(
        JSON.toJSONString(product)
    );

    event.setStatus("PENDING");

    outboxMapper.insert(event);

}

后台 Worker:

java 复制代码
@Scheduled(fixedDelay = 500)
public void sendOutboxEvent() {

    List<OutboxEvent> events =
        outboxMapper.selectPending();

    for (OutboxEvent event : events) {

        try {

            rocketMQTemplate.syncSend(
                "product-events",
                event
            );

            outboxMapper.markSent(
                event.getId()
            );

        } catch (Exception e) {

            log.error(
                "send event failed {}",
                event.getId(),
                e
            );

        }

    }

}

消费者:

java 复制代码
@Override
public void onMessage(OutboxEvent event) {

    if (
        "PRODUCT_UPDATED".equals(
            event.getEventType()
        )
    ) {

        String key =
            "product:" +
            event.getAggregateId();

        redisTemplate.delete(key);

    }

}

二十四、方案五:订阅 MySQL Binlog

还有一种非常经典的方案:

text 复制代码
Canal

或者:

text 复制代码
Debezium

核心思想是:

应用程序只修改 MySQL,不主动操作 Redis。

然后监听 MySQL Binlog。

架构:

text 复制代码
Application
     ↓
   MySQL
     ↓
   Binlog
     ↓
   Canal
     ↓
     MQ
     ↓
Cache Consumer
     ↓
   Redis

业务代码变得非常简单:

java 复制代码
@Transactional
public void updateProduct(Product product) {

    productMapper.updateById(product);

}

完全不操作 Redis。

数据库修改后:

text 复制代码
MySQL Binlog

会记录:

text 复制代码
UPDATE product
SET price = 120
WHERE id = 1001

Canal 捕获这个事件:

json 复制代码
{
  "database": "shop",
  "table": "product",
  "type": "UPDATE",
  "data": [
    {
      "id": "1001",
      "price": "120"
    }
  ]
}

消费者:

java 复制代码
public void handleBinlog(BinlogEvent event) {

    if (
        !"product".equals(event.getTable())
    ) {
        return;
    }

    for (
        Map<String, String> row :
        event.getData()
    ) {

        String id = row.get("id");

        redisTemplate.delete(
            "product:" + id
        );

    }

}

这样 Redis 就变成:

text 复制代码
MySQL 数据变化的消费者

二十五、Canal 方案为什么很适合大型系统?

因为业务代码里不用到处写:

java 复制代码
redisTemplate.delete()

假设现在:

text 复制代码
product-service

order-service

admin-service

promotion-service

都可能修改商品。

如果每一个服务都要记得:

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

非常容易漏。

但如果统一监听:

text 复制代码
MySQL Binlog

那么只要 MySQL 发生变化:

text 复制代码
Binlog

↓

Canal

↓

Redis

就能统一处理。


二十六、Canal 和 MQ 组合

生产环境比较常见:

text 复制代码
MySQL
  ↓
Binlog
  ↓
Canal
  ↓
Kafka
  ↓
Cache Consumer
  ↓
Redis

为什么中间要加 Kafka?

因为可以做到:

text 复制代码
削峰

解耦

重试

消息持久化

多消费者

例如除了 Redis 之外,还可以有:

text 复制代码
Elasticsearch Consumer

Data Warehouse Consumer

Search Consumer

Analytics Consumer

架构:

text 复制代码
                   ┌──────── Redis
                   │
MySQL → Canal → Kafka ───── Elasticsearch
                   │
                   ├──────── Data Warehouse
                   │
                   └──────── Analytics

这已经属于 CDC:

text 复制代码
Change Data Capture

架构。


二十七、缓存一致性到底应该追求强一致还是最终一致?

这里需要理解一个非常重要的工程思想。

很多人会问:

怎么让 Redis 和 MySQL 100% 强一致?

实际上,如果业务真的要求:

text 复制代码
100% 强一致

那么:

最好不要依赖缓存作为正确性判断依据。

例如:

text 复制代码
银行余额

库存扣减

支付状态

订单状态

这些数据不能因为 Redis 的值就直接认为是真的。

比如余额:

java 复制代码
BigDecimal balance =
    redisTemplate.opsForValue()
        .get("balance:" + userId);

然后直接:

java 复制代码
if (
    balance.compareTo(amount) >= 0
) {

    transfer();

}

这种设计风险非常大。

真正关键的数据应该以:

text 复制代码
MySQL

或者其他权威存储

作为最终判断依据。

Redis 更适合:

text 复制代码
缓存

加速查询

热点数据

会话

限流

排行榜

分布式锁

二十八、Redis 缓存最好设置过期时间

无论你采用什么方案,我都建议缓存设置 TTL。

不要:

java 复制代码
redisTemplate.opsForValue()
    .set(key, value);

更推荐:

java 复制代码
redisTemplate.opsForValue()
    .set(
        key,
        value,
        30,
        TimeUnit.MINUTES
    );

原因很简单。

假设所有机制都失效:

text 复制代码
MQ 消息丢失

删除缓存失败

程序 Bug

网络异常

TTL 是最后一道保险。

例如:

text 复制代码
MySQL = 120

Redis = 100

即使没有成功删除。

30 分钟以后:

text 复制代码
Redis 自动过期

下次请求:

text 复制代码
Redis miss

↓

MySQL = 120

↓

Redis = 120

数据自动恢复。


二十九、TTL 最好加入随机值

如果大量缓存统一设置:

text 复制代码
30分钟

可能在某一个时间点一起过期。

造成:

text 复制代码
缓存雪崩

所以可以随机化:

java 复制代码
int ttl =
    30 * 60
    +
    ThreadLocalRandom
        .current()
        .nextInt(300);

redisTemplate.opsForValue()
    .set(
        key,
        product,
        ttl,
        TimeUnit.SECONDS
    );

最终:

text 复制代码
1800 ~ 2100 秒

随机过期。

这样可以避免大量缓存同时失效。


三十、缓存重建也可能导致并发问题

假设一个热门商品:

text 复制代码
product:1001

刚好过期。

此时:

text 复制代码
10000 个请求

同时进来。

全部:

text 复制代码
Redis miss

于是:

text 复制代码
10000 个请求查询 MySQL

数据库瞬间压力巨大。

这就是:

text 复制代码
缓存击穿

三十一、使用分布式锁解决缓存击穿

例如:

java 复制代码
public Product getProduct(Long id) {

    String key =
        "product:" + id;

    Product product =
        redisCache.get(key);

    if (product != null) {

        return product;

    }

    String lockKey =
        "lock:product:" + id;

    boolean locked =
        redisLock.tryLock(
            lockKey,
            10,
            TimeUnit.SECONDS
        );

    if (!locked) {

        try {

            Thread.sleep(50);

        } catch (
            InterruptedException e
        ) {

            Thread.currentThread()
                .interrupt();

        }

        return getProduct(id);

    }

    try {

        // double check

        product =
            redisCache.get(key);

        if (product != null) {

            return product;

        }

        product =
            productMapper
                .selectById(id);

        if (product != null) {

            redisCache.set(
                key,
                product,
                30,
                TimeUnit.MINUTES
            );

        }

        return product;

    } finally {

        redisLock.unlock(lockKey);

    }

}

这里特别关键的一点是:

java 复制代码
product = redisCache.get(key);

if (product != null) {
    return product;
}

锁里面需要再次查询 Redis。

也就是:

text 复制代码
Double Check

因为可能其他线程已经完成了缓存重建。


三十二、使用 Redisson 实现

实际项目中一般不会自己手写 Redis 分布式锁。

可以使用:

text 复制代码
Redisson

例如:

java 复制代码
@Resource
private RedissonClient redissonClient;

完整代码:

java 复制代码
public Product getProduct(Long id) {

    String key =
        "product:" + id;

    Product product =
        redisCache.get(key);

    if (product != null) {

        return product;

    }

    String lockKey =
        "lock:product:" + id;

    RLock lock =
        redissonClient
            .getLock(lockKey);

    try {

        boolean success =
            lock.tryLock(
                3,
                10,
                TimeUnit.SECONDS
            );

        if (!success) {

            throw new RuntimeException(
                "系统繁忙,请稍后重试"
            );

        }

        product =
            redisCache.get(key);

        if (product != null) {

            return product;

        }

        product =
            productMapper
                .selectById(id);

        if (product == null) {

            return null;

        }

        redisCache.set(
            key,
            product,
            30,
            TimeUnit.MINUTES
        );

        return product;

    } catch (
        InterruptedException e
    ) {

        Thread.currentThread()
            .interrupt();

        throw new RuntimeException(e);

    } finally {

        if (
            lock.isHeldByCurrentThread()
        ) {

            lock.unlock();

        }

    }

}

三十三、缓存空对象解决缓存穿透

例如有人不断请求:

text 复制代码
/product/999999999

数据库里根本不存在。

流程一直:

text 复制代码
Redis miss

↓

MySQL miss

攻击者大量请求就会一直打数据库。

解决办法:

text 复制代码
缓存空值

例如:

java 复制代码
Product product =
    productMapper.selectById(id);

if (product == null) {

    redisTemplate.opsForValue()
        .set(
            key,
            "NULL",
            5,
            TimeUnit.MINUTES
        );

    return null;

}

读取:

java 复制代码
Object value =
    redisTemplate.opsForValue()
        .get(key);

if ("NULL".equals(value)) {

    return null;

}

这里空值 TTL 一般不要太长:

text 复制代码
1分钟

5分钟

10分钟

根据业务调整。


三十四、商品查询完整案例

我们把前面的方案整合一下。

java 复制代码
@Service
public class ProductService {

    @Resource
    private ProductMapper productMapper;

    @Resource
    private RedisTemplate<String, Object>
        redisTemplate;

    @Resource
    private RedissonClient redissonClient;

    private static final String
        PRODUCT_PREFIX = "product:";

    public Product getProduct(Long id) {

        String key =
            PRODUCT_PREFIX + id;

        Object cache =
            redisTemplate
                .opsForValue()
                .get(key);

        if (cache != null) {

            if (
                "NULL".equals(cache)
            ) {

                return null;

            }

            return (Product) cache;

        }

        return rebuildCache(id);

    }

    private Product rebuildCache(
        Long id
    ) {

        String key =
            PRODUCT_PREFIX + id;

        String lockKey =
            "lock:" + key;

        RLock lock =
            redissonClient
                .getLock(lockKey);

        try {

            boolean locked =
                lock.tryLock(
                    3,
                    10,
                    TimeUnit.SECONDS
                );

            if (!locked) {

                throw new RuntimeException(
                    "系统繁忙"
                );

            }

            Object cache =
                redisTemplate
                    .opsForValue()
                    .get(key);

            if (cache != null) {

                if (
                    "NULL".equals(cache)
                ) {

                    return null;

                }

                return (Product) cache;

            }

            Product product =
                productMapper
                    .selectById(id);

            if (product == null) {

                redisTemplate
                    .opsForValue()
                    .set(
                        key,
                        "NULL",
                        5,
                        TimeUnit.MINUTES
                    );

                return null;

            }

            long ttl =
                1800 +
                ThreadLocalRandom
                    .current()
                    .nextLong(300);

            redisTemplate
                .opsForValue()
                .set(
                    key,
                    product,
                    ttl,
                    TimeUnit.SECONDS
                );

            return product;

        } catch (
            InterruptedException e
        ) {

            Thread.currentThread()
                .interrupt();

            throw new RuntimeException(e);

        } finally {

            if (
                lock.isHeldByCurrentThread()
            ) {

                lock.unlock();

            }

        }

    }

}

三十五、商品更新完整案例

数据库更新完成后删除缓存:

java 复制代码
@Transactional
public void updateProduct(
    Product product
) {

    productMapper.updateById(product);

    String key =
        PRODUCT_PREFIX +
        product.getId();

    TransactionUtils.afterCommit(() -> {

        deleteCacheWithRetry(key);

    });

}

缓存删除:

java 复制代码
private void deleteCacheWithRetry(
    String key
) {

    try {

        redisTemplate.delete(key);

    } catch (Exception e) {

        log.error(
            "delete cache failed, key={}",
            key,
            e
        );

        saveRetryTask(key);

    }

}

保存重试任务:

java 复制代码
private void saveRetryTask(
    String key
) {

    CacheEvent event =
        new CacheEvent();

    event.setCacheKey(key);

    event.setStatus(0);

    event.setRetryCount(0);

    cacheEventMapper.insert(event);

}

三十六、进一步优化:缓存版本号

有些高并发场景,可以给数据增加:

text 复制代码
version

例如数据库:

sql 复制代码
CREATE TABLE product (

    id BIGINT PRIMARY KEY,

    name VARCHAR(255),

    price DECIMAL(10,2),

    version BIGINT NOT NULL DEFAULT 1

);

更新:

sql 复制代码
UPDATE product
SET
    price = 120,
    version = version + 1
WHERE id = 1001;

缓存:

json 复制代码
{
  "id": 1001,
  "price": 120,
  "version": 8
}

写缓存时比较版本:

text 复制代码
newVersion > cacheVersion

才允许覆盖。

伪代码:

java 复制代码
if (
    newProduct.getVersion()
    >
    cacheProduct.getVersion()
) {

    redis.set(
        key,
        newProduct
    );

}

不过这里要注意:

text 复制代码
GET

比较

SET

本身也不是原子操作。

所以真正实现时通常需要:

text 复制代码
Lua Script

三十七、Lua 实现版本控制写缓存

例如 Redis 中保存:

text 复制代码
Hash

字段:

text 复制代码
version
data

Lua:

lua 复制代码
local key = KEYS[1]

local newVersion =
    tonumber(ARGV[1])

local newData =
    ARGV[2]

local oldVersion =
    redis.call(
        'HGET',
        key,
        'version'
    )

if not oldVersion then

    redis.call(
        'HSET',
        key,
        'version',
        newVersion,
        'data',
        newData
    )

    return 1

end

oldVersion =
    tonumber(oldVersion)

if newVersion > oldVersion then

    redis.call(
        'HSET',
        key,
        'version',
        newVersion,
        'data',
        newData
    )

    return 1

end

return 0

Java:

java 复制代码
DefaultRedisScript<Long> script =
    new DefaultRedisScript<>();

script.setScriptText(luaScript);

script.setResultType(Long.class);

Long result =
    redisTemplate.execute(
        script,
        Collections.singletonList(key),
        version.toString(),
        JSON.toJSONString(product)
    );

这样就能避免:

text 复制代码
旧版本数据

覆盖

新版本数据

三十八、实际项目应该怎么选?

如果是普通项目,比如:

text 复制代码
博客

商城商品

后台管理系统

内容平台

推荐:

text 复制代码
Cache Aside

+

先更新 MySQL

+

事务提交后删除 Redis

+

TTL

已经足够。

整体:

text 复制代码
读取:

Redis
 ↓ miss
MySQL
 ↓
Redis


更新:

MySQL
 ↓ commit
删除 Redis

如果项目并发比较高,可以加:

text 复制代码
延迟双删

+

Redis 分布式锁

+

缓存随机 TTL

如果是中大型系统:

text 复制代码
MySQL

+

MQ

+

Redis

通过消息实现:

text 复制代码
最终一致性

如果业务服务很多:

text 复制代码
微服务

几十个服务

多数据消费者

推荐:

text 复制代码
MySQL Binlog

+

Canal / Debezium

+

Kafka

+

Redis

把缓存同步从业务代码中解耦出来。


三十九、几个方案对比

方案 实现难度 一致性 适合场景
更新 DB + 更新缓存 较差 不推荐
先删缓存再更新 DB 较差 一般不推荐
更新 DB 再删缓存 较好 大多数项目
事务提交后删缓存 更好 推荐
延迟双删 较好 高并发
MQ 异步删缓存 中高 最终一致 中大型系统
本地消息表 高可靠最终一致 核心业务
Canal + Binlog 高可靠最终一致 大型微服务
Redis + 版本号 较强 特殊高并发场景

四十、我更推荐的生产方案

如果让我设计一个普通互联网业务,我通常会优先选择:

text 复制代码
Cache Aside

读取:

text 复制代码
Redis

↓

没有

↓

MySQL

↓

写 Redis

更新:

text 复制代码
BEGIN

↓

UPDATE MySQL

↓

COMMIT

↓

DELETE Redis

Redis:

text 复制代码
设置 TTL
+
随机过期时间

如果 Redis 删除失败:

text 复制代码
记录失败任务

↓

异步重试

整体架构:

text 复制代码
                  ┌───────────────┐
                  │     Client    │
                  └───────┬───────┘
                          │
                          ▼
                  ┌───────────────┐
                  │ Application   │
                  └───────┬───────┘
                          │
                 ┌────────┴─────────┐
                 │                  │
                 ▼                  ▼
              Redis              MySQL
                 ▲                  │
                 │                  │
                 │               COMMIT
                 │                  │
                 └──── DELETE ──────┘

对于大多数业务来说:

没有必要追求 Redis 和 MySQL 在每一个毫秒都绝对一致。

更现实的目标应该是:

text 复制代码
避免长期不一致

+

允许短暂不一致

+

异常情况下能够自动恢复

+

最终保证一致

这才是缓存一致性设计真正的工程思想。


四十一、最后总结

Redis 和 MySQL 数据一致性问题,本质上并不是一道简单的:

text 复制代码
先更新谁?

的问题。

而是涉及:

text 复制代码
并发

事务

缓存失效

网络异常

消息可靠性

分布式系统

最终一致性

等一系列问题。

实际开发中可以记住一句最重要的话:

缓存不是数据源,MySQL 才是数据源。

对于绝大多数业务,我建议采用:

text 复制代码
Cache Aside
+
更新数据库
+
事务提交后删除缓存
+
缓存 TTL
+
删除失败异步重试

如果系统规模继续扩大,再逐步升级到:

text 复制代码
本地消息表
↓
MQ
↓
Canal / Debezium
↓
CDC

而不是一开始就为了"绝对一致"引入一整套复杂架构。

缓存设计真正要解决的,不是"永远不出现旧数据",而是:

即使发生异常,系统也能够在一个可接受的时间窗口内自动恢复到正确状态。

相关推荐
2401_894915531 小时前
GEO 优化源码性能调优:高并发地域请求缓存与索引优化
java·服务器·网络·数据库·缓存
冰暮流星1 小时前
MySQL 流程函数详解
数据库·sql·mysql
半个落月2 小时前
Next.js 16 笔记应用实战:Redis 数据链路与组件两版拆分详解
前端·redis·next.js
晓晓_za8986682 小时前
多租户 GEO 优化系统源码架构:权限隔离与数据分离实现
java·tcp/ip·spring·缓存·微服务·架构
烬羽2 小时前
一个博客系统,为什么要拆成 6 张表?
数据库·mysql
程序员-Benothing2 小时前
MySQL 中的日志类型有哪些?binlog、redo log 和 undo log 的作用和区别是什么?
数据库·mysql
2301_800954992 小时前
MySQL 数据库基础:数据类型、查询与备份恢复
数据库·mysql·oracle
起名真的太难了3 小时前
Mysql的数据类型,查询与备份操作
数据库·sql·mysql