Skip to content
清晨的一缕阳光
返回

MySQL COUNT 优化实战

核心概念

COUNT 是最常用的聚合函数,但在大数据量场景下性能问题突出。

COUNT 类型对比

类型含义NULL 处理性能
COUNT(*)统计所有行包括 NULL最优
COUNT(1)统计所有行包括 NULL与 COUNT(*) 相同
COUNT(列)统计非 NULL 行排除 NULL稍慢
COUNT(DISTINCT 列)统计去重后的行数排除 NULL最慢

MySQL 官方说明

MySQL 官方文档明确说明:
COUNT(*) 是专门优化过的,不需要担心性能问题。

InnoDB 对 COUNT(*) 的优化:
- 不需要读取具体列值
- 直接使用可见性判断
- 选择最小的索引扫描

COUNT 底层原理

InnoDB COUNT 实现

InnoDB 不存储表的总行数(与 MyISAM 不同):
原因:MVCC + 事务隔离,不同事务看到的行数不同

COUNT(*) 执行过程:
1. 选择一个最小的索引(如果有二级索引)
2. 遍历索引树
3. 根据 MVCC 可见性判断是否计数
4. 返回总数

MyISAM vs InnoDB

-- MyISAM:存储总行数,COUNT(*) 极快
SELECT COUNT(*) FROM users;  -- 0.001s
-- 直接从元数据读取

-- InnoDB:需要扫描,COUNT(*) 较慢
SELECT COUNT(*) FROM users;  -- 10s
-- 需要遍历索引树

为什么 InnoDB 不存储总行数?

1. MVCC:不同事务看到的行数不同
2. 并发:存储总行数需要加锁,影响并发
3. 事务:未提交的事务影响行数统计

COUNT 优化方案

方案 1:使用覆盖索引

-- 创建覆盖索引
CREATE INDEX idx_create_time ON orders(create_time);

-- COUNT 使用覆盖索引
SELECT COUNT(*) FROM orders WHERE create_time > '2024-01-01';
-- Extra: Using index(只扫描索引树,不回表)

-- 对比:无索引
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 全表扫描

索引选择原则:

方案 2:近似计数

-- 精确 COUNT(慢)
SELECT COUNT(*) FROM orders;  -- 10s

-- 近似计数(快)
SELECT TABLE_ROWS FROM information_schema.TABLES 
WHERE TABLE_SCHEMA = 'database' AND TABLE_NAME = 'orders';
-- 0.01s

-- 误差:约 1-5%,可接受场景使用

注意: TABLE_ROWS 是近似值,不定期更新。

-- 更新统计信息
ANALYZE TABLE orders;

-- 查看更新后的统计
SELECT TABLE_ROWS FROM information_schema.TABLES 
WHERE TABLE_NAME = 'orders';

方案 3:缓存计数

-- 创建计数表
CREATE TABLE table_counts (
    table_name VARCHAR(50) PRIMARY KEY,
    row_count BIGINT,
    update_time DATETIME
);

-- 初始化
INSERT INTO table_counts VALUES ('orders', 1000000, NOW());

-- 触发器维护(增量更新)
DELIMITER $$
CREATE TRIGGER trg_order_after_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
    UPDATE table_counts 
    SET row_count = row_count + 1, update_time = NOW()
    WHERE table_name = 'orders';
END$$

CREATE TRIGGER trg_order_after_delete
AFTER DELETE ON orders
FOR EACH ROW
BEGIN
    UPDATE table_counts 
    SET row_count = row_count - 1, update_time = NOW()
    WHERE table_name = 'orders';
END$$
DELIMITER ;

-- 查询缓存的计数
SELECT row_count FROM table_counts WHERE table_name = 'orders';
-- 0.001s

适用场景:

方案 4:Redis 缓存

// Redis 缓存计数
@Service
public class OrderCountService {
    
    @Resource
    private RedisTemplate<String, Long> redisTemplate;
    
    @Resource
    private OrderMapper orderMapper;
    
    // 获取计数
    public Long getOrderCount() {
        String key = "count:orders";
        Long count = redisTemplate.opsForValue().get(key);
        
        if (count == null) {
            // 缓存未命中,查询数据库
            count = orderMapper.count();
            // 写入缓存,5 分钟过期
            redisTemplate.opsForValue().set(key, count, 5, TimeUnit.MINUTES);
        }
        
        return count;
    }
    
    // 增量更新
    @Transactional
    public void createOrder(Order order) {
        orderMapper.insert(order);
        // 异步更新 Redis
        redisTemplate.opsForValue().increment("count:orders");
    }
    
    // 定期校准(防止缓存漂移)
    @Scheduled(cron = "0 0 * * * ?")  // 每小时
    public void calibrate() {
        Long dbCount = orderMapper.count();
        redisTemplate.opsForValue().set("count:orders", dbCount);
    }
}

适用场景:

方案 5:分库分表计数

-- 分表场景:orders_0, orders_1, ..., orders_9

-- 方案 1:汇总所有分表
SELECT SUM(cnt) FROM (
    SELECT COUNT(*) AS cnt FROM orders_0
    UNION ALL
    SELECT COUNT(*) AS cnt FROM orders_1
    UNION ALL
    ...
    SELECT COUNT(*) AS cnt FROM orders_9
) tmp;

-- 方案 2:维护汇总表
CREATE TABLE order_count_summary (
    date DATE PRIMARY KEY,
    total_count BIGINT
);

-- 每天统计一次
INSERT INTO order_count_summary
SELECT DATE(create_time), COUNT(*) 
FROM orders 
GROUP BY DATE(create_time)
ON DUPLICATE KEY UPDATE total_count = VALUES(total_count);

-- 查询总数
SELECT SUM(total_count) FROM order_count_summary;

方案 6:使用 COUNT(1) vs COUNT(*)

-- MySQL 5.7+:COUNT(*) 和 COUNT(1) 性能相同
SELECT COUNT(*) FROM orders;  -- 优化器转为 COUNT(1)
SELECT COUNT(1) FROM orders;  -- 直接执行

-- 执行计划相同
EXPLAIN SELECT COUNT(*) FROM orders;
EXPLAIN SELECT COUNT(1) FROM orders;

-- 结论:使用 COUNT(*),语义更清晰

方案 7:优化 COUNT(DISTINCT)

-- COUNT(DISTINCT) 性能最差
SELECT COUNT(DISTINCT user_id) FROM orders;
-- 需要去重,使用临时表

-- 优化 1:使用近似去重(MySQL 8.0.12+)
-- 没有内置的近似去重函数,需要自定义

-- 优化 2:使用 HyperLogLog(Redis)
-- Redis: PFADD orders:user {user_id}
-- Redis: PFCOUNT orders:user

-- 优化 3:位图(适合整数 ID)
-- 使用位图记录已出现的 ID

方案 8:分区表 COUNT 优化

-- 创建分区表
CREATE TABLE orders (
    id BIGINT,
    create_time DATETIME,
    ...
) PARTITION BY RANGE (YEAR(create_time)) (
    PARTITION p0 VALUES LESS THAN (2020),
    PARTITION p1 VALUES LESS THAN (2021),
    PARTITION p2 VALUES LESS THAN (2022),
    PARTITION p3 VALUES LESS THAN (2023),
    PARTITION p4 VALUES LESS THAN MAXVALUE
);

-- 查询特定分区
SELECT COUNT(*) FROM orders PARTITION (p3) 
WHERE create_time >= '2023-01-01';
-- 只扫描 2023 年的分区

-- 查询所有分区
SELECT COUNT(*) FROM orders;
-- 扫描所有分区

不同场景 COUNT 优化

场景 1:带条件 COUNT

-- 优化前:全表扫描
SELECT COUNT(*) FROM orders WHERE status = 1;

-- 优化 1:添加索引
CREATE INDEX idx_status ON orders(status);
SELECT COUNT(*) FROM orders WHERE status = 1;
-- Extra: Using index

-- 优化 2:覆盖索引
CREATE INDEX idx_status_id ON orders(status, id);
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 只扫描索引,不回表

场景 2:范围 COUNT

-- 优化前
SELECT COUNT(*) FROM orders WHERE create_time > '2024-01-01';

-- 优化:索引 + 覆盖
CREATE INDEX idx_create_time ON orders(create_time);
SELECT COUNT(*) FROM orders WHERE create_time > '2024-01-01';
-- Using index

场景 3:多条件 COUNT

-- 优化前
SELECT COUNT(*) FROM orders WHERE status = 1 AND user_id = 100;

-- 优化:复合索引
CREATE INDEX idx_status_user ON orders(status, user_id);
SELECT COUNT(*) FROM orders WHERE status = 1 AND user_id = 100;
-- Using index

场景 4:GROUP BY COUNT

-- 优化前
SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id;
-- Using temporary

-- 优化:索引
CREATE INDEX idx_user_id ON orders(user_id);
SELECT user_id, COUNT(*) AS cnt FROM orders GROUP BY user_id;
-- 仍然需要临时表,但扫描更快

-- 优化 2:覆盖索引
CREATE INDEX idx_user_id ON orders(user_id);
-- 只扫描 user_id 索引列

场景 5:COUNT + LIMIT

-- 分页查询总数
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 慢查询

-- 优化:只查询是否有数据
SELECT 1 FROM orders WHERE status = 1 LIMIT 1;
-- 找到 1 条就返回,极快

-- 应用:判断是否有数据用 EXISTS
SELECT EXISTS(
    SELECT 1 FROM orders WHERE status = 1 LIMIT 1
) AS has_data;

场景 6:大数据量 COUNT

-- 1000 万数据,COUNT(*) 需要 10s+

-- 方案 1:近似计数
SELECT TABLE_ROWS FROM information_schema.TABLES 
WHERE TABLE_NAME = 'orders';
-- 0.01s

-- 方案 2:缓存
SELECT row_count FROM table_counts WHERE table_name = 'orders';
-- 0.001s

-- 方案 3:Redis
GET count:orders
-- < 1ms

-- 方案 4:业务妥协
-- 显示 "1000 万+" 而不是精确数字

COUNT 性能对比

不同 COUNT 方式性能对比

-- 测试数据:orders 表 1000 万行

-- 1. COUNT(*)
SELECT COUNT(*) FROM orders;
-- 耗时:10s

-- 2. COUNT(1)
SELECT COUNT(1) FROM orders;
-- 耗时:10s(与 COUNT(*) 相同)

-- 3. COUNT(id)
SELECT COUNT(id) FROM orders;
-- 耗时:12s(需要检查 NULL)

-- 4. COUNT(非 NULL 列)
SELECT COUNT(create_time) FROM orders;
-- 耗时:12s

-- 5. COUNT(DISTINCT user_id)
SELECT COUNT(DISTINCT user_id) FROM orders;
-- 耗时:30s(需要去重)

-- 6. 近似计数
SELECT TABLE_ROWS FROM information_schema.TABLES WHERE TABLE_NAME = 'orders';
-- 耗时:0.01s

-- 7. Redis 缓存
GET count:orders
-- 耗时:< 1ms

索引对 COUNT 性能影响

-- 无索引
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 耗时:15s(全表扫描)

-- 有索引
CREATE INDEX idx_status ON orders(status);
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 耗时:2s(索引扫描)

-- 覆盖索引
CREATE INDEX idx_status_id ON orders(status, id);
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 耗时:1s(只扫描索引)

最佳实践总结

COUNT 使用原则

  1. 优先使用 COUNT(*)

    • MySQL 专门优化
    • 语义清晰
    • 性能最优
  2. 避免 COUNT(DISTINCT)

    • 性能差
    • 考虑使用 Redis HyperLogLog
  3. 大数据量使用近似计数

    • TABLE_ROWS
    • 缓存计数
    • Redis 缓存
  4. 添加合适的索引

    • WHERE 条件字段建索引
    • 使用覆盖索引

场景选择指南

1. 精确计数(小数据量):
   → COUNT(*) + 索引

2. 精确计数(大数据量):
   → 缓存计数 + 定期校准

3. 近似计数:
   → TABLE_ROWS
   → Redis 缓存

4. 判断是否有数据:
   → EXISTS(SELECT 1 ... LIMIT 1)

5. 去重计数:
   → Redis HyperLogLog
   → 位图

6. 分表场景:
   → 汇总表
   → 各分表 COUNT 求和

监控与调优

-- 查看 COUNT 相关慢查询
SELECT query, exec_time 
FROM mysql.slow_log 
WHERE query LIKE '%COUNT%';

-- 分析 COUNT 执行计划
EXPLAIN SELECT COUNT(*) FROM orders WHERE status = 1;

-- 查看索引使用情况
SHOW INDEX FROM orders;

常见误区

误区 1:COUNT(1) 比 COUNT(*) 快
→ MySQL 5.7+ 性能相同

误区 2:COUNT(列) 比 COUNT(*) 快
→ 实际更慢(需要检查 NULL)

误区 3:InnoDB 的 COUNT(*) 很快
→ 实际很慢(需要扫描)

误区 4:COUNT 必须精确
→ 很多场景可以接受近似值

参考资料


分享这篇文章到:

上一篇文章
Nacos 服务注册发现
下一篇文章
Spring Boot MyBatis-Plus 集成实战