有一回线上同事找我,说后台统计页面打开巨慢,一条 SQL 拖了十几秒才出结果。我让他把抓到的最新 SQL 文本发过来,一看就是烂大街的select count(*) from 订单表 where 创建时间 between ...。我第一反应是索引没走对,查完才发现,问题根本不在于这一句写得差,而是 Kingbase 这款数据库在架构设计上,压根就没给“查一个实时准确的行数”准备廉价通道。这句话说出来很多人不信——明明执行了count(*),结果也不是估算值,凭什么说不提供实时准确行数?这篇文章就把这个设计讲透,顺便聊聊真遇到行数场景时该怎么落地。
先说结论:Kingbase 的count(*)是实时扫出来的,不是从某个现成计数器里读出来的,所以它“准确”但“不廉价”;与此同时,系统表里的行数估算又是廉价的但“不准确”。两者加在一起,就是标题那句话——设计上就不提供实时准确行数。
1. 从一次线上“卡顿”说起
1.1 标题这句判断,到底在说什么
很多开发者的第一反应是:数据库里不都有一张“表”记录每张表的行数吗?我查一下那张表不就完了?传统认知里,元数据中确实有类似字段,但“有字段”和“字段实时准确”是两码事。在 Kingbase 里,pg_class.reltuples存了一个行数估算值,它来自ANALYZE或VACUUM采样,不是每次 DML 都会去更新。这个值用于优化器估算执行计划,不承诺与真实行数一致。
所以你要理解的是:Kingbase 提供“准实时准确行数”这条路,在系统设计层面就被放弃了。它只提供两种东西——兜底的精确扫描和采样来的估算值。中间那条“既便宜又精确”的路,不存在。
1.2 这个结论背后的 SQL 体验
我复现一下当时的场景。有一张千万级订单表,业务方想在页面显示“当前订单总数”,于是执行:
select count(*) from orders;这条语句在 Kingbase 里会进入全表扫描(或者全索引扫描),扫描过程要逐行判断可见性。如果表有 2000 万行,哪怕只取一列,也要遍历 2000 万条版本记录并做可见性判断,耗时随数据量线性增长。页面刷一次要几秒甚至十几秒,这还能忍,但如果有几个后台页面同时各刷一次,数据库 IO 和 CPU 立刻吃紧。
更隐蔽的是另一种写法:
select count(*) from orders where create_time >= now() - interval '7 days';业务以为“走索引就该快”,但实际上过滤 7 天数据可能仍然要扫大部分索引条目,最终代价还是与命中数据量相关。这个场景让我意识到,真正要解决的不是优化某一条 count,而是接受这个产品特性,换一套统计思路。
2. 为什么技术上拿不到实时准确行数
2.1 MVCC 与多版本可见性
核心原因藏在 MVCC 里。Kingbase 沿用了业界主流的 MVCC 事务模型,同一行物理数据可能同时存在多个历史版本。不同事务在不同时间点发起查询,看到的数据集合是不一样的。比如事务 A 删除了一行,事务 B 在那个时刻还没提交,另一个事务 C 可能还能看到旧版本。
这就意味着,“这张表到底有多少行”这句话本身需要先定义快照时刻,再对快照内所有行做可见性判定。数据库没有办法在每一行落盘时顺便维护一个全局计数器——因为同一行对当前事务未必可见,加了计数还得在事务结束时回滚,代价非常高。
用生活类比:你问一个停车场现在停了多少辆车,管理员如果有一块实时电子屏,一进一出就更新,这叫计数器方案;但 Kingbase 更像一个没有电子屏的大型自由停车场,每个入口闸机只知道“有一辆车经过”,但不知道这辆车是不是真的停进来了、是不是已经走了,唯一靠谱的办法就是派人进去一辆辆数。
2.2 count(*) 是一个“实时扫描”操作
在 Kingbase 里,count(*)不是一个从系统表读取的元数据操作,而是一个标准的聚合执行计划。它大概率表现为两种形态:
- 全表顺序扫描(Seq Scan):遍历堆表所有数据页,逐行判断可见性,然后计数。
- 索引全扫描(Index Only Scan):如果优化器认为某个索引比表更小,会选择扫描索引页面,同样要逐条判断可见性。
无论哪种,代价都与数据规模成正比。可能有人问:为什么不直接读pg_class.reltuples?因为优化器不敢用一个可能相差几个数量级的估算值来回答查询结果,它必须保证查询语义正确。count(*)的语义就是“扫描所有可见行并计数”,不是“返回一个近似值”。
2.3 统计信息是估算而非事实
系统表里的行数到底准不准?我们来看一次真实的数值变化:
select relname, reltuples, relpages from pg_class where relname = 'orders';reltuples的数值是最近一次ANALYZE或VACUUM时的采样结果。如果表在分析后又插入了大量数据,reltuples不会自动变化。我做过一个实验:一张空表插入了 100 万行,没有执行ANALYZE,此时reltuples可能仍显示极小的值;直到手动执行:
analyze orders;reltuples才会刷新到接近 100 万。注意“接近”——它只是采样估算,仍然可能有一定偏差。所以在 Kingbase 的使用习惯里,reltuples的正确用途是评估查询计划、观察表膨胀、判断是否需要 VACUUM,而不是给业务报表当精确行数用。
3. 设计上的取舍:准确 vs 实时 vs 性能
3.1 为什么不能像某些引擎那样维护一个计数器
数据库领域确实存在会维护行计数的存储引擎。它们的特点是表结构相对固定、并发写入模型简单,能够在数据变更时同步更新计数。但这种设计也有代价:每次插入、删除都要额外修改计数元数据,造成锁竞争和写放大;遇到事务回滚还要补偿计数;一旦计数元数据损坏,重建成本很高。
Kingbase 选择不做这件事,本质是取舍。它在设计上优先保证核心事务的高吞吐、稳定并发和写入低延迟,不愿意为了“查行数更快”这个低频需求,给每次 DML 都加上一把全局锁或者额外开销。通俗地说,为了一个不常发生的查询,惩罚所有高频写入,这笔账不划算。
3.2 MVCC 并发模型决定了“代价”
MVCC 本身就是为了解决并发读写冲突而设计的。准确行数维护和 MVCC 天然冲突:行版本对事务是否可见取决于事务开始时间、提交顺序,这根本不是一个简单加减计数器能表达的。
举个实际并发例子:事务 A 插入一行还没提交,事务 B 统计行数,看不到 A 的新行;事务 A 随后回滚,事务 C 统计行数,依然看不到;但一个全局计数器在这过程中要怎么变化?插入时 +1,回滚时 -1,如果 B 的查询已经读取过计数,它拿到的值又是否代表真实可见行数?答案是:任何“全局计数器 + 事务快照”的组合,都无法在不加锁的情况下给出每个并发事务的准确可见行数。这就决定了任何一种“实时准确”方案都会极大拖垮并发场景,所以设计上干脆不做。
3.3 这个设计的利与弊
劣势很明显:count(*)场景体验不直观,新手容易误判为“数据库卡死”;报表类功能直接依赖行数会踩坑;分页大接口容易拖垮实例。优势也很实在:写入链路没有额外开销,事务处理不会因为统计行数而互相阻塞,系统在复杂并发下依然保有稳定的吞吐。
我个人的态度是:不要试图让数据库去适应你“随时想要精确行数”的习惯,而要调整自己的统计方案去适应它的设计。后面第 5 节会给出具体手段。
4. 实际影响范围:哪些场景真正会踩坑
4.1 高影响场景
- 后台列表分页:要求返回“总条数 N 条”,每次翻页都执行一次
count(*),数据量大时接口直接变慢。 - 统计报表:按时间维度统计记录数,SQL 里多个
count(*)聚合,扫描范围巨大。 - 数据同步校验:“源端 10 万行,目标端是不是也 10 万行?”这种对账逻辑对准确与实时有双重要求,极易踩坑。
- 容量评估:想通过“今天订单表有多少行”判断增长速度,如果直接
count(*),每次全表扫描成本过高,还不如看reltuples和磁盘占用趋势。
4.2 低影响场景
- 主键点查、条件过滤、普通业务事务:这些查询的返回结果是少量行,不需要统计全表行数。
- 数据更新、删除操作:逻辑上依赖的是“匹配条件行数”,通常走索引,影响可控。
- 数据完整性校验(业务层面),可以在业务代码里用增量 ID 或事务日志处理,不依赖数据库的行数统计能力。
4.3 与同类数据库的横向对照
从内核来看,Kingbase 与 PostgreSQL 同源,所以这个特性也继承自 PostgreSQL:PG 同样不维护实时精确行数,count(*)同样需要扫描,reltuples同样只是估算。MySQL 的 InnoDB 引擎也类似,count(*)不读元数据、仍需扫描;而 MyISAM 引擎会保存精确行数,但代价是不支持事务、写锁粒度大。换句话说,绝大多数主流事务型数据库都不提供“实时准确行数”,这不是 Kingbase 独有的毛病,而是关系型事务引擎里普遍的取舍。
下面我整理一个对照表,方便记忆:
| 数据库 / 引擎 | count(*) 行为 | 元数据行数 | 事务能力 |
|---|---|---|---|
| Kingbase(核心) | 实时扫描,准确但慢 | reltuples 估算,不精确 | 完整 MVCC 事务 |
| PostgreSQL | 实时扫描,准确但慢 | reltuples 估算,不精确 | 完整 MVCC 事务 |
| MySQL InnoDB | 实时扫描,准确但慢 | 无精确行数 | 完整 MVCC 事务 |
| MySQL MyISAM | 读取计数器,极快 | 精确,但有写锁代价 | 无事务完整支持 |
这张表应该能帮团队统一认知:遇到“实时准确行数”需求,先不要觉得是数据库 bug,先想清楚这是不是所有事务型数据库都解决不了的老问题。
5. 替代与实战方案
5.1 估算行数:用系统表与查询计划
如果只想要一个量级,完全不需要count(*):
-- 近似行数 select reltuples::bigint from pg_class where relname = 'orders'; -- 更直观的方式:看执行计划的估算行数 explain select count(*) from orders;执行计划里的rows=1000000之类的数字,就是优化器基于统计信息算出来的估算值,适合判断规模、排查慢查询,不适合给用户展示精确总数。观察表增长趋势时,我会定期analyze orders;后再读reltuples,做成一个轻量监控任务,效果很好,不用反复全表扫。
5.2 精确计数:计数器表
业务真正需要的是“页面显示精确总数”,且写入量可接受时,我推荐计数器表。核心思路是把行数维护从数据库引擎层提到业务事务层。建一张计数表:
create table table_counter ( table_name text primary key, row_count bigint not null default 0 );然后在业务代码里,所有插入、删除与计数器更新放在同一个事务中执行。例如插入一条订单:
begin; insert into orders(...) values(...); update table_counter set row_count = row_count + 1 where table_name='orders'; commit;删除时对应减一。这样事务提交后,计数器的值就是精确值,而且查询select row_count from table_counter where table_name='orders'走主键点查,毫秒级返回。代价是每个 DML 增加一条更新语句,对高频写有一定开销;但如果业务量是中低频,这个方案非常稳。要注意:必须和业务 DML 同事务,否则并发场景下计数器会漂移;一旦发生异常回滚,计数也会跟着回滚,不会造成脏数据。
5.3 定时汇总:物化视图 / 汇总表
如果对“实时”要求没那么高,允许几分钟延迟,可以每天晚上或者每半小时跑一次:
create materialized view mv_order_count as select count(*) as cnt from orders; refresh materialized view mv_order_count;或者更轻量:定时把汇总结果写入一个统计表。页面展示时读汇总表,JSON 里带上“统计时间”,用户看到的是“截至某时点的总数”。这种方案最灵活,也是我处理报表类需求的首选——它同时兼顾了准确性(数据是实际扫出来的)和响应速度(不实时扫大表)。
5.4 需要分页总数怎么办:区间内计数 + 缓存
对于列表分页,不要直接 “总条数 + 当前页数据”全部实时算。我的通用做法是:
- 先查当前页数据,用
limit/offset。 - 总条数改用异步接口,用计数器表或者定时汇总表返回。
- 如果列表有时间范围筛选,则在筛选条件下做
count(*),但限制只能对最近 N 天查询;更大范围则提示用户“结果超过 N 万条,请细化筛选条件”。
实践中我还用过窗口函数在单页内做总数:
select id, title, count(*) over() as total_count from orders where create_time >= now() - interval '30 days' order by create_time desc limit 20;这种方式会多执行一次全量过滤统计,但它在一条 SQL 里完成,避免了应用层多次往返;数据量可控时也能接受。
5.5 对账与数据校验场景的替代逻辑
数据同步后的行数对账,强烈不建议直接两边count(*)硬碰。更稳的办法是:
- 按批次对账:用“批次号 + 自增主键最大值”判断是否丢数据。
- 抽样校验 + 计数摘要:对比两边每小时新增行数,连续看一段时间。
- 用事务日志追踪:数据同步方记录已同步行数,以长期增量累计为准。
这些方案固然多写代码,却能避免每次对账都把大表扫一遍。之前我帮团队做数据迁移验证时,就直接对比源端与目标端的 max(id) 和 sum(校验列),比 count 快了几个数量级,还绕开了实时准确行数的坑。
6. 常见问题与排查心得
6.1 count(*) 为什么这么慢,是卡死了吗?
大多数情况不是卡死,是在执行大范围扫描。排查步骤:
-- 查看当前正在执行的查询 select pid, state, query, wait_event_type, wait_event from pg_stat_activity where state = 'active';如果wait_event_type为空且还在跑,说明它在 CPU/IO 上花时间;如果看到锁等待,那才是真的被阻塞。判断是否“异常慢”,先看看表行数和查询范围,估算一下合理时长。
6.2 reltuples 不准怎么办?
执行一次:
analyze 表名;再查pg_class。高并发写入场景下,要按频率评估分析任务,比如每天凌晨跑一次analyze,保证优化器不会因为行数失真选错执行计划。注意analyze也会消耗资源,不要在业务高峰对所有大表一起做,建议错峰分批。
6.3 如何判断一个 count 到底会扫多少数据?
用explain:
explain select count(*) from orders where create_time >= now() - interval '7 days';看输出里的Seq Scan还是Index Scan,以及rows估算值。如果发现全表扫,但业务只关心最近数据,考虑加分区表或者走物化汇总;如果命中索引但返回行数巨大,同样会慢,因为还要对每条索引项判断可见性。
6.4 我踩过的几个具体坑
第一次帮业务方做“数据总量看板”,我图省事直接用count(*)查了三张大表,结果接口超时,把数据库 IO 打到告警线。后来换成计数器表 + 每日汇总,问题彻底消失。还有一个坑是:计数器表方案在项目初期没做并发测试,上线后发现并发写导致计数器漂移,排查到最后是有人把select和update分成了两个事务写。这类问题没有捷径,只能靠 Code Review 盯紧“同事务”这条铁律。
另外,如果公司有监控系统,可以把reltuples变化率做成趋势图,观察表增长速度,比定时count(*)高效得多。我自己做过一个简易脚本,每天凌晨对核心表执行analyze,再把reltuples落库成历史表,用普通图表就能看出表增长曲线,容量评估基本都靠它。
结尾的小体会
干这行久了,越来越觉得数据库很多设计不是缺陷,而是用一句话换来的特性。Kingbase 不保证实时准确行数,换来的是写入路径的简洁、并发事务的顺畅和优化器的灵活性。遇到这个限制,与其硬刚,不如在应用层补齐一套自己的统计方案。根据我个人经验,凡是提前做了计数器表、汇总表或趋势监控的团队,几乎没有一个是真被“行数统计慢”绊倒的;反过来,凡是临时上线才发现要count(*)的,基本都逃不过加班排查的命运。这个坑,能避开就早避开。