摘要:从一次"开启查询缓存后性能反而下降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. 查询缓存了100条SQL:
SELECT * FROM user WHERE id = 1;
SELECT * FROM user WHERE id = 2;
...
SELECT * FROM user WHERE name = 'alice';
2. 执行1条UPDATE:
UPDATE 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 |
|---|---|---|---|---|
| 无缓存 | - | 1000 | 10ms | 60% |
| MySQL查询缓存 | 5% | 800 | 15ms | 80%(锁竞争) |
| Redis缓存 | 95% | 10000 | 1ms | 20% |
| Caffeine本地缓存 | 98% | 15000 | 0.5ms | 15% |
结论:
MySQL查询缓存:
- 命中率低(5%)
- 性能差(锁竞争)
- 反而拖累性能 ❌
Redis缓存:
- 命中率高(95%)
- 性能好(内存操作)
- QPS提升10倍 ✅
Caffeine本地缓存:
- 命中率最高(98%)
- 性能最好(本地内存)
- 但分布式场景不适用
🎯 为什么应用层缓存更好?
优势1:精确失效
MySQL查询缓存:
UPDATE user SET balance=900 WHERE id=999;
→ 整张user表的缓存失效(包括id=1、2、3...)
Redis缓存:
UPDATE user SET balance=900 WHERE id=999;
→ 只删除key="user:999"的缓存
→ 其他缓存(user:1、user: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缓存是替代
应用层缓存更灵活,精确失效命中高