MySQL的COUNT大揭秘 🔢

65 阅读11分钟

一、开篇故事:三种数人头的方法 👥

想象体育场统计观众人数:

方法1:count(*)(数所有位置)

工作人员:
  "我数一下有多少个座位被占用!"
  
做法:
  → 遍历所有座位
  → 只要有人坐,计数+1
  → 不管坐的是谁
  
特点:
  ✅ 准确
  ✅ 高效(MySQL优化过)

方法2:count(1)(数所有位置,标记为1)

工作人员:
  "我给每个有人的座位标记为1,然后数1的个数!"
  
做法:
  → 遍历所有座位
  → 有人就标记为1
  → 数1的个数
  
特点:
  ✅ 准确
  ✅ 和count(*)几乎一样

方法3:count(name)(数特定属性)

工作人员:
  "我数一下有多少人带了身份证!"
  
做法:
  → 遍历所有座位
  → 检查身份证(name列)
  → 如果有身份证(非NULL),计数+1
  → 如果没身份证(NULL),不计数
  
特点:
  ⚠️ 可能不准确(有人没带身份证)
  ❌ 更慢(要检查具体值)

核心区别:

count(*)     → 统计行数(最快)
count(1)     → 统计行数(和count(*)几乎一样)
count(字段)  → 统计字段非NULL的行数(更慢)

二、三种COUNT的区别 🔍

2.1 语法差异

-- 1. count(*)
SELECT COUNT(*) FROM users;
-- 统计所有行(包括NULL)

-- 2. count(1)
SELECT COUNT(1) FROM users;
-- 统计所有行(给每行赋值1,然后统计1的个数)

-- 3. count(字段)
SELECT COUNT(name) FROM users;
-- 统计name字段非NULL的行数

-- 4. count(DISTINCT字段)
SELECT COUNT(DISTINCT name) FROM users;
-- 统计name字段去重后的非NULL行数

2.2 执行原理

count(*)的执行过程

-- 表结构
CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT
);

-- 数据
INSERT INTO users VALUES 
(1, 'Alice', 25),
(2, 'Bob', NULL),
(3, NULL, 30);

-- 执行count(*)
SELECT COUNT(*) FROM users;

-- MySQL执行计划:
-- 1. 选择最小的索引(通常是主键索引)
-- 2. 扫描索引(不需要读取数据行)
-- 3. 统计索引中的记录数
-- 4. 返回结果:3

-- 原理:
--   → InnoDB引擎没有保存表的行数
--   → 需要遍历索引来统计
--   → 选择最小的索引(减少IO)
--   → 只扫描索引,不读数据(索引覆盖)

count(1)的执行过程

SELECT COUNT(1) FROM users;

-- MySQL执行计划:
-- 1. 选择最小的索引
-- 2. 扫描索引
-- 3. 给每行赋值1(虚拟的,不实际存储)
-- 4. 统计1的个数
-- 5. 返回结果:3

-- 和count(*)的区别:
--   → 理论上count(1)多了一步"赋值1"
--   → 但MySQL优化器会优化掉这一步
--   → 实际执行和count(*)完全一样!

count(字段)的执行过程

SELECT COUNT(name) FROM users;

-- MySQL执行计划:
-- 1. 如果name有索引:
--    - 扫描name索引
--    - 检查每个值是否为NULL
--    - 统计非NULL的个数
--    - 返回结果:2(NULL不计数)

-- 2. 如果name无索引:
--    - 全表扫描(读取数据行)
--    - 检查name是否为NULL
--    - 统计非NULL的个数
--    - 返回结果:2

-- 特点:
--   ❌ 需要判断NULL(额外开销)
--   ❌ 可能需要读取数据行(慢)
--   ⚠️ 统计结果可能和count(*)不同

2.3 性能对比

-- 测试表(1000万行)
CREATE TABLE test (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(50),
    age INT,
    INDEX idx_name (name)
);

-- 插入1000万行数据
-- 其中10%的name为NULL

-- 性能测试:
SELECT COUNT(*) FROM test;     -- 耗时:1.2秒
SELECT COUNT(1) FROM test;     -- 耗时:1.2秒(和count(*)一样)
SELECT COUNT(id) FROM test;    -- 耗时:1.2秒(id是主键,非NULL)
SELECT COUNT(name) FROM test;  -- 耗时:1.5秒(需要判断NULL)
SELECT COUNT(age) FROM test;   -- 耗时:8.0秒(无索引,全表扫描)

性能排序:

count(*) ≈ count(1) ≈ count(主键) > count(有索引的列) >> count(无索引的列)

三、不同存储引擎的COUNT实现 🔧

3.1 MyISAM引擎

-- MyISAM表
CREATE TABLE test_myisam (
    id INT PRIMARY KEY,
    name VARCHAR(50)
) ENGINE=MyISAM;

-- 插入1000万行数据

-- count(*)查询
SELECT COUNT(*) FROM test_myisam;
-- 耗时:< 0.01秒(几乎瞬间!)

-- 原理:
--   → MyISAM引擎在表中保存了总行数
--   → count(*)时直接返回这个值
--   → 不需要遍历表
--   → 非常快!✅

-- 限制:
--   → 只有没有WHERE条件的count(*)才快
--   → 有WHERE条件仍需遍历

-- 示例:
SELECT COUNT(*) FROM test_myisam;               -- 快
SELECT COUNT(*) FROM test_myisam WHERE id > 10; -- 慢(需要遍历)

3.2 InnoDB引擎

-- InnoDB表
CREATE TABLE test_innodb (
    id INT PRIMARY KEY,
    name VARCHAR(50)
) ENGINE=InnoDB;

-- 插入1000万行数据

-- count(*)查询
SELECT COUNT(*) FROM test_innodb;
-- 耗时:1-2秒(需要遍历)

-- 原理:
--   → InnoDB没有保存总行数
--   → 因为MVCC(多版本并发控制)
--   → 不同事务看到的行数可能不同
--   → 必须遍历索引来统计

-- 例子:
-- 事务A
START TRANSACTION;
SELECT COUNT(*) FROM users;  -- 返回:100

-- 事务B(同时进行)
START TRANSACTION;
INSERT INTO users VALUES (101, 'New User');
SELECT COUNT(*) FROM users;  -- 返回:101

-- → 同一时刻,两个事务看到不同的行数
-- → InnoDB无法保存一个固定值

3.3 为什么InnoDB不保存行数?

原因1:MVCC(多版本并发控制)
  → 不同事务隔离级别看到不同数据
  → RC级别:读取最新提交的数据
  → RR级别:读取事务开始时的快照
  → 无法用一个值表示所有情况

原因2:支持事务
  → 事务可能回滚
  → 如果保存行数,回滚时需要更新
  → 增加事务开销

原因3:聚簇索引
  → InnoDB使用聚簇索引
  → 数据和索引在一起
  → 遍历索引也很快(相对)

四、COUNT性能优化 🚀

4.1 使用近似值

-- 方案1:使用EXPLAIN估算
EXPLAIN SELECT COUNT(*) FROM users;

-- 输出:
-- rows: 9876543  ← 这是估算值,不是精确值

-- 获取估算值(更快)
SELECT TABLE_ROWS 
FROM information_schema.TABLES 
WHERE TABLE_SCHEMA = 'mydb' 
  AND TABLE_NAME = 'users';

-- 返回:约1000万(估算值,误差10%-20%)

-- 优点:
--   ✅ 非常快(毫秒级)
--   ✅ 适合数据展示("约1000万条记录")

-- 缺点:
--   ❌ 不精确
--   ❌ 不适合业务逻辑判断

4.2 使用Redis缓存

@Service
public class UserService {
    
    @Autowired
    private RedisTemplate<String, Long> redisTemplate;
    
    @Autowired
    private UserMapper userMapper;
    
    private static final String COUNT_KEY = "user:count";
    
    // 查询总数(带缓存)
    public long getUserCount() {
        // 1. 先查Redis
        Long count = redisTemplate.opsForValue().get(COUNT_KEY);
        
        if (count != null) {
            return count;  // 缓存命中,直接返回
        }
        
        // 2. 查数据库
        count = userMapper.selectCount(null);
        
        // 3. 写入Redis(1小时过期)
        redisTemplate.opsForValue().set(COUNT_KEY, count, 1, TimeUnit.HOURS);
        
        return count;
    }
    
    // 新增用户(更新缓存)
    @Transactional
    public void addUser(User user) {
        // 1. 插入数据库
        userMapper.insert(user);
        
        // 2. 更新Redis(方案1:+1)
        redisTemplate.opsForValue().increment(COUNT_KEY);
        
        // 或者(方案2:删除缓存,下次查询时重新计算)
        // redisTemplate.delete(COUNT_KEY);
    }
    
    // 删除用户(更新缓存)
    @Transactional
    public void deleteUser(Long id) {
        userMapper.deleteById(id);
        redisTemplate.opsForValue().decrement(COUNT_KEY);
    }
}

优点:

✅ 查询非常快(Redis,毫秒级)
✅ 减轻数据库压力
✅ 适合展示类场景

缺点:

❌ 可能不准确(缓存更新失败)
❌ 需要维护缓存一致性
❌ 不适合强一致性要求

4.3 单独维护计数表

-- 创建计数表
CREATE TABLE table_count (
    table_name VARCHAR(50) PRIMARY KEY,
    row_count BIGINT NOT NULL DEFAULT 0,
    update_time DATETIME
);

-- 初始化
INSERT INTO table_count (table_name, row_count, update_time)
VALUES ('users', (SELECT COUNT(*) FROM users), NOW());

-- 触发器:INSERT时+1
CREATE TRIGGER users_insert_trigger
AFTER INSERT ON users
FOR EACH ROW
BEGIN
    UPDATE table_count 
    SET row_count = row_count + 1,
        update_time = NOW()
    WHERE table_name = 'users';
END;

-- 触发器:DELETE时-1
CREATE TRIGGER users_delete_trigger
AFTER DELETE ON users
FOR EACH ROW
BEGIN
    UPDATE table_count 
    SET row_count = row_count - 1,
        update_time = NOW()
    WHERE table_name = 'users';
END;

-- 查询总数(非常快)
SELECT row_count FROM table_count WHERE table_name = 'users';
-- 耗时:< 1ms

优点:

✅ 查询极快(单行查询)
✅ 强一致性(触发器保证)
✅ 适合业务逻辑判断

缺点:

❌ 增加写入开销(每次INSERT/DELETE都要更新计数表)
❌ 触发器维护成本
❌ 并发写入时可能成为瓶颈

4.4 分页优化

-- 问题:分页需要总数
SELECT COUNT(*) FROM orders WHERE user_id = 123;  -- 慢
SELECT * FROM orders WHERE user_id = 123 LIMIT 0, 10;

-- 优化1:不显示总数(推荐)
-- 只显示"下一页"按钮,不显示总页数
SELECT * FROM orders 
WHERE user_id = 123 
  AND id > #{lastId}  -- 上次查询的最后一个ID
LIMIT 10;

-- 优点:
--   ✅ 不需要count(*)
--   ✅ 性能好
--   ✅ 适合移动端(无限滚动)

-- 优化2:只在第一页查询总数
// 第一页:查询总数
if (page == 1) {
    long total = orderMapper.selectCount(...);
    // 缓存total到session或Redis(5分钟)
}

// 后续页:直接使用缓存的total
long cachedTotal = cache.get("order_total_" + userId);

4.5 使用覆盖索引

-- 表结构
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    user_id BIGINT,
    amount DECIMAL(10,2),
    status TINYINT,
    create_time DATETIME,
    INDEX idx_user_id (user_id)
);

-- count(*)查询
SELECT COUNT(*) FROM orders WHERE user_id = 123;

-- 执行计划:
-- 使用idx_user_id索引
-- 覆盖索引(不需要回表)
-- 只扫描索引,非常快 ✅

-- 如果查询无索引的列:
SELECT COUNT(*) FROM orders WHERE amount > 100;
-- 全表扫描 ❌

-- 优化:添加索引
CREATE INDEX idx_amount ON orders(amount);
-- 现在count(*)会使用idx_amount,快很多 ✅

五、实战案例 💼

案例1:电商订单统计

-- 需求:统计各状态的订单数量

-- 方案1:多次count(慢)
SELECT COUNT(*) FROM orders WHERE status = 0;  -- 待支付
SELECT COUNT(*) FROM orders WHERE status = 1;  -- 已支付
SELECT COUNT(*) FROM orders WHERE status = 2;  -- 已发货
SELECT COUNT(*) FROM orders WHERE status = 3;  -- 已完成
-- 问题:4次全表扫描!

-- 方案2:使用GROUP BY(推荐)✅
SELECT 
    status,
    COUNT(*) as count
FROM orders
GROUP BY status;

-- 结果:
-- status | count
-- -------+-------
--   0    | 1000
--   1    | 5000
--   2    | 3000
--   3    | 10000

-- 只需要一次扫描!性能提升4倍!

-- 方案3:使用SUM + CASE(单行结果)
SELECT 
    SUM(CASE WHEN status = 0 THEN 1 ELSE 0 END) as wait_pay,
    SUM(CASE WHEN status = 1 THEN 1 ELSE 0 END) as paid,
    SUM(CASE WHEN status = 2 THEN 1 ELSE 0 END) as delivered,
    SUM(CASE WHEN status = 3 THEN 1 ELSE 0 END) as finished
FROM orders;

-- 结果(一行):
-- wait_pay | paid | delivered | finished
-- ---------+------+-----------+---------
--   1000   | 5000 |   3000    |  10000

-- 也是一次扫描,但结果格式不同

案例2:分库分表后的COUNT

-- 场景:orders表分库分表(4个库×256个表=1024个分片)

-- 需求:统计某用户的订单总数
SELECT COUNT(*) FROM orders WHERE user_id = 123;

-- 问题:
--   → user_id=123只在一个分片
--   → 但ShardingSphere不知道在哪个分片
--   → 可能查询所有1024个分片!💀

-- 解决方案1:业务层聚合
long count = 0;
for (DataSource ds : dataSources) {
    count += jdbcTemplate.queryForObject(
        "SELECT COUNT(*) FROM orders_" + getTableIndex(userId) + " WHERE user_id = ?",
        Long.class,
        userId
    );
}

-- 解决方案2:在查询条件中包含分片键
SELECT COUNT(*) FROM orders 
WHERE user_id = 123  -- 分片键,可以路由到具体分片
  AND create_time >= '2024-01-01';

-- 解决方案3:使用ES
// ES中保存订单聚合数据
SearchResponse response = esClient.search(s -> s
    .index("orders")
    .query(q -> q.term(t -> t.field("userId").value(123)))
    .size(0)  // 不需要返回文档
);

long count = response.hits().total().value();

六、面试高频问题 🎤

Q1: count(*)、count(1)、count(字段)的区别?

答:

  1. count(*):统计所有行(包括NULL),MySQL会选择最小的索引遍历,性能最好
  2. count(1):统计所有行,和count(*)几乎一样(MySQL优化器会优化)
  3. count(字段):统计字段非NULL的行数,需要判断NULL,性能较差

推荐使用count(*)。

Q2: 为什么MyISAM的count(*)比InnoDB快?

答: 因为MyISAM在表中保存了总行数,count(*)时直接返回。而InnoDB因为支持MVCC(多版本并发控制),不同事务看到的行数可能不同,无法保存一个固定值,必须遍历索引统计。

Q3: 如何优化大表的count(*)?

答:

  1. 使用近似值:EXPLAIN或information_schema.TABLES(快但不准确)
  2. Redis缓存:缓存总数,增删时更新(快但可能不一致)
  3. 计数表:单独维护计数表+触发器(快且准确)
  4. 分页优化:不显示总数,使用"下一页"方式
  5. 覆盖索引:确保有索引可用,避免全表扫描

Q4: count(*)会导致全表扫描吗?

答: 不一定。InnoDB的count(*)会选择最小的索引遍历,不是全表扫描(全表扫描是扫描数据文件)。如果有WHERE条件且条件列有索引,会使用该索引;如果没有WHERE条件,会选择最小的索引(通常是主键索引)遍历。

Q5: 如何选择count(*)、count(1)、count(主键)?

答: 都选count(*),因为:

  1. count(*)是SQL标准,语义清晰
  2. MySQL对count(*)有专门优化
  3. count(1)和count(*)性能几乎一样
  4. count(主键)也差不多,但需要确保主键非NULL

七、总结口诀 📝

COUNT函数有三种,
星号一字段来分。
count(*)最常用,
统计所有行数清。

count(1)差不多,
优化器会优化成。
count(字段)要小心,
NULL值不计数中。

MyISAM引擎快,
表中保存总行数。
InnoDB要遍历,
MVCC是原因重。

性能优化有妙招,
缓存计数少不了。
覆盖索引要记牢,
分页优化效果好!

参考资料 📚


下期预告: 154-如何保证Redis缓存和MySQL数据的一致性?🔄


编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0

愿你的COUNT如计数器般精准高效! 🔢✨