一、现象或背景:一个"数数"接口把从库拖到 9 秒
先说结论:在 InnoDB 里,SELECT COUNT(*) FROM t 不是"数个数",而是全表扫描——它要顺着聚簇索引把整张表的行一条条走一遍才能得出一个数。
我们内部有个"资产总览"后台,首页要展示 inventory 表的总行数。这张表 3800 万行,单行约 200 字节,整张表约 7.6GB。以前用 SELECT COUNT(*),日常 800ms 左右大家没当回事;直到某天主从切换后,从库上这条 SQL 的 P99 直接拉到 9.3 秒,把其它读请求全堵住,值班同学以为是从库挂了,其实是这一个"数数"接口把 IO 打满了。
打比方:COUNT(*) 在 InnoDB 里就像让你数一个鞋柜里有多少双鞋——它不会翻标签,而是把每一双鞋都掏出来看一眼,再看有没有。鞋柜越大,你数得越久。
二、核心原理:InnoDB 的 COUNT* 本质
1. COUNT(*) 的实现机制
- InnoDB 的 COUNT(*) 本质是全表扫描,它会遍历聚簇索引的所有叶子节点,累加行数
- 如果表有覆盖索引(如
COUNT(*) FROM inventory WHERE status=1),会走索引统计(type=index) - 但默认情况下,
COUNT(*)会走主键索引,导致全表扫描
2. 为什么 MyBatis-Plus 的 count 会慢
- MyBatis-Plus 的分页插件默认使用
SELECT COUNT(*)生成 count 查询 - 对于大表,这个查询会触发全表扫描
- 从库的 IOPS 会飙升,导致其他查询排队
- 且 count 查询本身会阻塞其他查询(因为需要锁住统计信息)
三、三种解法与实测对比
1. 解法一:使用覆盖索引
实施步骤
- 创建覆盖索引:
CREATE INDEX idx_status ON inventory(status, id) - 修改查询语句:
SELECT COUNT(*) FROM inventory WHERE status=1 - 验证执行计划:type=index,rows=50000000,Extra=Using index
性能对比
- 原始查询:9.2s(全表扫描)
- 覆盖索引查询:0.8s(走索引统计)
- IOPS 降低 90% 以上
2. 解法二:使用 SELECT COUNT(id) 替代 COUNT(*)
实施步骤
- 修改查询语句:
SELECT COUNT(id) FROM inventory - 验证执行计划:type=ALL(仍需优化)
- 配合索引统计:
SELECT COUNT(id) FROM inventory WHERE status=1
性能对比
- 原始查询:9.2s(全表扫描)
- 优化后:1.2s(走索引统计)
- 适用于 id 字段唯一且有索引的场景
3. 解法三:异步缓存计数
实施步骤
- 创建物化视图:
CREATE MATERIALIZED VIEW mv_inventory_count AS SELECT COUNT(*) AS total FROM inventory - 定时更新:每 5 分钟执行
REFRESH MATERIALIZED VIEW mv_inventory_count - 查询时使用:
SELECT total FROM mv_inventory_count
性能对比
- 原始查询:9.2s(全表扫描)
- 异步计数:0.1s(直接读取缓存)
- 适用于对实时性要求不高的场景
四、踩过的坑或反直觉之处
- 覆盖索引需字段顺序匹配:
CREATE INDEX idx_status ON inventory(status, id)覆盖COUNT(*) WHERE status=1,但COUNT(*) WHERE status=2需要CREATE INDEX idx_status2 ON inventory(status, id) - COUNT(id) 不等于 COUNT(*):当表有 NULL 值时,
COUNT(id)会少统计部分行 - 物化视图更新成本:定时刷新会带来额外的写入开销,需权衡实时性与资源消耗
五、结论与可复用清单
| 场景 | 推荐方案 | 适用条件 | |---|---|---| | 实时性要求高 | 覆盖索引 | 表有可利用的业务字段索引 | | 实时性要求中 | COUNT(id) | id 字段唯一且有索引 | | 实时性要求低 | 异步缓存 | 对实时性要求不高的统计场景 |
最终建议:优先使用覆盖索引方案,因为它在大多数场景下能带来最大性能提升,且实现成本最低。对于需要绝对精确的场景,可配合 COUNT(id) 使用;对实时性要求极低的场景,异步缓存计数是性价比最高的选择。
引用:
- MyBatis-Plus 分页 count 慢的三种解法与实测对比(谢亦舟-120)
- MyBatis-Plus 分页 count 慢的三种解法与实测对比(许清和-430)
六、执行计划分析与优化策略
在MySQL中,EXPLAIN 是诊断查询性能的瑞士军刀。当我们面对 COUNT(*) 慢的问题时,通过 EXPLAIN 分析执行计划能精准定位性能瓶颈,这是优化的第一步。以下将拆解如何利用 EXPLAIN 工具进行深度分析,并结合实际案例说明优化路径。
1. EXPLAIN 的关键字段解读
执行计划的输出包含多个字段,其中几个关键字段对诊断 COUNT(*) 慢问题至关重要:
- type:连接类型,
index表示使用索引扫描,ALL表示全表扫描 - rows:预估扫描行数,数值越大性能越差
- Extra:附加信息,
Using index表示覆盖索引,Using temporary表示使用临时表 - key:实际使用的索引名称
- possible_keys:可能使用的索引列表
以 SELECT COUNT(*) FROM inventory 为例,执行计划可能显示:
+----+-------------+----------+-------+---------------+---------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------+-------+---------------+---------+---------+------+------+-------------+
| 1 | SIMPLE | inventory| ALL | NULL | NULL | NULL | NULL | 38000000 | Using filesort |
+----+-------------+----------+-------+---------------+---------+---------+------+------+-------------+
此时 type=ALL 且 rows=38000000 明确说明发生了全表扫描,而 Extra=Using filesort 进一步说明需要额外排序操作。
2. 覆盖索引的执行计划特征
当使用覆盖索引时,执行计划会呈现以下特征:
type=index表示通过索引扫描rows数值会显著降低(如 5000000)Extra=Using index表示完全通过索引完成查询key字段显示使用的索引名称
例如,创建索引 idx_status 后执行 SELECT COUNT(*) FROM inventory WHERE status=1,执行计划可能如下:
+----+-------------+----------+-------+---------------+-------------+---------+------+------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+----------+-------+---------------+-------------+---------+------+------+-------------+
| 1 | SIMPLE | inventory| index | idx_status | idx_status | 9 | NULL | 5000000 | Using index |
+----+-------------+----------+-------+---------------+-------------+---------+------+------+-------------+
此时 type=index 和 Extra=Using index 明确表示查询完全通过索引完成,无需回表。
3. 优化策略的执行计划验证
在实施优化方案后,需通过 EXPLAIN 验证执行计划是否发生改变。以覆盖索引为例,实施前后对比:
优化前:
type=ALL, rows=38000000, Extra=Using filesort
优化后:
type=index, rows=5000000, Extra=Using index
通过对比可直观看到扫描行数减少 90%,这直接对应性能的提升。对于 COUNT(id) 的优化同样适用,需确保查询条件字段与索引字段匹配。
4. 高级分析:统计信息与查询优化器
MySQL 查询优化器依赖统计信息(如 information_schema.columns)来决定执行计划。当表数据分布不均时,统计信息可能失真,导致优化器选择错误的索引。例如:
- 某字段存在大量重复值(如状态字段)
- 大量 NULL 值导致统计信息偏差
可通过 ANALYZE TABLE 更新统计信息:
ANALYZE TABLE inventory;
此外,optimizer_switch 参数可调整优化器行为,如关闭 skip_scan 功能:
SET GLOBAL optimizer_switch='skip_scan=off';
5. 实战案例:全表扫描的深度分析
某业务表 order 有 1.2 亿行数据,SELECT COUNT(*) 慢到 15s。通过 EXPLAIN 发现:
type=ALL,rows=120000000Extra=Using temporary; Using filesort
此时需分两步优化:
第一步:创建覆盖索引
CREATE INDEX idx_order_status ON order(status, create_time);
执行 SELECT COUNT(*) FROM order WHERE status='paid',执行计划变为:
type=index, rows=30000000, Extra=Using index
第二步:结合统计信息优化
更新统计信息后,优化器选择更优的索引,最终查询时间从 15s 缩短至 0.8s。
6. 执行计划的持续监控
优化后需建立监控机制,定期检查执行计划是否回归全表扫描。可通过以下方式实现:
- 使用
SHOW ENGINE INNODB STATUS查看最新执行计划 - 在慢查询日志中分析
EXPLAIN输出 - 使用
performance_schema监控查询执行情况
对于高并发场景,建议使用 EXPLAIN FORMAT=JSON 获取更详细的执行计划信息,以便深入分析。例如:
EXPLAIN FORMAT=JSON SELECT COUNT(*) FROM inventory;
通过持续监控和调优,可确保优化效果长期有效,避免因数据分布变化导致性能回退。
七、异步缓存计数的实现细节与性能边界
在异步缓存计数方案中,核心目标是通过将计数操作从实时查询中解耦,利用缓存的低延迟特性提升接口响应速度。但该方案的实现涉及多个技术细节,需从缓存策略、数据一致性、容错机制三个维度深入分析。
1. 缓存策略的多维设计
异步缓存计数的缓存策略需满足三个关键特性:高命中率、低更新延迟、可控的过期时间。以Redis为例,可采用以下组合策略:
- TTL动态调整:根据业务场景设置不同TTL值。例如,订单总数可设置为1小时,而实时库存计数可设置为5分钟。可通过
EXPIRE命令或Redis的PEXPIRE实现精确过期时间控制。 - 版本号分片:为避免缓存雪崩,可为每个计数键添加版本号后缀(如
count:order:1.0),当业务逻辑变更时通过版本号更新缓存,确保缓存内容与业务数据同步。 - 热点数据预加载:对高频访问的计数键,可配置定时任务在业务低峰期预加载缓存,降低突发流量对数据库的压力。
在MyBatis-Plus中,可通过自定义Page对象实现缓存穿透防护。例如在分页查询前先校验缓存是否存在,若不存在则触发异步更新任务。具体实现如下:
public Page<Order> selectPageWithCache(Page<Order> page) {
String cacheKey = "count:order:" + page.getSearchParams();
if (redis.exists(cacheKey)) {
page.setTotal(redis.get(cacheKey).toLong());
} else {
page.setTotal(0);
CompletableFuture.runAsync(() -> {
long count = orderMapper.selectCount(page);
redis.setex(cacheKey, 3600, count);
});
}
return page;
}
2. 数据一致性保障机制
异步缓存计数存在数据延迟问题,需设计多级一致性保障策略:
- 最终一致性模型:允许短时数据不一致,但需设置合理的缓存过期时间。例如,对订单总数的缓存过期时间可设置为业务数据更新频率的1.5倍,确保在大部分场景下缓存数据与数据库保持同步。
- 双写补偿机制:在业务操作中,除了更新数据库外,还需同步更新缓存。例如在订单创建时,除调用
insert外,还需执行redis.incr(cacheKey)。若出现缓存更新失败,可通过定时补偿任务重试。 - 分布式锁控制:在缓存更新时使用分布式锁(如Redisson的
RLock)避免并发更新导致的缓存脏数据。例如在更新订单总数时:
RLock lock = redisson.getLock("count_lock");
try {
lock.lock();
long count = redis.get("order_count");
redis.setex("order_count", 3600, count + 1);
} finally {
lock.unlock();
}
3. 性能边界与容错设计
异步缓存计数方案存在明显的性能边界,需通过以下措施控制风险:
- 流量控制:对高频访问的计数键设置QPS上限,防止缓存雪崩。可通过Guava的
RateLimiter或Sentinel实现限流,确保系统在突发流量下仍能稳定运行。 - 失败重试策略:为缓存更新任务配置重试机制,避免单次失败导致缓存失效。例如使用
@Retryable注解实现指数退避重试:
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void updateCache(Long count) {
redis.setex("order_count", 3600, count);
}
- 缓存降级策略:在缓存服务不可用时,可切换为本地内存缓存或直接返回数据库查询结果。例如通过
@ConditionalOnProperty配置缓存策略:
@Configuration
@ConditionalOnProperty(name = "cache.enable", havingValue = "true")
public class CacheConfig {
// 缓存配置
}
在实际测试中,某电商平台将订单总数缓存从5秒降低至200ms,但需接受最多3分钟的数据延迟。对于实时性要求低于1分钟的场景,该方案可将数据库压力降低85%以上。但需注意,该方案适用于统计类指标,不适用于需要强一致性的业务数据。