在高并发系统中,MySQL 作为持久化数据库负责数据的可靠存储,Redis 作为高性能缓存层承担读请求压力,二者搭配已成为业界标准架构。然而,这种组合也带来了三大经典挑战:

  1. 数据不一致(缓存与数据库内容不同步)
  2. 缓存穿透(大量请求查询不存在的数据)
  3. 缓存击穿(热点 key 过期瞬间引发 DB 雪崩)

若处理不当,轻则性能下降,重则系统崩溃。本文将从原理出发,结合生产实践,提供一套可落地、高可用的解决方案


一、数据一致性:缓存与数据库如何同步?

常见策略对比

策略 描述 优点 缺点 适用场景
Cache-Aside(旁路缓存) 读:先查缓存,未命中查 DB 并回填;写:先更新 DB,再删除缓存 简单、通用 存在短暂不一致窗口 绝大多数业务
Read/Write Through 缓存层代理读写,自动同步 DB 应用无感知 实现复杂,Redis 不原生支持 中间件封装场景
Write Behind(异步写) 先写缓存,异步批量刷入 DB 写性能极高 数据丢失风险高 日志、监控等非关键数据

推荐方案:Cache-Aside + 删除缓存(而非更新)

为什么“删除缓存”优于“更新缓存”?

  • 避免无效计算:若缓存数据由多个 DB 字段聚合而成(如用户详情 = 用户表 + 订单数 + 积分),每次字段变更都更新缓存成本高。
  • 防止并发覆盖:A、B 两个线程先后更新 DB,若都去更新缓存,可能 B 先完成导致缓存为旧值。

经典问题:先删缓存还是先更新 DB?

方案 A:先更新 DB,再删缓存(推荐)
# 伪代码
update_mysql(data)
delete_redis(key)
  • 优点:即使删除失败,下次读会回源重建缓存,最终一致。
  • 极端情况:删缓存后、新请求回源前,有旧请求写入旧缓存?概率极低,可通过延迟双删缓解。
方案 B:先删缓存,再更新 DB
delete_redis(key)
update_mysql(data)
  • 风险:删缓存后、DB 更新前,若有请求回源,会把旧数据重新写入缓存 → 脏读

🔒 增强版:延迟双删(Delay Double Delete)

delete_redis(key)
update_mysql(data)
sleep(500ms)  # 等待可能的旧读请求完成
delete_redis(key)

适用于对一致性要求极高的场景(如金融),但引入延迟,需权衡。


二、缓存穿透:防御“查无此物”的恶意攻击

什么是缓存穿透?

  • 攻击者或 bug 导致大量请求查询根本不存在的数据(如 user_id = -1)。
  • 缓存无命中 → 直接打到 MySQL → DB 压力剧增甚至宕机。

解决方案

1. 空值缓存(Null Cache)
  • 查询 DB 无结果时,将 null 或特殊标记(如 "EMPTY")写入缓存,设置较短 TTL(如 1~5 分钟)。
user = redis.get(key)
if user is None:
    user = mysql.query(key)
    if user:
        redis.setex(key, 3600, user)
    else:
        redis.setex(key, 60, "EMPTY")  # 防穿透

⚠️ 注意:TTL 不宜过长,避免真实数据插入后无法及时生效。

2. 布隆过滤器(Bloom Filter)
  • 在缓存前加一层布隆过滤器,快速判断 key 是否可能存在
  • 若布隆过滤器返回“不存在”,直接拒绝请求,不查缓存和 DB。
  • 优点:内存占用小,查询快;缺点:存在误判(假阳性),但不会漏判。

组合使用:布隆过滤器(粗筛) + 空值缓存(兜底) = 高效防穿透。


三、缓存击穿:保护“热点 key”过期瞬间

什么是缓存击穿?

  • 某个高并发访问的 key 在 Redis 中过期。
  • 大量请求同时发现缓存失效,瞬间涌向 MySQL → DB 承压。

解决方案

1. 逻辑过期(Logical Expiration)
  • 缓存中不仅存数据,还存一个“逻辑过期时间”。
  • 请求发现逻辑过期后,不立即回源,而是尝试获取一个分布式锁,由一个线程去重建缓存,其他线程继续使用旧数据。
{
  "data": { ... },
  "expire_time": 1700000000  // 逻辑过期时间戳
}
2. 互斥锁(Mutex Lock)重建缓存
  • 使用 Redis 的 SET key mutex EX 10 NX 实现分布式锁。
  • 只有抢到锁的线程去查 DB 并更新缓存,其他线程等待或重试。
def get_user(user_id):
    key = f"user:{user_id}"
    user = redis.get(key)
    if user:
        return user

    # 尝试获取锁
    lock_key = f"lock:{key}"
    if redis.set(lock_key, "1", ex=10, nx=True):
        try:
            user = mysql.query(user_id)
            if user:
                redis.setex(key, 3600, user)
            else:
                redis.setex(key, 60, "EMPTY")  # 防穿透
        finally:
            redis.delete(lock_key)
    else:
        # 等待一小段时间后重试(或返回旧数据/默认值)
        time.sleep(0.1)
        return get_user(user_id)
3. 永不过期 + 后台刷新
  • 热点 key 设置为永不过期(或超长 TTL)。
  • 启动后台任务定期刷新缓存(如每 30 分钟)。
  • 适用:数据变化不频繁的场景(如配置、排行榜)。

四、生产环境最佳实践清单

场景 推荐做法
缓存更新 采用“先更新 DB,再删缓存” + 延迟双删(高一致性要求)
缓存穿透 布隆过滤器 + 空值缓存(TTL 1~5 分钟)
缓存击穿 互斥锁重建 + 逻辑过期(二选一)
缓存雪崩 随机 TTL(如基础 TTL ± 10%),避免大量 key 同时失效
监控告警 监控 Redis 命中率、MySQL QPS 突增、空值缓存比例
降级策略 当 Redis 故障时,可临时关闭缓存,直连 DB(需限流)

结语

MySQL 与 Redis 的协同不是简单“加个缓存”就完事,而是一套涉及一致性模型、故障防御、并发控制的系统工程。没有银弹,只有权衡:

  • 强一致? → 牺牲性能,用分布式锁+双删。
  • 高可用? → 接受短暂不一致,用 Cache-Aside + TTL 随机化。
  • 抗攻击? → 布隆过滤器 + 空值缓存筑起第一道防线。

真正的高手,不是避免问题,而是在问题发生前就布好局。通过本文的策略组合,你可以在性能、一致性与稳定性之间找到属于你业务的最佳平衡点。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐