原创

MySQL与Redis缓存一致性问题详解及解决方案

一、缓存一致性问题的背景

在高并发互联网系统中,数据库往往是系统性能的瓶颈。即使使用 MySQL 的索引优化、读写分离、分库分表等技术,在访问量持续增长时仍然会出现性能压力。因此,在架构设计中通常会引入 Redis 作为缓存层。

典型架构如下:

客户端 → 应用服务 → Redis → MySQL

读取流程:

1. 先查 Redis
2. Redis 未命中再查 MySQL
3. 查询结果写入 Redis
4. 返回数据

写入流程:

1. 更新 MySQL
2. 更新或删除 Redis

这种架构虽然能显著降低数据库压力,但同时带来了新的问题:缓存与数据库数据不一致

缓存一致性问题本质是:

缓存中的数据与数据库中的数据出现短暂或长期的不一致状态。

在高并发系统中,这个问题不可完全避免,但可以通过合理的架构设计将问题控制在可接受范围。


二、缓存一致性的核心问题

缓存一致性问题主要来源于以下几个原因:

1 数据更新顺序问题

假设存在以下操作顺序:

线程A:更新数据库
线程B:读取数据库
线程A:删除缓存

如果线程B在缓存删除之前读取数据库,并将旧数据写入缓存,就会出现:

缓存 = 旧数据
数据库 = 新数据

这就是经典的 缓存脏数据问题


2 并发读写问题

高并发场景下常见流程:

1. 查询缓存
2. 缓存不存在
3. 查询数据库
4. 写入缓存

如果多个线程同时查询:

线程1:缓存未命中
线程2:缓存未命中
线程3:缓存未命中

所有线程都会访问数据库。

这会导致:

  • 数据库瞬间压力暴增
  • 缓存击穿

3 延迟写入问题

当系统采用异步写缓存时,可能出现:

数据库已经更新
缓存还未更新

导致短时间内用户读取到旧数据。


三、缓存一致性的三种常见模式

业界常见的缓存模式主要有三种:

  1. Cache Aside Pattern(旁路缓存)
  2. Read Through / Write Through
  3. Write Behind

其中最常用的是 Cache Aside Pattern


四、Cache Aside Pattern(旁路缓存)

旁路缓存是互联网系统最常用的模式。

读取流程:

1 查询缓存
2 未命中查询数据库
3 写入缓存
4 返回数据

示例代码(Java):

public User getUser(Long userId) {

    String key = "user:" + userId;

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

    if (cache != null) {
        return JSON.parseObject(cache, User.class);
    }

    User user = userMapper.selectById(userId);

    if (user != null) {
        redisTemplate.opsForValue().set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);
    }

    return user;
}

更新流程

更新通常采用:

1 更新数据库
2 删除缓存

示例代码:

@Transactional
public void updateUser(User user) {

    userMapper.updateById(user);

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

    redisTemplate.delete(key);
}

为什么删除缓存而不是更新缓存

更新缓存存在以下问题:

  1. 业务复杂
  2. 可能遗漏字段
  3. 容易产生并发问题

删除缓存更加简单可靠。

因此行业最佳实践:

更新数据库 → 删除缓存


五、双写不一致问题

即使采用 Cache Aside 模式,仍然会存在并发问题。

典型流程:

线程A:更新数据库
线程B:读取数据库
线程A:删除缓存
线程B:写入旧缓存

最终:

Redis = 旧数据
MySQL = 新数据

解决方案一:延迟双删

延迟双删是非常常见的解决方案。

流程:

1 更新数据库
2 删除缓存
3 延迟一段时间
4 再次删除缓存

Java示例:

@Transactional
public void updateUser(User user) {

    userMapper.updateById(user);

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

    redisTemplate.delete(key);

    executorService.submit(() -> {
        try {
            Thread.sleep(500);
        } catch (InterruptedException ignored) {
        }

        redisTemplate.delete(key);
    });
}

延迟时间如何设置

延迟时间需要满足:

数据库查询时间 < 延迟时间

一般推荐:

300ms ~ 1s

优点

  • 实现简单
  • 成本低
  • 对绝大多数业务有效

缺点

  • 仍然不是强一致
  • 依赖经验配置延迟时间

六、基于消息队列保证一致性

在复杂系统中,通常采用 MQ方案

更新流程:

1 更新数据库
2 发送消息
3 消费者删除缓存

架构:

应用 → MySQL
     → MQ → Cache Service → Redis

优点

1 解耦业务 2 提高可靠性 3 支持失败重试


示例流程

生产者:

@Transactional
public void updateUser(User user) {

    userMapper.updateById(user);

    mqProducer.send("cache_delete_topic", user.getId());
}

消费者:

@KafkaListener(topics = "cache_delete_topic")
public void deleteCache(Long userId) {

    String key = "user:" + userId;

    redisTemplate.delete(key);
}

消息可靠性设计

必须保证:

数据库更新成功 → 消息必须发送成功

解决方案:

事务消息

常见实现:

  • RocketMQ事务消息
  • Outbox Pattern

七、基于 Binlog 的缓存更新

在大型系统中,更常见的方案是:

监听 MySQL Binlog 同步缓存。

架构:

MySQL → Binlog → Canal → Cache Service → Redis

流程:

1 MySQL数据更新
2 生成Binlog
3 Canal监听Binlog
4 解析数据变化
5 更新或删除Redis

优点

  1. 完全解耦业务代码
  2. 数据变化自动同步
  3. 适用于大型系统

Canal 架构

MySQL
   │
Binlog
   │
Canal Server
   │
消息队列
   │
缓存服务
   │
Redis

示例数据变更事件

Binlog解析后:

table = user
type = UPDATE
id = 1001

缓存服务执行:

DEL user:1001

八、缓存击穿问题

缓存击穿指:

热点Key突然失效
大量请求直接访问数据库

解决方案:

1 互斥锁

只允许一个线程查询数据库。

示例:

String key = "user:" + userId;

String value = redis.get(key);

if (value == null) {

    String lockKey = "lock:user:" + userId;

    if (redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {

        User user = userMapper.selectById(userId);

        redis.set(key, JSON.toJSONString(user), 10, TimeUnit.MINUTES);

        redis.delete(lockKey);

        return user;

    } else {

        Thread.sleep(50);

        return getUser(userId);
    }
}

2 热点数据永不过期

热点数据可以设置:

TTL = -1

由后台任务定期更新。


九、缓存穿透问题

缓存穿透是指:

查询一个不存在的数据
缓存和数据库都不存在
每次请求都会访问数据库

攻击场景:

userId = -1

解决方案一:缓存空值

key = user:-1
value = null
ttl = 1分钟

解决方案二:布隆过滤器

布隆过滤器可以在访问数据库之前判断:

数据是否可能存在

结构:

请求 → BloomFilter → Redis → MySQL

RedisBloom示例

BF.ADD user_filter 1001
BF.EXISTS user_filter 1001

十、缓存雪崩问题

缓存雪崩指:

大量Key同时过期
大量请求直接访问数据库

解决方案

1 随机过期时间

TTL = base + random

示例:

int ttl = 600 + new Random().nextInt(300);

2 多级缓存

本地缓存 (Caffeine)
        ↓
Redis
        ↓
MySQL

架构:

Client
  ↓
Application
  ↓
Caffeine
  ↓
Redis
  ↓
MySQL

十一、示例数据库结构

用户表示例:

CREATE TABLE user (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    username VARCHAR(64) NOT NULL,
    email VARCHAR(128),
    status TINYINT DEFAULT 1,
    create_time DATETIME NOT NULL,
    update_time DATETIME NOT NULL,
    KEY idx_username(username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

十二、缓存一致性方案对比

方案 一致性 复杂度 适用场景
Cache Aside 最终一致 中小系统
延迟双删 较高 常规互联网业务
MQ删除缓存 高并发系统
Binlog同步 很高 大型分布式系统

十三、最佳实践总结

高并发系统推荐架构:

                +----------------+
                |   BloomFilter  |
                +--------+-------+
                         |
                         v
Client → Application → Redis
                         |
                         v
                      MySQL

写入流程:

1 更新数据库
2 删除缓存
3 延迟双删

复杂系统:

MySQL → Binlog → Canal → MQ → Cache Service → Redis

核心原则:

  1. 优先保证数据库正确
  2. 缓存只作为加速层
  3. 允许短暂不一致

互联网系统中很少追求强一致缓存,因为成本极高。

合理的目标是:

在性能与一致性之间取得平衡,实现最终一致性。


正文到此结束
评论插件初始化中...
Loading...