列存储适不适合订单报表?从一条慢 SQL 聊聊金仓 KES 的使用边界
订单详情查得挺快,一到月底汇总就开始转圈。这种问题,做业务系统时并不少见。讨论优化方案,通常先想到加索引、拆分查询,再往后就会碰到"列存储"。
但我觉得,在决定换存储方式之前,应该先看清楚:这条查询到底在读什么,又有多少数据是读进来以后根本用不上的。
本文用订单月报做一个设计示例,讨论电科金仓数据库 KingbaseES(简称 KES)的列存使用思路。没有虚构的项目实测成绩,重点放在原理、示例和落地时容易忽略的地方。 
一、同一张订单表,实际上承担了两种工作
假设有一张订单表,除了订单编号、支付金额、支付时间,还保存收货地址、联系方式、渠道信息、优惠明细和扩展字段。
客服查订单时,通常根据订单编号找到一条记录,再读取其中的大部分信息。查询范围小,目标明确,普通行存表配合合适的索引,一般就能很好地处理。
月报查询却是另一回事。
运营想知道上个月各渠道有多少笔已支付订单、总金额是多少。可能要处理几百万条记录,但真正参与计算的只有支付时间、订单状态、渠道和金额几个字段。
两类查询的差别,可以简单列出来:
| 查询需求 | 读取记录的范围 | 使用字段的范围 |
|---|---|---|
| 根据订单编号查详情 | 很少 | 通常较多 |
| 统计月度渠道收入 | 较多 | 通常较少 |
| 修改订单状态 | 一条或少量记录 | 少量字段 |
| 汇总半年销售趋势 | 较多 | 日期、金额等少数字段 |
这也是列存储值得考虑的原因:有些分析查询需要处理很多行,却只关心很少几列。
当然,不能看到"聚合"两个字,就认定必须改列存。如果一个索引已经能把查询范围缩小到几百条,或者覆盖索引可以满足读取需要,现有行存方案可能已经足够。
我会先检查执行计划和实际扫描量,再讨论存储结构。否则,原本只是过滤条件写错了,最后却变成一次数据库改造。
二、列存省在哪里,不能只用"压缩率高"解释
行存和列存的区别,首先是数据组织方式。
行存通常把一条记录的多个字段组织在一起,便于访问完整记录。列存则把同一列的数据集中组织,查询只需要金额和渠道时,可以减少对其他字段数据的读取。
这里说的是存储组织原理。实际数据库还涉及数据页、缓存、溢出存储和不同访问路径,不能理解为行存查询任何字段都必然把整条记录的所有物理内容读一遍。
拿订单月报来说,收货地址再长,也不参与金额统计。分析查询如果能够只读取相关列,就有机会减少磁盘读取和内存搬运。
压缩是另一项收益。同一列的数据类型相同,取值往往也有规律。例如订单状态可能只有几种,渠道编号的重复度也可能较高。这类数据集中存放,通常比把多种字段混在一起更容易压缩。
不过,压缩并非没有成本。数据读取后需要相应的解压处理;实际收益取决于字段内容、压缩方式,以及查询当时更受磁盘还是 CPU 限制。随机性很强的字符串,与大量重复的状态值,不能期待相同的压缩效果。
因此,我不太喜欢直接给出"换成列存快多少倍"的结论。缺少数据分布、查询条件和硬件环境,这个倍数对项目决策帮助不大。
三、落到 KES,先确认自己使用的是哪一种能力
查阅 KES 文档时,有个细节需要注意:产品能力介绍和具体操作手册,解决的是不同问题。
产品介绍说明数据库能够覆盖哪些场景;真正建表时,还要确认当前安装包提供什么功能、采用什么对象形式,以及有哪些限制。
KES V9R1C10 文档目录包含 cstore_fdw 插件条目。本文的操作示例采用可读取到完整正文的 KES V9 官方插件手册所描述的用法,不把其他数据库的列存建表参数,或其他版本的功能,直接套到当前环境。 KingbaseES - 金仓数据库
就这份手册描述的 cstore_fdw 而言,需要记住几个事实:
- 通过列存外部表组织数据,支持列投影及基于数据范围的跳过读取。
- 支持
COPY、INSERT INTO ... SELECT ...导入,以及ANALYZE统计信息收集。 - 文档明确列出了 UPDATE、DELETE、单条记录插入及字段约束方面的限制。KingbaseES产品手册
这些限制会直接影响表的用途。
还在支付、退款、修改状态的在线订单,不适合照搬成这种列存表。已经完成业务核对、按批次生成的历史报表数据,才更值得拿来验证。
这里的判断针对本文讨论的插件实现,并不是说所有列存储产品都不能更新。
四、做一个小实验,把建表和查询过程走清楚
下面准备一份已经整理好的历史订单数据,用于生成渠道销售报表。
示例应在独立测试库中执行,要求安装包提供 cstore_fdw,执行账号具有相应权限;具体加载要求以所装版本手册为准。代码按官方语法编写,但本文没有连接真实 KES 实例执行,因此不提供实测耗时。
先创建普通行存暂存表,负责保存导入前的数据:
sql
CREATE TABLE demo_sales_stage (
order_id BIGINT PRIMARY KEY,
paid_day DATE NOT NULL,
channel_code VARCHAR(20) NOT NULL,
pay_amount NUMERIC(12, 2) NOT NULL
);
准备四条样例。这里采用单独的 INSERT 写入普通暂存表,不是直接逐条写列存表。
sql
INSERT INTO demo_sales_stage
VALUES (1001, DATE '2026-09-01', 'APP', 99.90);
INSERT INTO demo_sales_stage
VALUES (1002, DATE '2026-09-02', 'APP', 50.00);
INSERT INTO demo_sales_stage
VALUES (1003, DATE '2026-09-02', 'WEB', 120.00);
INSERT INTO demo_sales_stage
VALUES (1004, DATE '2026-09-03', 'WEB', 30.00);
COMMIT;
然后创建扩展和对应的外部表服务对象。以下对象名假设尚未使用;扩展若已经安装,应复用,不需要重复创建。
sql
CREATE EXTENSION cstore_fdw;
CREATE SERVER demo_cstore_server
FOREIGN DATA WRAPPER cstore_fdw;
CREATE FOREIGN TABLE demo_sales_column (
order_id BIGINT,
paid_day DATE,
channel_code VARCHAR(20),
pay_amount NUMERIC(12, 2)
)
SERVER demo_cstore_server
OPTIONS (compression 'pglz');
CREATE SERVER 在这里用于关联外部数据包装器,并不表示必须另外部署一台远程数据库。上述外部表定义及压缩选项来自官方插件手册描述的使用方式。KingbaseES产品手册
把暂存表中的数据一次导入:
sql
INSERT INTO demo_sales_column
(order_id, paid_day, channel_code, pay_amount)
SELECT order_id, paid_day, channel_code, pay_amount
FROM demo_sales_stage;
ANALYZE demo_sales_column;
接着执行月报查询:
sql
SELECT
channel_code,
COUNT(*) AS order_count,
SUM(pay_amount) AS total_amount
FROM demo_sales_column
WHERE paid_day >= DATE '2026-09-01'
AND paid_day < DATE '2026-10-01'
GROUP BY channel_code
ORDER BY channel_code;
根据这四条样例,预期结果为:
| channel_code | order_count | total_amount |
|---|---|---|
| APP | 2 | 149.90 |
| WEB | 2 | 150.00 |
这里只验证流程与计算结果,四条数据当然不足以说明列存的性能优势。
还有一个操作细节:**导入语句不能当成幂等脚本反复执行。**重复运行会再次追加同一批数据。正式流程需要在导入端管理批次和重复数据,不能指望这个列存目标替你拦住重复订单。
五、真正难的地方,是历史数据也可能变
前面的实验容易理解,是因为输入数据已经固定。
业务里更常见的问题是:九月份的订单,十月份发生了退款;财务发现渠道归属不对,要求重算某个月报表;导入任务执行到一半断开,重跑时又不知道上一批是否完整写入。
这些问题与列存查询速度没有直接关系,却决定了方案能不能长期运行。
以月度快照为例,我更倾向于把生成过程分成几个明确步骤:

这是应用层的设计建议,不是声称插件内置了整套快照发布流程。
新批次先生成,数量、金额和关键明细核对通过之后,再让报表使用它。旧批次如何保留、何时清理,要结合访问入口和维护窗口安排。
相比直接覆盖正在使用的数据,这种做法更容易说明"当前报表来自哪个批次"。一旦业务提出异议,也有机会回查生成时间和输入范围。
如果业务要求每次退款都立即反映到报表里,就不能只给一个月底批量导入方案。可能需要单独的实时统计路径,也可能当前需求继续使用行存更合适。列存的收益不能抵消业务口径上的缺失。
六、为什么同样按日期过滤,读取量还可能不同
列存除了减少无关列的读取,还可能利用数据块的范围信息,排除明显不满足条件的部分。
官方插件手册描述了保存最大值、最小值并据此跳过无关数据的机制。KingbaseES产品手册
可以用一个简化例子理解它。
假设某个数据块的日期全部落在一月份,查询条件却是九月份,那么这块数据就有机会被排除。可如果每个块里都混着一月到十二月的记录,单靠日期范围就很难排除多少块。
这意味着,数据进入存储时的组织方式,可能影响后续查询效果。
但我不会因此直接建议"所有数据导入前都排序"。排序本身要消耗 CPU、内存和临时磁盘;报表如果主要按渠道筛选,单纯按日期组织也未必最合适。
比较实际的做法,是用同一份业务样本测试不同组织方式,分别记录导入时间和常用查询时间。若为了让一条偶尔执行的查询快一点,却让每天的导入窗口延长很多,就需要重新衡量。
类似地,列存也不意味着查询一定不会大范围扫描。没有选择性很好的过滤条件,又要统计大量历史数据时,大范围读取相关列本来就是正常工作。需要优化的是读取和计算的成本,而不是看到"扫描"就认为执行计划有问题。
七、性能测试,别拿一条 SQL 就下结论
如果准备正式评估,我会从系统里选几类实际请求,而不是只写一条最容易体现列存优势的 SQL。
| 测试内容 | 想确认的问题 |
|---|---|
| 单月按渠道汇总 | 常用报表有没有收益 |
| 半年或全年汇总 | 大范围分析表现如何 |
| 少量字段与较多字段查询 | 读取列数变化有多大影响 |
| 带关联的真实报表 | 瓶颈是否转移到连接和聚合 |
| 批量导入 | 能否在业务允许的窗口内完成 |
| 并发查询 | 单条变快后,整体资源是否仍可控 |
行存和列存比较时,应保证输入数据、统计口径和结果一致。行存一侧如果已经有合理索引,也应保留为现实基线。把未优化的行存与专门调过的列存放在一起,得出的结论很难指导上线。
缓存状态同样会影响结果。第一次查询可能主要等待磁盘,后面几次则更多命中缓存。至少应该记录首次和重复执行的表现,不要挑最快的一次作为最终成绩。
存储空间比较也要统一范围:列存实际数据文件、元数据,以及行存侧的数据和索引,分别包含了什么,应写清楚。不能因为外部表某个目录对象看起来很小,就认定数据已经被压缩到几乎不占空间。
最后还要看一眼业务接口。列存表和交易表如果位于同一个实例,仍可能共享 CPU、内存和磁盘资源。分析查询更高效,不等于它天然拥有资源隔离。
八、我会优先从哪张表开始
如果让我给一个已有业务系统做试点,我会先找这样的数据:历史跨度较长,日常主要追加或按批次重建,报表经常扫描很多记录,却只读取其中少数字段。
已核对的订单快照、历史渠道统计明细,都可以作为候选。仍在高频修改、依赖逐条更新和强约束保障的核心交易表,则先保留原有设计。
对 KES 而言,列存能力给了开发者一种处理分析负载的选择。是否采用,仍要回到查询方式、数据更新规律和维护成本上。
我希望最终拿到的验证结果,不只是"月报快了",还包括:结果与原口径一致,批次重跑不会重复计数,历史订正有处理办法,导入任务也不会挤占正常交易的资源。
这些事情都讲得通,再把列存放进正式系统,心里才有底。