引言:MySQL 8.0为何移除查询缓存
MySQL 8.0 做出了一个具有深远影响的决定:彻底移除自 MySQL 3.23 起就存在的查询缓存(Query Cache)。对于许多长期使用 MySQL 的开发者来说,这个改动令人困惑——一个看似有用的功能为何被砍掉?答案在于查询缓存的固有设计缺陷:它是全局互斥锁保护的哈希表,任何表的写操作都会导致该表所有缓存失效,在高并发写入场景下反而成为性能瓶颈。
在 MySQL 5.7 中,查询缓存默认已经关闭(
1 | query_cache_type=OFF |
),8.0 则直接删除了相关代码和参数。这意味着我们必须采用更现代、更合理的缓存策略来填补查询缓存移除后的性能空缺。本文将系统性地介绍三种互补的替代方案,帮助你构建高性能的 MySQL 应用。
方案一:深入理解与调优 InnoDB Buffer Pool
查询缓存移除后,InnoDB Buffer Pool 成为 MySQL 最核心的数据缓存机制。Buffer Pool 缓存的是数据页和索引页,而非查询结果,但它对重复查询的性能提升同样显著——尤其是当热点数据能完全装入内存时。
Buffer Pool 核心参数调优
Buffer Pool 的大小是最关键的配置。在专用数据库服务器上,建议设置为物理内存的 50%-75%:
1
2
3
4
5
6
7 [mysqld]
innodb_buffer_pool_size = 12G
innodb_buffer_pool_instances = 12
innodb_read_ahead_threshold = 56
innodb_max_dirty_pages_pct = 75.0
innodb_max_dirty_pages_pct_lwm = 10.0
innodb_flush_neighbors = 0
Buffer Pool 预热与转储
MySQL 重启后 Buffer Pool 为空,会导致一段时间的性能抖动。8.0 提供了 Buffer Pool 转储和预热功能:
1
2
3
4 [mysqld]
innodb_buffer_pool_dump_at_shutdown = ON
innodb_buffer_pool_load_at_startup = ON
innodb_buffer_pool_dump_pct = 25
手动触发预热:
1
2
3
4 SET GLOBAL innodb_buffer_pool_dump_now = ON;
SET GLOBAL innodb_buffer_pool_load_now = ON;
SHOW STATUS LIKE 'Innodb_buffer_pool_dump_status';
SHOW STATUS LIKE 'Innodb_buffer_pool_load_status';
监控 Buffer Pool 命中率
Buffer Pool 的命中率直接反映了缓存效果。计算公式:
1
2
3
4
5
6
7
8
9
10
11
12
13 SELECT
ROUND(
100 - (
SELECT variable_value
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_reads'
) * 100.0 / (
SELECT variable_value
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_read_requests'
),
2
) AS buffer_pool_hit_rate_pct;
命中率低于 95% 说明 Buffer Pool 不足或存在全表扫描问题。通过 sys 库可以进一步分析哪些表占用最多 Buffer Pool:
1
2
3
4
5
6
7 SELECT
table_name,
pages,
bytes / 1024 / 1024 AS size_mb
FROM sys.innodb_buffer_stats_by_table
ORDER BY pages DESC
LIMIT 20;
改进 LRU 算法:减少全表扫描污染
InnoDB 的 Buffer Pool 使用改进的 LRU 算法,将 LRU 链表分为 young 区域(热数据,前 5/8)和 old 区域(冷数据,后 3/8)。新读取的页先进入 old 区域头部,只有在 old 区域存活超过
1 | innodb_old_blocks_time |
(默认1秒)后再次被访问才移入 young 区域。这有效防止了一次性全表扫描将热数据逐出:
1
2
3 [mysqld]
innodb_old_blocks_time = 1000
innodb_old_blocks_pct = 37
如果你的业务中存在大量后台报表查询扫描大表,适当增大
1 | innodb_old_blocks_time |
可以进一步保护热数据。
方案二:ProxySQL 查询缓存实战
ProxySQL 是高性能的 MySQL 代理中间件,它内置了查询缓存功能,可以缓存 SELECT 查询的结果集。与 MySQL 原生查询缓存不同,ProxySQL 的缓存是每个查询规则独立配置的,不会因为某张表被写入就失效所有相关缓存。
安装与基础配置
1
2
3
4
5 wget https://github.com/sysown/proxysql/releases/download/v2.7.1/proxysql_2.7.1-ubuntu22_amd64.deb
dpkg -i proxysql_2.7.1-ubuntu22_amd64.deb
systemctl start proxysql
systemctl enable proxysql
mysql -u admin -padmin -h 127.0.0.1 -P 6032
配置后端 MySQL 服务器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16 INSERT INTO mysql_servers (hostgroup_id, hostname, port, weight, max_connections)
VALUES
(10, '192.168.1.100', 3306, 1, 200),
(10, '192.168.1.101', 3306, 1, 200),
(20, '192.168.1.100', 3306, 1, 100);
INSERT INTO mysql_users (username, password, default_hostgroup, active)
VALUES ('app_user', 'app_password', 10, 1);
UPDATE global_variables SET variable_value = 'proxysql_monitor'
WHERE variable_name = 'mysql-monitor_username';
UPDATE global_variables SET variable_value = 'monitor_pwd'
WHERE variable_name = 'mysql-monitor_password';
LOAD MYSQL SERVERS TO RUNTIME;
SAVE MYSQL SERVERS TO DISK;
配置查询缓存规则
ProxySQL 的查询缓存通过 mysql_query_rules 表实现,支持按查询模式精确匹配或正则匹配:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 INSERT INTO mysql_query_rules
(rule_id, active, match_digest, destination_hostgroup, cache_ttl, apply)
VALUES
(100, 1, '^SELECT .* FROM product WHERE id = ?', 20, 60000, 1),
(200, 1, '^SELECT .* FROM category WHERE', 20, 120000, 1),
(300, 1, '^SELECT COUNT(*) FROM order', 20, 30000, 1);
INSERT INTO mysql_query_rules
(rule_id, active, match_digest, destination_hostgroup, apply)
VALUES
(999, 1, '^SELECT', 20, 1);
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;
查询缓存参数调优
1
2
3
4
5
6
7
8 UPDATE global_variables SET variable_value = '268435456'
WHERE variable_name = 'mysql-query_cache_size';
UPDATE global_variables SET variable_value = '1048576'
WHERE variable_name = 'mysql-threshold_resultset_size';
LOAD MYSQL VARIABLES TO RUNTIME;
SAVE MYSQL VARIABLES TO DISK;
监控缓存命中率
1
2
3
4
5
6
7
8
9
10
11
12 SELECT
hostgroup HG,
queries,
cache_hits,
ROUND(cache_hits * 100.0 / NULLIF(queries, 0), 2) AS cache_hit_pct
FROM stats_mysql_query_digest
WHERE queries > 0
ORDER BY queries DESC
LIMIT 20;
SELECT * FROM stats_mysql_global
WHERE variable_name LIKE '%cache%';
ProxySQL 查询缓存的优势在于:缓存失效是基于 TTL 的,不受表写操作影响;可以按查询规则精细控制哪些查询缓存、哪些不缓存;缓存命中在 ProxySQL 层直接返回,不经过 MySQL,延迟极低。
方案三:Redis 应用层缓存架构
对于更复杂的缓存需求——包括跨查询组合结果、需要灵活失效策略、或需要缓存计算结果——应用层 Redis 缓存是最灵活的方案。
缓存架构设计原则
经典缓存策略是 Cache-Aside(旁路缓存):应用先查 Redis,命中则直接返回;未命中则查 MySQL,将结果写入 Redis 并返回。关键问题在于缓存一致性和缓存穿透的防护。
| 策略 | 适用场景 | 一致性保证 | 复杂度 |
|---|---|---|---|
| Cache-Aside | 读多写少 | 最终一致 | 低 |
| Write-Through | 写操作需要强一致 | 强一致 | 中 |
| Write-Behind | 写密集,可容忍延迟 | 最终一致 | 高 |
| Refresh-Ahead | 热点数据,防止过期 | 最终一致 | 高 |
Python 实现示例
以下是一个生产级的 Cache-Aside 实现,包含缓存穿透防护和击穿保护:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80 import redis
import pymysql
import json
import hashlib
import threading
class MySQLRedisCache:
def __init__(self, redis_client, mysql_config,
default_ttl=300, null_ttl=60):
self.redis = redis_client
self.mysql_config = mysql_config
self.default_ttl = default_ttl
self.null_ttl = null_ttl
self._lock = threading.Lock()
def _cache_key(self, sql, params=None):
raw = sql + (json.dumps(params, sort_keys=True) if params else '')
return f'mysql:cache:{hashlib.md5(raw.encode()).hexdigest()}'
def _table_key(self, table):
return f'mysql:version:{table}'
def query(self, sql, params=None, ttl=None, tables=None):
ttl = ttl or self.default_ttl
cache_key = self._cache_key(sql, params)
cached = self.redis.get(cache_key)
if cached is not None:
if cached == b'__NULL__':
return None
return json.loads(cached)
with self._lock:
cached = self.redis.get(cache_key)
if cached is not None:
if cached == b'__NULL__':
return None
return json.loads(cached)
conn = pymysql.connect(**self.mysql_config)
try:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
cursor.execute(sql, params)
result = cursor.fetchall()
finally:
conn.close()
if result is None or len(result) == 0:
self.redis.setex(cache_key, self.null_ttl, '__NULL__')
else:
self.redis.setex(cache_key, ttl, json.dumps(result, default=str))
return result
def invalidate_table(self, table):
version_key = self._table_key(table)
self.redis.incr(version_key)
cache = MySQLRedisCache(
redis_client=redis.Redis(host='127.0.0.1', port=6379, db=0),
mysql_config={
'host': '127.0.0.1',
'port': 3306,
'user': 'app_user',
'password': 'app_pass',
'database': 'myapp'
},
default_ttl=300,
null_ttl=60
)
products = cache.query(
'SELECT id, name, price FROM product WHERE category_id = %s',
params=(10,),
ttl=600,
tables=['product']
)
cache.invalidate_table('product')
缓存一致性进阶:表版本号方案
简单的全局清除缓存过于粗暴。表版本号方案可以在缓存键中嵌入表版本号,写操作时只需递增版本号,相关缓存自然失效:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 def _cache_key_with_version(self, sql, params=None, tables=None):
raw = sql + (json.dumps(params, sort_keys=True) if params else '')
version_parts = []
for table in (tables or []):
version = self.redis.get(self._table_key(table))
version_parts.append(f'{table}:{version or "0"}')
version_str = '|'.join(version_parts)
full_key = f'{raw}:{version_str}'
return f'mysql:cache:{hashlib.md5(full_key.encode()).hexdigest()}'
def invalidate_table(self, table):
version_key = self._table_key(table)
new_version = self.redis.incr(version_key)
return new_version
这种方案的优势:不需要扫描和删除缓存键,只需递增版本号;旧版本缓存键自然过期;写入路径极轻量,适合高并发场景。
三种方案对比与选型建议
三种方案各有适用场景,通常需要组合使用:
| 维度 | Buffer Pool | ProxySQL缓存 | Redis应用缓存 |
|---|---|---|---|
| 缓存粒度 | 数据页/索引页 | 查询结果集 | 任意数据结构 |
| 失效机制 | LRU淘汰 + 写时失效 | TTL过期 | TTL + 主动失效 |
| 配置复杂度 | 低(MySQL参数) | 中(代理规则) | 高(应用代码) |
| 适用数据量 | 内存容量 | ProxySQL内存 | Redis内存 |
| 一致性 | 强(始终读最新数据) | 弱(TTL窗口内可能过期) | 可调(主动失效=强,TTL=弱) |
| 查询加速 | 减少磁盘IO | 绕过MySQL | 绕过MySQL |
| 额外依赖 | 无 | ProxySQL | Redis |
推荐组合策略:
- 所有场景必备:Buffer Pool 调优是基础,确保热数据常驻内存
- 读多写少的Web应用:Buffer Pool + ProxySQL 缓存,对应用透明
- 复杂查询和聚合:Buffer Pool + Redis,缓存计算结果
- 高并发电商/社交:三者组合,Buffer Pool 保底、ProxySQL 加速简单查询、Redis 缓存热点数据
实战:组合方案性能基准测试
下面是一个使用 sysbench 进行基准测试的方案,验证不同缓存组合的效果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26 sysbench oltp_read_only \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-port=3306 \
--mysql-user=sbtest \
--mysql-password=sbtest \
--mysql-db=sbtest \
--tables=10 \
--table-size=100000 \
prepare
sysbench oltp_read_only \
--threads=32 --time=300 \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-port=3306 \
run
sysbench oltp_read_only \
--threads=32 --time=300 \
--db-driver=mysql \
--mysql-host=127.0.0.1 \
--mysql-port=6033 \
--mysql-user=app_user \
--mysql-password=app_password \
run
典型测试结果对比(32线程,300秒):
| 配置 | QPS | 平均延迟(ms) | P99延迟(ms) |
|---|---|---|---|
| 仅Buffer Pool | 12,500 | 2.56 | 8.3 |
| Buffer Pool + ProxySQL缓存 | 45,000 | 0.71 | 1.2 |
| Buffer Pool + Redis | 52,000 | 0.62 | 1.0 |
| 三者组合 | 58,000 | 0.55 | 0.9 |
注意:以上数据为读密集场景的典型表现,实际结果取决于数据量、查询模式和硬件配置。混合读写场景中 ProxySQL 缓存的效果会因写比例上升而下降。
常见问题与排错指南
Buffer Pool 命中率突然下降
常见原因及排查:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 SELECT * FROM sys.schema_tables_with_full_table_scans
ORDER BY rows_full_scanned DESC
LIMIT 10;
SELECT
SUBSTRING_INDEX(INDEX_NAME, '/', 1) AS table_name,
pages,
records,
bytes / 1024 / 1024 AS size_mb
FROM sys.innodb_buffer_stats_by_index
ORDER BY pages DESC
LIMIT 20;
SELECT * FROM sys.io_global_by_wait_by_bytes
ORDER BY total_bytes DESC LIMIT 10;
ProxySQL 缓存命中率低
常见原因:TTL 设置过短;查询规则匹配不精确;结果集超过阈值。排查方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 SELECT
digest_text,
count_star,
sum_rows_affected,
first_seen,
last_seen
FROM stats_mysql_query_digest
WHERE digest_text LIKE 'SELECT%'
ORDER BY count_star DESC
LIMIT 20;
SELECT variable_name, variable_value
FROM stats_mysql_global
WHERE variable_name IN (
'Query_cache_cache_hits',
'Query_cache_cache_misses',
'Query_cache_entries',
'Query_cache_used_memory'
);
Redis 缓存与数据库不一致
这是最常见的问题。推荐的解决策略:
- 延迟双删:先删缓存、再更新数据库、延迟N毫秒后再删一次缓存
- 基于Binlog的异步失效:使用 Canal 或 Debezium 监听 Binlog,数据变更时自动删除对应缓存
- 短TTL兜底:即使主动失效失败,TTL也能保证数据最终一致
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 import time
import threading
import pymysql
def update_with_cache(cache, sql, params, tables):
for table in tables:
cache.invalidate_table(table)
conn = pymysql.connect(**cache.mysql_config)
try:
with conn.cursor() as cursor:
cursor.execute(sql, params)
conn.commit()
finally:
conn.close()
def delayed_invalidate():
time.sleep(0.5)
for table in tables:
cache.invalidate_table(table)
threading.Thread(target=delayed_invalidate, daemon=True).start()
总结
MySQL 8.0 移除查询缓存不是一个损失,而是一个推动我们采用更优架构的契机。原生查询缓存的全局互斥锁和粗粒度失效机制,在并发写入场景下反而拖累性能。取而代之的三种方案各有侧重:
- InnoDB Buffer Pool是最基础也是必选项,合理的参数调优可以覆盖绝大多数性能需求
- ProxySQL 查询缓存以极低的运维成本提供透明的查询结果缓存,适合读多写少的场景
- Redis 应用层缓存提供最大的灵活性和最强的缓存控制力,适合复杂业务逻辑和高性能需求
三者并非互斥,而是互补关系。从 Buffer Pool 调优开始,根据业务需求逐步引入 ProxySQL 和 Redis,可以构建出远超原生查询缓存的性能体系。记住:没有银弹,只有最适合场景的方案。

汤不热吧