MySQL为什么要去掉查询缓存?

摘要:从一次"开启查询缓存后性能反而下降30%"的反常现象出发,深度剖析MySQL查询缓存的致命缺陷。通过缓存失效机制、全局锁竞争、以及缓存命中率的真实数据,揭秘为什么查询缓存在MySQL 8.0被彻底移除、为什么写多读少的场景缓存完全无效、以及Redis缓存为什么比MySQL查询缓存强100倍。配合时序图展示缓存失效的连锁反应,给出应用层缓存的最佳实践。


💥 翻车现场

周一下午,哈吉米看到一篇文章:"开启MySQL查询缓存,性能提升10倍!"

哈吉米:"查询缓存?听起来很厉害!"

查看MySQL配置:

-- 查看查询缓存是否开启(MySQL 5.7)
SHOW VARIABLES LIKE 'query_cache%';

+------------------------------+----------+
| Variable_name                | Value    |
+------------------------------+----------+
| query_cache_type             | OFF      |  ← 默认关闭
| query_cache_size             | 0        |
+------------------------------+----------+

哈吉米:"默认关闭?我开启试试!"

修改配置:

# my.cnf
[mysqld]
query_cache_type = 1      # 开启查询缓存
query_cache_size = 256M   # 缓存大小256MB

重启MySQL,压测……

压测结果(开启查询缓存前):
QPS: 5000
平均响应时间: 10ms

压测结果(开启查询缓存后):
QPS: 3500  ← 下降了30%
平均响应时间: 15ms  ← 变慢了

哈吉米:"卧槽,开启查询缓存后性能反而下降了?"

查看缓存命中率:

SHOW STATUS LIKE 'Qcache%';

+-------------------------+----------+
| Variable_name           | Value    |
+-------------------------+----------+
| Qcache_hits             | 1523     |  ← 缓存命中1523| Qcache_inserts          | 98234    |  ← 缓存插入98234| Qcache_not_cached       | 523      |
+-------------------------+----------+

-- 缓存命中率 = 1523 / (1523 + 98234) = 1.5%

哈吉米:"命中率才1.5%?而且还变慢了?"

南北绿豆和阿西噶阿西来了。

南北绿豆:"MySQL查询缓存有3个致命缺陷,所以MySQL 8.0直接把它删了!"
哈吉米:"删了?为什么?"
阿西噶阿西:"来,我给你讲讲查询缓存的坑。"


🕳️ 致命缺陷1:只要有UPDATE,整张表的缓存全部失效

缓存失效的机制

阿西噶阿西在白板上画了一个场景。

缓存的key:SQL语句(完整的SQL,包括空格)

示例:
SELECT * FROM user WHERE id = 1;  ← key1
SELECT * FROM user WHERE id=1;    ← key2(空格不同,不同的key)

缓存的value:查询结果

失效规则

只要表有任何UPDATE/INSERT/DELETE操作:
→ 这张表的所有缓存全部失效 ❌

示例:
1. 查询缓存了100SQLSELECT * FROM user WHERE id = 1;
   SELECT * FROM user WHERE id = 2;
   ...
   SELECT * FROM user WHERE name = 'alice';

2. 执行1UPDATEUPDATE user SET balance = 900 WHERE id = 999;

3. 结果:
   所有100条缓存全部失效(包括id=1、id=2的缓存)

失效的连锁反应

sequenceDiagram
    participant App1 as 应用1(查询)
    participant Cache as 查询缓存
    participant App2 as 应用2(更新)
    participant MySQL

    App1->>MySQL: 1. SELECT * FROM user WHERE id=1
    MySQL->>MySQL: 2. 执行查询
    MySQL->>Cache: 3. 缓存结果(key=SQL, value=data)
    MySQL->>App1: 4. 返回结果
    
    Note over Cache: 缓存中有1000条查询结果
    
    App2->>MySQL: 5. UPDATE user SET balance=900 WHERE id=999
    MySQL->>MySQL: 6. 执行更新
    
    rect rgb(255, 182, 193)
        MySQL->>Cache: 7. 清空user表的所有缓存
        Note over Cache: 1000条缓存全部失效 ❌
    end
    
    App1->>MySQL: 8. SELECT * FROM user WHERE id=1(同样的查询)
    App1->>Cache: 9. 查询缓存
    Cache->>App1: 10. 缓存不存在(已失效)
    MySQL->>MySQL: 11. 重新执行查询
    MySQL->>Cache: 12. 重新缓存
    MySQL->>App1: 13. 返回结果

南北绿豆:"看到了吗?只要有1个UPDATE,整张表的缓存全清空,太粗暴了!"


写多读少的场景

场景:订单表

每秒操作:
- 查询:100次
- 插入:1000次(订单不断创建)

缓存情况:
T0: 查询100次,缓存100条
T1: 插入1条订单 → 缓存全部失效
T2: 查询100次,缓存100条
T3: 插入1条订单 → 缓存全部失效
...

结果:
- 缓存命中率:接近0%
- 缓存完全无效
- 还增加了缓存失效的开销 ❌

阿西噶阿西:"所以写多读少的表,查询缓存完全没用,反而拖累性能!"


🕳️ 致命缺陷2:查询缓存的全局锁

全局锁竞争

问题

// MySQL内核代码(简化)
struct Query_cache {
    pthread_mutex_t lock;  // 全局锁
    Hash_map cache;        // 缓存数据
};

// 查询时
void query_cache_get(SQL) {
    pthread_mutex_lock(&lock);  // 加锁
    
    result = cache.get(SQL);
    
    pthread_mutex_unlock(&lock);  // 解锁
    
    return result;
}

// 插入缓存时
void query_cache_insert(SQL, result) {
    pthread_mutex_lock(&lock);  // 加锁
    
    cache.put(SQL, result);
    
    pthread_mutex_unlock(&lock);  // 解锁
}

// 失效时
void query_cache_invalidate(table) {
    pthread_mutex_lock(&lock);  // 加锁
    
    cache.remove_all(table);  // 清空表的所有缓存
    
    pthread_mutex_unlock(&lock);  // 解锁
}

问题

高并发场景(1000个查询并发):

线程1:查询 → 获取锁 → 查缓存 → 释放锁
线程2:等待锁...
线程3:等待锁...
...
线程1000:等待锁...

结果:
- 1000个线程竞争同一把锁
- 大量锁等待
- 性能下降 ❌

锁竞争时序图

sequenceDiagram
    participant T1 as 线程1
    participant Lock as 全局锁
    participant Cache as 缓存
    participant T2 as 线程2
    participant T3 as 线程3

    T1->>Lock: 1. 请求锁
    Lock->>T1: 2. 获取锁 ✅
    T1->>Cache: 3. 查询缓存
    Cache->>T1: 4. 返回数据
    
    par 其他线程等待
        T2->>Lock: 5. 请求锁
        Note over T2: 等待线程1释放锁...
        
        T3->>Lock: 6. 请求锁
        Note over T3: 等待线程1释放锁...
    end
    
    T1->>Lock: 7. 释放锁
    Lock->>T2: 8. 获取锁
    
    Note over T2,T3: 串行执行,性能差

南北绿豆:"全局锁导致查询缓存成为性能瓶颈,并发越高,锁竞争越激烈!"


🕳️ 致命缺陷3:缓存key太严格

SQL必须完全一样

-- 查询1(缓存)
SELECT * FROM user WHERE id = 1;

-- 查询2(不命中缓存,空格不同)
SELECT * FROM user WHERE id=1;

-- 查询3(不命中缓存,大小写不同)
select * from user where id = 1;

-- 查询4(不命中缓存,参数不同)
SELECT * FROM user WHERE id = 2;

问题

ORM框架生成的SQL:
MyBatis:SELECT * FROM user WHERE id = #{id}
→ 生成:SELECT * FROM user WHERE id = 1
        SELECT * FROM user WHERE id = 2
        ...

Hibernate:SELECT * FROM user WHERE id = ?
→ 每次参数不同,SQL不同

结果:
- 每个参数都是不同的缓存key
- 缓存无法复用
- 命中率极低 ❌

阿西噶阿西:"所以查询缓存对ORM框架几乎无效!"


🎯 MySQL 8.0:彻底移除查询缓存

官方的解释

MySQL 8.0 Release Notes:

移除查询缓存的原因:
1. 缓存失效太频繁(任何写操作都清空)
2. 全局锁竞争严重(高并发性能差)
3. 缓存命中率低(SQL必须完全一样)
4. 维护成本高(代码复杂)

替代方案:
- 应用层缓存(Redis、Memcached)
- 结果集缓存(MyBatis二级缓存)
- 数据库层面优化(索引、SQL优化)

MySQL 8.0的查询

-- MySQL 8.0查询缓存相关变量
SHOW VARIABLES LIKE 'query_cache%';

Empty set  ← 完全没有这些变量了

-- MySQL 5.7
+------------------------------+----------+
| query_cache_type             | OFF      |
| query_cache_size             | 0        |
+------------------------------+----------+

-- MySQL 8.0
查询缓存相关的代码和变量全部删除

🎯 用什么替代查询缓存?

方案1:Redis缓存(推荐⭐⭐⭐⭐⭐)

@Service
public class UserService {
    
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    
    @Autowired
    private UserMapper userMapper;
    
    public User getUserById(Long userId) {
        String cacheKey = "user:" + userId;
        
        // 1. 查Redis缓存
        User user = redisTemplate.opsForValue().get(cacheKey);
        
        if (user != null) {
            return user;  // 缓存命中 ✅
        }
        
        // 2. 缓存未命中,查数据库
        user = userMapper.selectById(userId);
        
        // 3. 写入缓存(10分钟过期)
        redisTemplate.opsForValue().set(cacheKey, user, 10, TimeUnit.MINUTES);
        
        return user;
    }
    
    @Transactional
    public void updateUser(User user) {
        // 1. 更新数据库
        userMapper.updateById(user);
        
        // 2. 删除缓存(精确失效,不是全表失效)
        String cacheKey = "user:" + user.getId();
        redisTemplate.delete(cacheKey);  // 只删除这一条
    }
}

Redis vs MySQL查询缓存

特性MySQL查询缓存Redis缓存
失效粒度表级(整张表失效)行级(只失效一条)
锁竞争全局锁无锁(或分片锁)
命中率低(SQL必须完全一样)高(按业务key缓存)
性能差(锁竞争)好(内存操作)
灵活性差(自动管理)好(手动控制)

哈吉米:"所以Redis缓存比MySQL查询缓存好太多了!"


方案2:MyBatis二级缓存

// Mapper.xml
<mapper namespace="com.example.mapper.UserMapper">
    
    <!-- 开启二级缓存 -->
    <cache eviction="LRU" size="1024" flushInterval="60000"/>
    
    <select id="selectById" resultType="User">
        SELECT * FROM user WHERE id = #{id}
    </select>
</mapper>

// 使用
User user1 = userMapper.selectById(1L);  // 查数据库,写入缓存
User user2 = userMapper.selectById(1L);  // 命中缓存 ✅

// 更新后
userMapper.updateById(user);  // 二级缓存失效
User user3 = userMapper.selectById(1L);  // 重新查数据库

优点

  • ✅ 简单(配置即可)
  • ✅ 不需要额外组件

缺点

  • ❌ 分布式场景不适用(本地缓存)
  • ❌ 失效机制简单(整个Mapper失效)

方案3:Caffeine本地缓存

@Configuration
public class CacheConfig {
    
    @Bean
    public Cache<Long, User> userCache() {
        return Caffeine.newBuilder()
            .maximumSize(10000)  // 最多1万条
            .expireAfterWrite(10, TimeUnit.MINUTES)  // 10分钟过期
            .build();
    }
}

@Service
public class UserService {
    
    @Autowired
    private Cache<Long, User> userCache;
    
    public User getUserById(Long userId) {
        // 从本地缓存获取
        return userCache.get(userId, id -> {
            // 缓存不存在,查数据库
            return userMapper.selectById(id);
        });
    }
    
    public void updateUser(User user) {
        userMapper.updateById(user);
        // 删除缓存
        userCache.invalidate(user.getId());
    }
}

优点

  • ✅ 性能极好(本地内存)
  • ✅ 不依赖外部组件

缺点

  • ❌ 分布式场景不一致(每个应用有自己的缓存)

🎯 查询缓存 vs 应用缓存对比

性能对比

测试场景:1000 QPS,查询user表

方案命中率QPS响应时间CPU
无缓存-100010ms60%
MySQL查询缓存5%80015ms80%(锁竞争)
Redis缓存95%100001ms20%
Caffeine本地缓存98%150000.5ms15%

结论

MySQL查询缓存:
- 命中率低(5%)
- 性能差(锁竞争)
- 反而拖累性能 ❌

Redis缓存:
- 命中率高(95%)
- 性能好(内存操作)
- QPS提升10倍 ✅

Caffeine本地缓存:
- 命中率最高(98%)
- 性能最好(本地内存)
- 但分布式场景不适用

🎯 为什么应用层缓存更好?

优势1:精确失效

MySQL查询缓存:
UPDATE user SET balance=900 WHERE id=999;
→ 整张user表的缓存失效(包括id=123...)

Redis缓存:
UPDATE user SET balance=900 WHERE id=999;
→ 只删除key="user:999"的缓存
→ 其他缓存(user:1user:2)仍然有效 ✅

优势2:无锁或分片锁

MySQL查询缓存:
全局锁 → 所有查询串行竞争

Redis:
分片(16384个slot)→ 不同key在不同分片
→ 并发查询不冲突

优势3:灵活控制

// Redis可以灵活控制缓存策略

// 缓存1小时
redisTemplate.opsForValue().set(key, value, 1, TimeUnit.HOURS);

// 缓存10分钟
redisTemplate.opsForValue().set(key, value, 10, TimeUnit.MINUTES);

// 永不过期(手动失效)
redisTemplate.opsForValue().set(key, value);

// 条件失效
if (user.isVip()) {
    redisTemplate.delete("user:" + user.getId());
}

🎓 面试标准答案

题目:MySQL为什么要去掉查询缓存?

答案

MySQL 8.0彻底移除查询缓存

3个致命缺陷

1. 失效太频繁

  • 表有任何UPDATE/INSERT/DELETE → 整张表缓存失效
  • 写多读少的表,缓存几乎无效
  • 缓存失效的开销反而拖累性能

2. 全局锁竞争

  • 查询缓存用全局锁保护
  • 高并发时,锁竞争严重
  • 性能不升反降

3. 命中率低

  • SQL必须完全一样(包括空格、大小写)
  • ORM框架生成的SQL参数不同,无法命中
  • 实际命中率通常< 10%

替代方案

  • Redis缓存(推荐)
  • Caffeine本地缓存(单机)
  • MyBatis二级缓存

为什么应用层缓存更好

  • 精确失效(行级,不是表级)
  • 无全局锁
  • 灵活控制(过期时间、失效策略)
  • 命中率高(按业务key缓存)

题目:什么场景下查询缓存有用?

答案

几乎没有有用的场景

理论上可能有用

  • 读多写少(查询1000次,更新1次)
  • SQL完全固定(不用ORM,手写SQL)
  • 低并发(没有锁竞争)

实际情况

  • 这种场景极少
  • 即使满足条件,Redis缓存也更好
  • 所以MySQL 8.0直接删除了

建议

  • MySQL 5.7及以下:关闭查询缓存(默认关闭)
  • MySQL 8.0:已移除,无法使用
  • 所有场景:用应用层缓存(Redis)

🎉 结束语

晚上7点,哈吉米把查询缓存关闭了,改用Redis。

哈吉米:"关闭查询缓存后,性能从3500 QPS恢复到5000 QPS!"

南北绿豆:"对,查询缓存在现代应用中完全是鸡肋,甚至拖累性能。"

阿西噶阿西:"记住:MySQL 8.0移除查询缓存是正确的决定,用Redis替代就对了。"

哈吉米:"还有应用层缓存可以精确控制失效,比表级失效强太多了。"

南北绿豆:"对,理解了查询缓存的缺陷,就知道为什么要用应用层缓存了!"


记忆口诀

MySQL查询缓存三缺陷,表级失效太粗暴
全局锁竞争性能差,SQL必须完全一样
写多读少缓存无效,高并发锁等待
MySQL8.0已移除,Redis缓存是替代
应用层缓存更灵活,精确失效命中高