
如果你在 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_ROWSSELECT 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 (cost0.00..1234.56 rows45678 width23)这里的rows45678就是优化器基于统计信息和查询条件估算出来的行数。它并不保证准确但能帮你快速了解一张表的数据量级以及一个过滤条件的选择性。这个方法在日常排查执行计划时特别有用。我习惯在做 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 拿不到实时的准确行数”你可以把这篇的内容讲给他听这不是能力缺失而是数据库在并发一致性、查询性能和实现成本之间作出的理性设计。你要做的不是抱怨它而是学会在不同的需求下选择最合适的那把尺子。