行存与列存的底层博弈:从MySQL慢查询到ClickHouse的40倍加速
2026/9/14 4:21:16 网站建设 项目流程

上周值班的时候,我收到一条数据库告警:一个统计报表的SQL在MySQL上跑了8秒多。查询本身很简单,就是按月份统计订单表的销售额和订单量,表也就1亿行出头。我把同样的SQL丢到一套ClickHouse环境里跑了一下,0.2秒出结果,40倍差距。不是我调了什么参数,也不是写SQL的人水平差,而是两张表在磁盘上摆数据的方式,从根上就不一样。这就是行存和列存的区别。这篇文章我想把这个话题彻底聊透,从底层存储机制到真实的选型踩坑,帮你在数据落盘之前就想清楚该用哪种存储。

1. 一次线上事故给行存和列存之争开了个头

1.1 那条让我从工位上蹦起来的SQL

先说那个具体的SQL。它长这样:

SELECT DATE_FORMAT(order_date, '%Y-%m') AS ym, COUNT(*) AS order_cnt, SUM(amount) AS total_amount FROM orders WHERE order_date >= '2024-01-01' AND order_date < '2025-01-01' GROUP BY DATE_FORMAT(order_date, '%Y-%m');

在MySQL里,orders表虽然有order_date字段,但当时没有建对应的二级索引,优化器选择了全表扫描。1亿行数据全部读出来,每一行的所有字段都要从磁盘搬进内存,然后只取其中三个字段参与计算。这个过程中,大约90%的I/O都浪费在了根本用不到的字段上。

同样的逻辑在列存数据库里,引擎只需要读取order_date和amount这两列对应的数据块,其他100多个字段连碰都不碰。I/O量差了十几倍,再叠加后面要说的压缩和向量化执行,20倍以上的性能差距就这么来的。

1.2 别急着换数据库,先看数据在磁盘上怎么摆

大多数人遇到SQL慢,第一反应是加索引、调参数、换机器,很少有人会往存储结构层面想。但存储结构恰恰是决定OLTP和OLAP性能分水岭的根本原因。

行存和列存,说的不是数据库产品本身的区别,而是数据在磁盘上的物理组织方式。一张表的同一批数据,按行连续摆放就是行存,按列连续摆放就是列存。这个选择直接决定了后续所有的I/O行为、压缩效果、执行引擎的设计思路,甚至决定了什么样的SQL在你的系统里会跑得快、什么样的SQL注定跑得慢。

理解了这个底层差异,你再看各类数据库产品的选型、架构设计、性能调优方案,很多之前觉得玄学的东西都会豁然开朗。

2. 行存的底牌:页、B+树和"一次I/O取整行"

2.1 InnoDB把数据一页页往前摆的原因

行存最典型的代表就是MySQL InnoDB。InnoDB把数据按照B+树组织,叶子节点以页为基本单位,默认16KB一页。每一页里连续存放着多条完整的行记录,一条记录的所有字段都堆在一起。

你可以把InnoDB的数据文件想象成一本写满用户资料的通讯录,每一页A4纸对应一个数据页,每行是一个人的完整档案(姓名、电话、地址、备注全在一起)。你要查某个人的完整资料,翻到对应页就能拿到全部信息。

这套设计的核心逻辑是迎合OLTP场景。交易系统里绝大多数的查询都是通过主键或唯一索引精确定位某一行,比如查一个用户的余额、查一笔订单的详情。这时候数据既然连续摆在同一页,一次逻辑I/O就能把整行数据全部拿到,既不用跳来跳去地拼装,也不用担心缺胳膊少腿。

2.2 点查快、聚合慢的根因在哪里

InnoDB的行存设计在聚合分析场景下问题就来了。假设你那张orders表有150个字段,但报表只统计amount和order_date两个字段。进行全表聚合时,InnoDB必须把1亿行的完整记录从磁盘读出来,每一行都可能超过1KB,150个字段一个不落全部进内存,然后才能把需要的两个字段抠出来计算。

这就像你去图书馆查一本1000页的书里出现了多少次"数据分析"这个词,你不可能只翻某几页,因为这个词可能出现在任何一页。你只能一页页翻完整本书。

这就是行存放在分析型查询上的致命伤:I/O放大。数据记录越宽,被浪费的I/O比例就越高。一张150字段的表做2字段聚合,实际有用的I/O只有约1.5%,剩下98.5%的磁盘IO都在搬运垃圾数据。数据库再快,也扛不住这样的资源浪费。

2.3 二级索引背后藏着的随机I/O

行存的另一个隐藏痛点在于二级索引和回表。MySQL里,除了主键索引之外的所有二级索引,叶子节点存的是主键值。如果你通过一个普通索引去查几百万条记录,比如SELECT * FROM orders WHERE merchant_id = 'M10086',执行过程是先在二级索引树里找到一大批主键ID,再用这批主键ID回到主键索引去逐行取数据。

在同一个数据页里取数据还好,一旦这些主键对应的记录散落在不同的页,随机I/O就在所难免。机械硬盘时代这是灾难,SSD时代虽然好一些,但离散读取相比连续读取仍然有数倍的性能差距。这也解释了为什么很多人用LIMIT 10点查还行,一旦把LIMIT去掉全量查就等着超时。

行存擅长的是"拿到一个点,读取整行数据",本质上是按主键驱动的存取模型。

3. 列存的爆发:压缩、延迟物化和向量化执行

3.1 同类型数据放到一起,压缩算法立刻开挂

列存的核心思路是把同一列的所有值连续存放,ClickHouse的MergeTree、Apache Parquet/ORC文件格式、Doris/StarRocks的列式存储,走的都是这个路线。这个看似简单的调整,带来了连锁反应。

第一层好处是压缩率。同一列的数据类型一致、取值范围往往高度重复,非常利于压缩算法发挥。拿订单状态字段举例,全表可能只有"待支付""已支付""已发货""已完成""已取消"五种值,字典编码之后每个值只需要一个极短的整数编号,再配合LZ4或ZSTD做一次熵编码,这一列的存储开销可以缩小到原来的几十分之一。

日期字段可以用Delta编码,存储相对差值而不是完整时间戳;数值型字段可以用变长整数编码,小数值占用更少的字节。我见过一张线上100GB的MySQL表,用列存格式落地后只占约14GB,压缩比超过7倍。存储成本下降的同时,I/O开销也跟着下降,因为从磁盘读到内存的物理数据量大幅减少。

需要说明的是,高基数列(比如UUID、手机号这类几乎没有重复值的字段)压缩率并不高,这是列存的天然短板,后面单独聊。

3.2 只读必要的列:延迟物化的巧妙设计

列存带来的第二层好处是查询列裁剪。分析SQL通常只关心少数几个字段,列存引擎可以直接从磁盘上只读取这几列对应的数据块,完全跳过无关列。

这里有个概念叫延迟物化(Late Materialization),意思是查询过程中先不急着把整行数据组装出来,而是只对涉及条件过滤和聚合的列做计算,等到最后输出结果时才按行号把需要的列拼起来。对于SELECT COUNT(*), SUM(amount) FROM orders WHERE status = '已支付'这种SQL,列存引擎只需要读status和amount两列,可能只占表总数据量的2%。数据扫描量降低一两个数量级,性能想不提升都难。

延迟物化还有个附带好处是CPU缓存的利用率。同一列的数据在内存里也是连续存放的,遍历一个纯数值数组比遍历一堆包含变长字符串的结构体要快得多,CPU cache命中率高,cache miss少,数据处理效率自然高。

3.3 Zone Map与排序键,让数据块自己说话

列存引擎几乎都不约而同地实现了数据块级别的元数据统计,这个机制有很多名字:ClickHouse叫Zone Map,Parquet叫Page Index,Doris/StarRocks也有类似的Min/Max索引。原理很简单:每个数据块在生成的时候,记录下块内每一列的最小值和最大值。

执行查询时,引擎直接拿着查询条件去和块的Min/Max做比对,如果一个数据块的最小值和最大值都完全不落在查询范围内,整个块就直接跳过。配合Partition分区裁剪使用,效果更显著。我前面那个按月统计的SQL,在ClickHouse里如果表按月份做了分区,引擎可以直接裁剪掉11/12的数据块,实际扫描量只有1/12。

排序键在这里起到放大器的作用。如果表按照order_date排序存放,那么每个数据块内order_date的范围会非常集中,Min/Max区间很窄,Zone Map的过滤精度大大提高。反之,如果一张表完全乱序存放,同一个数据块里可能塞进了各个时间范围的数据,Min/Max区间几乎覆盖整个数据集,Zone Map就无法生效。

3.4 向量化执行:把CPU的全部马力用起来

光减少I/O还是不够的,列存引擎通常还配套向量化执行引擎。这个设计的内在逻辑是:既然同一列的数据在内存里已经是连续的、同类型的数组,那就没有必要一行一行地去解释执行。

向量化执行简单说就是批处理,一次处理一整批数据(比如1024行),循环内部没有任何条件分支,直接对连续内存区域做批量运算。现代CPU普遍支持SIMD指令集,可以在一个时钟周期内对多条数据执行同一种操作。这种"数据并行×指令并行"的组合,让列存引擎在聚合计算上的吞吐量远超传统逐行解释执行的方案。

行存数据库的执行引擎如果想做向量化,首先得把打散在内存各处的行数据重新整理成列式布局,这就产生了额外的物化开销。所以在行存库里,向量化执行往往是半吊子状态,很难发挥出全部威力。

4. 列存不是万能的:一个真实的点查打脸现场

4.1 为什么列存做点查会"卡脖子"

看到这里你可能会觉得列存简直天下无敌,我当初也是这么想的,直到我把一个用户中心的部分接口切到列存库,点查直接崩了。

当时我拿ClickHouse存用户基础信息,然后按用户ID查单个用户的手机号和昵称,SELECT mobile, nickname FROM users WHERE user_id = 'U10001'。结果单个查询耗时30到50毫秒,虽然看起来不多,但对比MySQL同样条件1到2毫秒的耗时,50倍差距。

根源还是存储布局。users表如果按列存,user_id、mobile、nickname分别存放在不同的文件或数据块里。点查一条记录时,引擎需要把三个列文件都要打开,各自定位再读取一段数据,然后按行号拼回一条完整记录。这中间涉及多路I/O和多个元数据定位,开销远大于行存的一次页读取。

列存数据库的元数据管理也专门为Scan设计,数据块级别的索引粒度远粗于主键索引。在B+树上你可以通过几层树高精确定位到某一条记录,在列存里你可能要先扫一批数据块才能确定目标记录在哪。结果是:高频点查场景,列存数据库被行存数据库按在地上摩擦。

4.2 高频更新在列存里是一场灾难

列存的另一个硬伤是随机更新。因为同一列的数据物理连续存放,一条UPDATE如果改动了一个字段,往往意味着要重写对应的列数据块,甚至整行涉及的多个列块。行存在页内就地修改一下就完事,列存却无法这样处理,几乎所有的列存引擎都是面向追加写设计的。

列存引擎普遍采用LSM或类LSM的结构来处理写入,更新的做法是"先标记删除,再追加新版本"。如果业务是高频、小范围的更新操作,后台会疯狂触发compaction任务,把大量已经写入的数据重新排序合并。一段时间下来,磁盘I/O全被合并任务吃掉了,查询性能也跟着波动。

所以列存引擎的写入最佳姿势是批量追加,比如每隔几分钟攒一批Insert,一次插入几万行。如果你让它一条一条地insert,每秒几千次,用不了多久整个集群就开始报警了。

4.3 行存MySQL/PostgreSQL至今没被替代的真正原因

行存数据库到今天仍然是绝对主流的OLTP基础设施,靠的就是它在事务处理上的自洽性。MySQL InnoDB支持完整的事务ACID特性、行级锁、MVCC多版本并发控制,可以在高并发下保证数据一致性。列存引擎在这方面整体薄弱,很多列存产品要么不支持跨行事务,要么只支持弱一致性的副本同步。

举例来说,一个电商下单流程里有扣库存、生成订单、更新账户余额三个动作,这三个动作必须在一个事务里原子完成,任何一个失败都要全部回滚。行存数据库可以通过SHOW ENGINE INNODB STATUS看到完整的信息流,列存引擎要做到这一步还非常费劲。在数据一致性、事务边界、外键约束这些硬指标上,行存仍然是不可替代的。

另外一个原因是生态成熟度。MySQL和PostgreSQL拥有几十年的工具链、运维经验、人才储备,身边随便找一个后端工程师就能写好SQL,但能熟练调优列存数仓的人明显更少。技术的选择从来不只看性能,还要看团队和生态的匹配度。

5. 实际架构选型:交易、分析、HTAP各自怎么落

5.1 交易系统:老老实实用行存

如果你的系统承担的是用户登录、订单增删改、支付流水、余额变更这类交互式事务,选型不用纠结,行存MySQL或PostgreSQL依然是第一选择。这类场景的特点是:查询走主键或唯一索引,写入频繁但单次数据量小,强依赖事务和行级锁,数据一致性要求极高。

需要提醒的是,交易系统要把分析性查询隔离出去。不要让运营人员直接在主库上跑大聚合SQL,哪怕用的是行存里最强悍的机器也扛不住。我见过太多案例,主库CPU打满的元凶不是真实业务流量,而是一个忘了加条件的后台报表任务。常规做法是给主库挂一个从库,把分析型只读请求挪到从库上;如果分析需求更重,就干脆上列存数仓。

5.2 分析报表:果断上列存数仓

对于数据量在千万级以上的分析场景,比如用户行为分析漏斗、订单销售周报、广告投放效果统计、经营大盘看板,列存数据仓库是目前性价比最高的选择。ClickHouse、Doris、StarRocks在这一梯队里都表现优秀。

用列存数仓之前,有两点功课必须做:第一,按业务日期等高频过滤字段设计好分区策略,让分区裁剪尽可能地发挥作用;第二,合理设置排序键,让Zone Map能高效过滤数据块。这两点直接决定了你的列存库的真实性价比,做得好的话查询性能能有量级提升。

5.3 MySQL+列存数仓:目前性价比最高的黄金组合

当前生产环境里最常见的数据架构,既不是纯行存也不是纯列存,而是"MySQL负责在线事务,列存数仓负责离线分析"的组合。这套架构的实现链路已经很成熟:业务库通过Binlog日志解析(Canal、Flink CDC),或者定时批量同步工具,把数据同步到ClickHouse/Doris。

这套方案最核心的优势是隔离。在线业务和离线报表各用各的引擎,互不干扰。主库的索引和存储结构只为点查和写入服务,不需要为分析查询做妥协。分析侧的数据模型也独立设计,可以提前做预聚合、物化视图。

踩过的几个坑顺便说一下。同步延迟需要监控,Flink CDC处理DDL变更时可能中断,字段类型映射容易出问题,MySQL的datetime与ClickHouse的字段类型对应关系必须提前设计好。另外,删除操作同步到列存库是个难点,很多团队只同步新增和更新,历史数据删除靠定期全量对账,方案要提前规划和验证。

5.4 HTAP行列混合,一份数据两副面孔

如果你不想维护两条数据链路,又希望同时兼顾事务和分析,可以考虑HTAP架构。典型的代表是TiDB的TiFlash、PolarDB等产品。这类架构的核心思想是:一份逻辑数据,同时维护行存和列存两个物理副本,行存副本服务点查和写入,列存副本服务分析查询,后台通过Raft等日志复制协议自动同步。

TiDB的具体实现方式是TiFlash通过Raft Learner协议订阅数据变更,异步地在本地刷新列存副本。业务不需要感知副本机制,一条SQL来了之后,优化器根据查询特征自动选择走行存还是列存。一致性和新鲜度上,列存副本通常会有秒级以内的延迟,不是强一致的。

HTAP的取舍点在于:它不是免费的,列存副本需要额外的存储资源;也不是所有行存产品都能提供HTAP能力,迁移成本要评估。但如果你的业务已经在用TiDB这类天然支持行列混合的数据库,分析查询尽量交给列存副本,效果会很明显。

6. 设计你的表结构之前,先问自己三个问题

6.1 你到底是"取一行"还是"扫一列"

我判断一张表应该用行存还是列存,第一个问题永远是:核心查询是按唯一键提取一行记录,还是按条件扫描大量数据做聚合。

按用户ID查个人资料、按订单号查订单详情、按设备ID查设备状态,这类"取一行"的场景应该选行存。你只需要快速导航到一个数据点,行存的B+树索引天生就是干这个的。

按月统计销量、按省份统计用户数、按渠道统计转化率,这类"扫一列"的场景应该选列存。你在意的不是某一行,而是一系列记录在某个维度上的规律。列存能只扫描相关列并配合高级压缩,让分析响应时间从秒级降到毫秒级。

6.2 你的写入是"一条条改"还是"批量灌"

第二个问题是写请求的形态。在线交易、用户操作类应用,数据是源源不断的单条写入,每条记录之间没有批量关系。这种模式适合行存,因为页内插入、就地修改、事务回滚都已经非常成熟稳定。

数据分析、日志采集、物联网上报类场景,数据是持续不断、批量攒积的,比如每隔几分钟收集一批日志,或者定期从上游同步一批订单。这时用列存就非常合适,大批量追加写入是列存引擎最喜欢的节奏。

如果你的业务恰好在两个极端之间摇摆,比如平时单条写入,但每天又有大批量分析任务,我更建议走"行存主库+列存数仓"两条腿走路的架构,而不是逼迫单套引擎同时满足两种模式。

6.3 你的数据是长表还是宽表

第三个问题从数据形态看。一张200个字段的宽表,做统计分析时往往只需要其中几列,行存会读取大量冗余数据,列存的列裁剪能力则能大放异彩。相反,如果一张表只有五六个字段,行存和列存的差距就会被显著缩小,因为无论哪种存储,需要搬到内存的物理数据量并没有数量级差别。

数据模型在事务和分析之间的倾向性,同样是一个判断维度。高度规范化、多表Join关系复杂、有事务边界和数据一致性要求的数据模型,天然适合行存;而宽表、冗余字段多、以明细数据探索和聚合分析为主的数据模型,更适合列存。

考虑到现在越来越普遍的HTAP能力,我的建议是:不要在没有明确场景的情况下去尝试设计一套数据模型同时兼容两种存储。先把核心场景识别清楚,你自然知道该把数据放在哪种存储,或者如何设计两套数据模型分别服务不同的需求。

写在最后的经验之谈

这几年做数据架构选型下来,我越来越觉得数据库没有高低之分,只有合不合适。行存把"事务、一致性、可靠性"做到了极致,列存把"扫描、压缩、分析计算"做到了极致,两者各守一摊,谁也替代不了谁。真正值得反复推敲的,是在业务设计阶段就明确自己的查询模式、写入模式和数据形态,把这些前置问题想清楚,远比事后给慢查询加索引要有效得多。

如果你现在正被一个"怎么加索引都不快"的慢查询困扰,我建议你先别急着在引擎层折腾,先去打印一下执行计划,看看它到底是在等I/O还是在等CPU,再看看它读取了大量不需要的字段。这些信号会告诉你,瓶颈是不是出在存储结构上。当你理解到这一层,你就能真正像一个有经验的数据工程师一样,在表结构还没有落地之前,就预见到它的性能走向。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询