一、开篇故事:三种数人头的方法 👥
想象体育场统计观众人数:
方法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(字段)的区别?
答:
- count(*):统计所有行(包括NULL),MySQL会选择最小的索引遍历,性能最好
- count(1):统计所有行,和count(*)几乎一样(MySQL优化器会优化)
- count(字段):统计字段非NULL的行数,需要判断NULL,性能较差
推荐使用count(*)。
Q2: 为什么MyISAM的count(*)比InnoDB快?
答: 因为MyISAM在表中保存了总行数,count(*)时直接返回。而InnoDB因为支持MVCC(多版本并发控制),不同事务看到的行数可能不同,无法保存一个固定值,必须遍历索引统计。
Q3: 如何优化大表的count(*)?
答:
- 使用近似值:EXPLAIN或information_schema.TABLES(快但不准确)
- Redis缓存:缓存总数,增删时更新(快但可能不一致)
- 计数表:单独维护计数表+触发器(快且准确)
- 分页优化:不显示总数,使用"下一页"方式
- 覆盖索引:确保有索引可用,避免全表扫描
Q4: count(*)会导致全表扫描吗?
答: 不一定。InnoDB的count(*)会选择最小的索引遍历,不是全表扫描(全表扫描是扫描数据文件)。如果有WHERE条件且条件列有索引,会使用该索引;如果没有WHERE条件,会选择最小的索引(通常是主键索引)遍历。
Q5: 如何选择count(*)、count(1)、count(主键)?
答: 都选count(*),因为:
- count(*)是SQL标准,语义清晰
- MySQL对count(*)有专门优化
- count(1)和count(*)性能几乎一样
- count(主键)也差不多,但需要确保主键非NULL
七、总结口诀 📝
COUNT函数有三种,
星号一字段来分。
count(*)最常用,
统计所有行数清。
count(1)差不多,
优化器会优化成。
count(字段)要小心,
NULL值不计数中。
MyISAM引擎快,
表中保存总行数。
InnoDB要遍历,
MVCC是原因重。
性能优化有妙招,
缓存计数少不了。
覆盖索引要记牢,
分页优化效果好!
参考资料 📚
下期预告: 154-如何保证Redis缓存和MySQL数据的一致性?🔄
编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0
愿你的COUNT如计数器般精准高效! 🔢✨