在关系型数据库内部,数据落盘方式直接决定了查询引擎该如何取数。SQL行存储与列存储并不是简单的“换个格式”,而是从页分配、缓存命中到执行算子的全链路差异。理解这两种存储模型的底层机制,才能在建表时选对结构,避免线上分析语句把磁盘IO打满。

物理布局与读写单元的本质差异
行存储(row store)以行为最小连续单元,一条记录的所有字段值按顺序写入同一个数据页。以传统堆表或B+树叶子节点为例,页内先放第一行的id、name、age、city,紧接着第二行同字段。这种布局让“根据主键拿整行”非常高效,因为一次页读就能拿到全部列,非常适合OLTP里的增删改和点查。事务提交时,整行写入回滚段与日志,一致性控制也以行为粒度。
列存储(column store)则把每个字段单独成组,同一列的所有行值连续存放。比如先存完全部行的id,再存全部行的name。这样当查询只关心age大于30的人数时,引擎只需读取age这一串连续块,完全跳过name、city等无关列。由于同列数据类型一致,压缩算法(字典、游程、位图)能获得极高比率,进一步减少物理读。下面用伪代码展示两种格式在内存中的抽象表达:
-- 行存储逻辑视图 CREATE TABLE user_row ( id INT, name VARCHAR(50), age INT, city VARCHAR(50) ) ENGINE=ROW_STORE; -- 列存储逻辑视图(以列式引擎为例) CREATE TABLE user_col ( id INT, name VARCHAR(50), age INT, city VARCHAR(50) ) ENGINE=COLUMN_STORE;
从缓存角度看,行存储的缓冲池常因宽表而迅速被整行占满,分析查询扫描大表时会频繁淘汰页;列存储因每次只加载需要的列,缓冲命中率在分析负载下明显更高。这也是为什么混合负载数据库会引入行列混存或列存副本。
查询执行与谓词下推的不同路径
在执行SELECT city, COUNT(*) FROM t WHERE age > 30这类语句时,行存储引擎通常先按页扫描,逐行取出age做判断,再提取city。即便有索引,若过滤后还需投影多列,随机回表成本不可忽视。谓词下推在行存中更多依赖索引结构,而非存储格式本身。
列存储天然支持“列裁剪”与“谓词下推到存储层”。引擎在打开段文件时已知各列的上下界(min/max),可跳过整块不满足age范围的页;同列连续布局也让比较操作能批量进行。以下示例展示列存过滤时的批量判断逻辑:
// 列存块级跳过示例
for (auto& block : age_column.blocks) {
if (block.max < 30) continue; // 整块跳过
for (int i = 0; i < block.count; i++) {
if (block.values[i] > 30) {
result_city.append(city_column.at(block.offset + i));
}
}
}
此外,列存储更容易配合向量化执行器,一次处理上千个同列值,减少函数调用与分支预测失败。行存储若要做向量化,需先拆行再拼列,开销更大。因此在聚合、扫描密集型报表中,列存的执行效率优势来自“少读+批量算”的双重叠加。
事务能力与适用场景的边界
行存储在MVCC、行级锁、高频更新上积累数十年工程优化。更新某行某列时,引擎定位到页内偏移直接改写或追加版本,事务隔离实现直观。列存储因同列跨行集中,单行更新会牵动多个列文件,通常采取“增量行存+后台合并”或只允许批量载入,因而不适合作为核心交易表。
实际选型应看负载轮廓:订单、账户、会话等需要低延迟读写与强一致的场景,继续使用行存;用户行为、日志、BI大宽表等以扫描和聚合为主、写入多为追加的场景,采用列存可获得数量级性能提升。很多系统提供行列双写或HTAP架构,在写侧保留行存事务性,在读侧同步列存副本服务分析流量。
-- 典型HTAP:行存写,列存读 INSERT INTO orders_row VALUES (1, 'pay', 99); -- 异步同步到列存视图 SELECT region, SUM(amount) FROM orders_col WHERE ts > '2023-01-01' GROUP BY region;
归根结底,SQL行存储与列存储的根本区别不在语法层,而在“数据怎样落在磁盘”以及“引擎怎样取它”。认清这点,才能在表设计阶段就规避把分析查询压在行存结构上的典型误区。
row_storecolumn_storeSQL_engine修改时间:2026-08-15 17:12:35