如果你在 Kingbase 上执行过select count(*) from a_table,而且那张表已经有几百万行,大概率会等到心里发毛。接着你可能开始怀疑数据库是不是坏了,甚至想吐槽一句:连个行数都拿不到,算什么数据库。先别急着下结论,我今天想聊的恰恰是这个现象背后的设计逻辑:Kingbase 设计上就不提供实时准确行数。这句话不是贬义,而是所有严肃关系型数据库共同的取舍。
这个结论对两类人特别有用:一类是刚接触 Kingbase、正被慢吞吞的count(*)折磨的开发者,另一类是准备做技术选型、需要判断“能不能拿数据库的行数做业务逻辑”的架构师。理解了这个设计逻辑,你就不会再纠结“为什么count(*)不准”,而是会把精力放在“什么时候该用估算值、什么时候该接受精确扫描、什么时候自己维护一个行数”。
1. 为什么 Kingbase 不提供“实时准确行数”
1.1 MVCC 决定了行数是“视角”问题
Kingbase 和 PostgreSQL 系数据库一样,底层使用 MVCC(多版本并发控制)来管理数据。MVCC 的核心思想是:每个事务看到的是某个时间点的数据快照,而不是数据库当前时刻的物理状态。一个事务在开始执行时拿到一个快照标识,之后无论读取多少次,它都只能看到这个快照下已经提交、并且对该事务可见的数据。
这就带来一个很多人没想明白的问题:所谓“准确行数”,到底是哪个时刻的准确行数?事务 A 开始时有 1000 行,事务 B 在同一秒插入了 10 行并提交,事务 C 又删除了 5 行。此时你去执行count(*),看到的行数取决于你的事务快照落在哪个位置。不同会话、不同隔离级别、不同启动时间,拿到的是完全不同的“准确行数”。
因此,数据库不可能在表头挂一个全局计数器,每次增删改都实时更新。那样做不仅违背 MVCC 语义,还会让所有写入事务去抢占同一把锁,性能会直接崩塌。设计者的选择是:不提供一个“魔法般的实时行数”,而是让count(*)按快照扫描并统计,同时把优化器需要的估算行数另存为统计信息。
1.2 精确 count 是最昂贵的查询之一
在没有 WHERE 条件时,select count(*) from table看起来简单,但数据库必须把表中的每一行都读出来,逐个判断该行在当前快照下是否可见。这个判断要检查行的创建事务、删除事务、提交状态、infomask 位图等一堆信息。表越大,扫描的块越多,count(*)就越慢。
很多人不理解:为什么我不能直接看索引的条目数?原因是索引里同样有 MVCC 可见性问题。普通索引的每个条目都需要回表或者通过可见性映射来判断当前快照下是否有效。即便你建了一个只包含单列的窄索引,让数据库走 Index Only Scan,它依然要扫描索引叶节点,并检查每一行的可见性。如果表刚被大量更新过,可见性映射还没标记为“全可见”,那 Index Only Scan 还要回表检查,代价更高。
所以精确count(*)本质上是一个全量扫描问题。它没有办法通过维护一个计数器来 O(1) 返回结果。一旦你理解了这一点,就会明白“为什么数据库设计上就不提供实时准确行数”这句话其实是在保护并发性能,而不是为难用户。
1.3 统计信息才是优化器的“行数来源”
既然不提供实时准确行数,那优化器怎么决定选全表扫描还是走索引?靠的是统计信息。Kingbase 在后台会自动执行 ANALYZE,或者你手动执行ANALYZE,数据库会采样一部分数据页,估算当前表的行数和页面数,并写入系统表。
在 Kingbase 的 PostgreSQL 兼容模式下,这个信息主要存在pg_class表的reltuples和relpages字段里。reltuples是估算行数,relpages是估算占用页数。优化器拿到这两个值,结合查询条件的选择性,就能估算出每种执行计划的成本。
这透露了一个关键点:Kingbase 在设计上把“估算行数”和“准确行数”分成两套体系。估算行数给优化器用,追求的是低成本和足够准确;准确行数只在count(*)执行时实时计算,追求的是结果正确但代价高昂。你不能指望pg_class.reltuples等于真实行数,它只是一个统计值,会随着 ANALYZE 和 VACUUM 更新,但不会因为每一条 INSERT 实时变化。
2. 这不是 Kingbase 的个例,各家数据库都是这么设计的
2.1 PostgreSQL / Oracle / MySQL 的取舍
如果你用过 PostgreSQL,会很熟悉pg_class.reltuples。PostgreSQL 同样不维护实时行数,精确count(*)一样要扫描。Oracle 里也有类似设计:ALL_TABLES.NUM_ROWS是优化器统计信息,它来自DBMS_STATS采样,不是实时的。你查ALL_TABLES看到的是上次收集统计信息时的估算值。
MySQL 的 InnoDB 引擎也是一样。虽然select count(*) from table在 InnoDB 里会走最少的二级索引扫描,比扫主键快一些,但它依然要扫描索引,而不是直接返回结果。information_schema.tables.table_rows显示的则是采样估算值,和真实值经常有明显偏差。
数据库厂商们在这个问题上的态度高度一致:实时准确行数需要维护全局计数器或者频繁统计,这会破坏写入扩展性,还会因为事务并发导致同一时刻不同事务看到不同状态。与其纠缠“实时准确”这个伪需求,不如用采样估算喂给优化器,再把“全量精确统计”作为用户显式执行的代价高昂的操作。
2.2 统计信息与真实数据的差异
统计信息是采样得到的,天然存在误差。采样率越高,误差越小,但 ANALYZE 花费的时间也越长。Kingbase 的默认统计目标是为常规业务平衡折中的结果:既不让统计过程本身拖垮系统,又能保证执行计划大体正确。
实际生产环境里,统计信息和真实行数偏差 10% 甚至 20% 都很常见,尤其是在频繁增删改、又没到自动 ANALYZE 阈值的大表上。你刚执行完ANALYZE,下一秒来了一笔批量导入,reltuples立刻就不准了。这不是 bug,而是统计机制天生带有的滞后性。
因此,当你问“为什么这个表的估算行数和真实行数差这么多”时,先检查一下上次 ANALYZE 是什么时候,再考虑是不是表数据变化太剧烈。数据库的优化器并不要求统计数字百分之百精确,只要量级正确,执行计划的成本估算就不会离谱。
2.3 Kingbase 在兼容模式下的差异
Kingbase 的一个特色是同时兼容 PostgreSQL 和 Oracle 两种使用习惯。在 PostgreSQL 兼容模式下,你查pg_class就能拿到统计信息;在 Oracle 兼容模式下,你也可以像查 Oracle 一样查ALL_TABLES或者DBA_TABLES的NUM_ROWS字段。
但不管走哪种模式,底层存储的信息来源是一样的,都是统计进程或手动 ANALYZE 之后落盘的估算数据。也就是说,兼容模式改变了你查询统计信息的 API,却没有改变“不提供实时准确行数”这个内核。这个设计思路是彻底的:从存储引擎到优化器,整个系统都建立在对估算数据的依赖上。
3. 不用 count(*) 也能拿到行数:三种实操方案
3.1 方案一:查系统统计表
如果你只是想快速知道一张表大概有多少行,最稳妥的方式不是执行count(*),而是先确保统计信息比较新,然后查询系统表。
-- 先手动更新统计信息,确保 reltuples 反映当前数据量 ANALYZE your_table; -- 再查询估算行数和占用页数 SELECT reltuples::bigint AS estimated_rows, relpages::bigint AS estimated_pages FROM pg_catalog.pg_class WHERE relname = 'your_table';需要注意几点:
reltuples是float4类型,转成bigint后更直观。- 表名如果包含大小写或模式前缀,查询条件要写完整,比如
relname = 'Orders' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'public')。 - 如果这张表从来没有被 ANALYZE 过,
reltuples可能是 0 或一个默认值,这种情况下查询结果没有参考意义,必须手动ANALYZE。
在 Oracle 兼容模式下,也可以使用ALL_TABLES.NUM_ROWS:
SELECT table_name, num_rows, blocks FROM all_tables WHERE table_name = 'YOUR_TABLE';这种查询是毫秒级的,因为它只是读取元数据,不触碰表数据。如果你的前端分页需要显示“共多少条”,用这个值就足够了。只要在页面上加一句“数据量统计有延迟”,绝大多数用户都能接受。
3.2 方案二:用 EXPLAIN 估算
除了直接查系统表,你还可以通过EXPLAIN观察优化器估算的行数。比如:
EXPLAIN SELECT * FROM your_table WHERE status = 'active';计划输出中会有一行扫描节点,比如:
Seq Scan on your_table (cost=0.00..1234.56 rows=45678 width=23)这里的rows=45678就是优化器基于统计信息和查询条件估算出来的行数。它并不保证准确,但能帮你快速了解一张表的数据量级,以及一个过滤条件的选择性。
这个方法在日常排查执行计划时特别有用。我习惯在做 SQL 调优时先跑一遍EXPLAIN,看看优化器认为目标表有多少行。如果这里显示的估算值明显异常,比如一张真实有 1000 万行的表显示只有 100 行,那我第一反应就是统计信息过期了,先去跑一次ANALYZE再回来重新分析。
3.3 方案三:维护计数表与物化视图
如果你的业务真的需要“随时快速读取一个相对精确的行数”,最靠谱的做法不是指望数据库系统表,而是在应用层或者数据库层自己维护一个计数器。
比如创建一张计数表:
CREATE TABLE public.table_row_count ( table_name text PRIMARY KEY, row_count bigint NOT NULL ); -- 初始化 INSERT INTO public.table_row_count VALUES ('orders', 0);然后通过触发器在每次插入和删除时更新计数:
CREATE OR REPLACE FUNCTION public.sync_orders_count() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN UPDATE public.table_row_count SET row_count = row_count + 1 WHERE table_name = 'orders'; RETURN NEW; END; $$; CREATE TRIGGER trg_orders_insert AFTER INSERT ON public.orders FOR EACH ROW EXECUTE FUNCTION public.sync_orders_count();删除时对应做减一操作。这里有几个关键点:
- 触发器和主表操作在同一个事务里,所以事务回滚时计数表也会回滚,两边会保持一致。
- 行级触发器在高频写入场景下会有额外开销。如果每秒写入量很大,计数表的 UPDATE 会成为热点,你需要评估收益和代价。
TRUNCATE不会触发行级触发器,所以清空表后要把计数表手工重置为 0,否则数据就对不上了。
另一种做法是使用物化视图定期刷新统计结果:
CREATE MATERIALIZED VIEW orders_count_mv AS SELECT count(*) AS row_count FROM public.orders; REFRESH MATERIALIZED VIEW orders_count_mv;物化视图本质是一个快照,只在刷新时更新。你可以安排每天凌晨刷新一次,白天查询时直接读这个物化视图。它的好处是逻辑简单,坏处是不能实时反映数据变化,而且刷新大表的物化视图本身也要花时间。
4. 什么时候必须用精确 count(*),怎么降低代价
4.1 适合精确 count 的场景
虽然实时准确行数不是一个好的全局需求,但有些场景确实需要精确值。
最典型的是事务性业务:比如订单号分配、库存余量校验、对账统计。这些场景不光要求行数准确,还要求在同一事务快照下和其他查询保持一致。此时你不能用统计信息估算,只能靠count(*)或者自己维护的计数逻辑。
另一个场景是后台报表或数据校验。比如月底导出全量数据,需要核对“导出了多少条”,这时候跑一次全量count(*)是合理操作。即使花几十秒,只要在非高峰窗口执行,影响也可控。
判断标准很简单:如果行数会被当作资金、库存、配额的判断依据,那必须精确;如果只是列表页的总数、容量估算、趋势曲线,那估算值足够了,没必要让数据库付出全表扫描的代价。
4.2 降低 count 代价的优化手段
如果确实需要精确count(*),也不代表只能傻傻地全表扫描。你可以从这几个方向优化。
第一,尽量让count(*)带上能用索引的条件。比如:
SELECT count(*) FROM orders WHERE status = 'PAID';如果status字段有索引,而且大部分行的status都不是PAID,那数据库可以走索引扫描,扫描的数据量会小很多。反过来,如果PAID几乎占全部数据,那依然接近全表扫描,这需要结合实际数据分布判断。
第二,使用覆盖索引。把查询需要的字段都放进索引,让数据库走 Index Only Scan,减少回表。常见的做法是做一个窄索引:
CREATE INDEX idx_orders_status_only ON orders(status);这样count(*) where status = 'PAID'只需要扫描索引叶节点,不碰堆表,性能会有明显提升。
第三,设置合理的并行度。Kingbase 在支持并行查询的版本里,可以让count(*)利用多个 CPU 并行扫描多个数据块,最后汇总结果。大表并行 count 能有效缩短响应时间,但会在短时间拉高 CPU 和 IO。不要在业务高峰期贸然开启高并行度,最好在压测环境确认峰值。
第四,通过分区表把数据拆开,分别count(*)再汇总。比如按月分区的大表,统计全年数据时,可以并发查询十二个分区,各自返回一个计数,再由应用层汇总。分区裁剪也能让条件查询只扫描相关分区,避免全表扫描。
5. 常见问题与排查技巧实录
5.1 count(*) 慢到不可接受
这是最常见的现象。一张千万行甚至上亿行的表,直接select count(*) from big_table,跑了十几秒甚至几十秒。你不需要立刻优化 SQL,第一步先确认数据量级和表结构。
如果这张表经常被查询总数,但业务并不需要绝对精确,那就改成查pg_class.reltuples,别再让前端每次都触达count(*)。如果业务需要精确值,那就参考 4.2 节的手段,看能否加索引、走并行、或者分区。
我遇到过不少真实案例,前端列表页每次刷新都调用一次count(*),一天几百万次请求,硬生生把一个大库的 CPU 打满。后来改成“每五分钟由后台定时任务算一次总数,结果写入 Redis,前端直接读缓存”,数据库负载立刻降下来了。问题往往不是count(*)本身,而是业务代码把它用在了错误的频率上。
5.2 估算行数与实际行数差距太大
当你查询pg_class.reltuples,发现它和实际行数差了好几个量级,先检查统计信息时效性。
SELECT relname, last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE relname = 'your_table';如果last_analyze已经是好几天前,而这张表每天都在大量写入,那统计信息落后很正常。手动执行一次ANALYZE your_table,然后重新查询reltuples,一般就能回到一个合理区间。
另外一个容易被忽视的点是:VACUUM和ANALYZE对reltuples的更新方式是采样估算,不是扫描全表。所以即使刚 ANALYZE 完,得到的也是一个近似值,不需要追求它和count(*)完全一致。你需要关注的是量级对不对,而不是小数位是否精准。
5.3 count(1)、count(*)、count(字段) 的语义差别
这个问题你们迟早会遇到。count(*)和count(1)在 Kingbase 里语义基本等价,都是统计快照下所有可见行数,不存在网上流传的“count(1) 比 count(*) 快”的说法。现代优化器会把两种写法转换成同样的执行计划。
但count(字段)不一样。它只统计该字段非 NULL 的行数。如果某一行这个字段是 NULL,就不会被计入。所以:
SELECT count(*) FROM orders; -- 所有行 SELECT count(1) FROM orders; -- 所有行,等价于 count(*) SELECT count(status) FROM orders; -- status 不为 NULL 的行如果你发现count(status)比count(*)少,不是数据丢了,而是有些行的status是 NULL。这个陷阱在写统计逻辑时特别容易踩到。
6. 写在最后:从“精确”到“够用”的思路转变
我自己刚开始用 Kingbase 时,也被“行数不实时准确”这件事困扰过。当时接了一个报表模块,前端要显示总条数,我第一反应就是用count(*)。结果表数据量一上来,接口超时,产品经理天天催。后来我换了个思路:先保证列表页的数据本身是实时的,把总数改成由后台定时任务统计、每十分钟刷新一次。产品接受后,数据库压力立刻降了下来。
这件事让我想明白一个问题:大多数业务里的“准确行数”其实不是精确值需求,而是“看起来合理、响应够快”的需求。数据库的统计信息、估算值、计数表,都是为了帮你在准确和性能之间找到平衡点。真正需要精确行数的场景并没有那么多,如果你遇到了,优先考虑能否通过索引、分区、计数表来满足,而不是让查询在每次请求时都全表扫一遍。
下次再有人问你“为什么 Kingbase 拿不到实时的准确行数”,你可以把这篇的内容讲给他听:这不是能力缺失,而是数据库在并发一致性、查询性能和实现成本之间作出的理性设计。你要做的不是抱怨它,而是学会在不同的需求下,选择最合适的那把尺子。