select count(id) 在事务中危险,因需逐行 mvcc 判断可见性,易被锁阻塞、读到未提交状态、争抢资源,且无法安全 ddl;count(id) 与 count(*) 性能无本质差异;高频计数应使用专用计数表而非 redis 或 count。

为什么 select count(id) 在事务里特别危险
不是语法报错,是逻辑陷阱。InnoDB 在已开启但未提交的事务中执行 COUNT(id),必须逐行判断每条记录对该事务是否可见——它不能跳过 MVCC 判断,也不能复用任何缓存。结果就是:可能被 gap lock 或 next-key lock 阻塞;可能扫到其他事务未提交的中间状态;还和你同一事务里的 INSERT 或 UPDATE 争抢 buffer pool 和 I/O。更隐蔽的问题是,你根本没法在事务里安全地加索引或改表结构,DDL 会直接失败或锁死。
count(id) 和 count(*) 其实没差别,别信“加字段就快”
MySQL 8.0+ 的 InnoDB 对 COUNT(*) 和 COUNT(id) 做了统一优化:只要 id 是主键(或非 NULL 的唯一索引列),两者都会走主键索引扫描;如果 id 是普通字段且允许 NULL,COUNT(id) 反而要额外过滤 NULL 值,比 COUNT(*) 更慢。真正影响性能的是有没有可用索引、索引大小、以及是否带 WHERE 条件。
-
EXPLAIN SELECT COUNT(*) FROM orders和EXPLAIN SELECT COUNT(id) FROM orders输出几乎一样 - 如果表没有主键,又没建
NOT NULL索引,两者都 fallback 到全表扫描 - 加
WHERE status = 'paid'后,哪怕status有索引,COUNT(id)也不会比COUNT(*)多省一个字节
高频 count 场景该用计数表,而不是 Redis
Redis 缓存计数在写失败时必然失衡,尤其不能用于资金、权限、库存等强一致性场景。真正可控的方案是建专用计数表:count_table,字段为 counter_name、value、updated_at。关键控制点:
- 事务隔离级别必须设为
READ COMMITTED,否则在REPEATABLE READ下,事务内多次读count_table会看到旧值 - 更新语句要用
INSERT ... ON DUPLICATE KEY UPDATE value = value + 1,主键为counter_name - 高并发写同一计数器时,考虑分桶:比如按
user_id % 100存 100 个 key,查总数时SUM(value) - 避免在业务事务里直接读写
count_table——它应作为独立聚合服务维护,和业务逻辑解耦
只查“有无”就别用 count,用 LIMIT 1
90% 的业务代码里,SELECT COUNT(*) FROM users WHERE org_id = ? AND role = ? 其实只是为了判断是否存在,不是真要数字。这种场景下,COUNT 是严重浪费:
- 哪怕只有一条匹配,也要扫完所有符合条件的行
- 无法利用
index condition pushdown提前终止 - 优化器不会为你自动转成半连接(semi-join)
换成 SELECT 1 FROM users WHERE org_id = ? AND role = ? LIMIT 1,数据库遇到第一条就返回,响应时间从秒级降到毫秒级。DAO 层方法名也该同步改成 existsByOrgAndRole(),而非 countByOrgAndRole()。
updated_at 字段没人监控,导致数据倾斜或长时间不更新却无人发现;还有人把 count_table 和业务表放在不同库,跨库事务一崩,计数就永远对不上。这些细节比选什么 SQL 写法更致命。











