从一张订单表说起:为什么有的查询快如闪电,有的慢到怀疑人生
做数据库选型或者调优时,我经常被问到同一个问题:“同样是几百万行的表,为什么在MySQL里跑一个聚合统计要好几秒,换到ClickHouse里几百毫秒就出结果了?”答案往往不在于数据库本身的优化能力差距,而是它底层存储数据的方式从根本上就不一样。这就是行式存储(Row-based Storage)和列式存储(Column-based Storage)的区别。
简单来说,行式存储把一整行的所有字段存在一起,像写日记一样按时间顺序记录;列式存储则把所有行的同一个字段存在一起,像做表格时把每一列单独拎出来归档。这个看似不大的差异,决定了两种存储引擎在写入效率、查询性能、压缩率、适用场景上的天壤之别。这篇文章我会从存储物理结构讲起,结合我用MySQL和ClickHouse做过的实际对比测试,把两种模式的工作原理、选型依据、常见误区和避坑经验一次讲透。不管你是在做数据仓库选型、业务库性能优化,还是单纯想搞明白“为什么这两个数据库差别这么大”,这篇都适合你。
1. 两种存储架构的本质差异与设计动机
1.1 行式存储:一切以“整行记录”为中心
先看行式存储的物理组织方式。以MySQL的InnoDB为例,数据页是存储的基本单元,每个数据页默认16KB。数据行按照主键顺序紧密排列在数据页中,同一行的所有列——主键id、用户名、手机号、订单金额、创建时间——在磁盘上是连续存放的。
假设一行订单记录占200字节,一个16KB的数据页大约能存放80行。当你要查询“id=5000的那一笔订单”,存储引擎只需定位到包含该行的数据页,把这一个页读入内存,解析出那一行,就返回了全部所需字段。整个过程只需一次或少数几次IO。
行式存储的核心优势在于“一次IO拿到完整记录”。对于OLTP(在线事务处理)场景,比如订单创建、用户登录、修改个人信息,绝大多数操作都是先根据某个键值找到一行,然后读取或更新这一行的全部字段。行式存储的组织方式与这种访问模式天然匹配——数据写下去就是一行,读的时候也按一行取出来,没有任何多余的数据搬移。
这也解释了为什么MySQL、PostgreSQL、Oracle这些传统关系型数据库统治OLTP领域几十年。事务的原子性、隔离性都建立在“一行数据是完整原子单元”的基础上。行锁、回滚日志、双写缓冲这些机制,全部围绕行的维度和数据页展开。如果把一行数据打散到不同文件里,事务的实现复杂度会爆炸式上升。
1.2 列式存储:把“同一属性”聚合到连续空间
列式存储换了一个组织维度。还是那张订单表,列式存储不会把“id=5000”这一行的所有字段放在一起,而是把表中所有行的“订单金额”字段连续存放在一个数据块里,所有行的“创建时间”字段存放在另一个数据块里,以此类推。
用列式存储查询SELECT SUM(amount) FROM orders WHERE create_time BETWEEN '2024-01-01' AND '2024-01-31'时,存储引擎只需要读取“amount”列的数据块和“create_time”列的数据块,其他列——用户名、手机号、收货地址——碰都不用碰,完全不需要参与IO。
这种按列聚合的存储方式,并非OLTP场景的通用最优解。想象一下如果要用列式存储做“根据订单id查一笔订单的全部字段”,存储引擎必须分别去各个列的数据块中读取数据,再在内存中拼接成完整行。在极端情况下,一张20列的表就等于要发起20次独立的读取操作。这也是列式存储很少被用作业务在线库的根本原因——事务型负载对点查和更新的要求太高。
1.3 物理结构差异的直观类比
为了帮助第一次接触这两个概念的朋友理解,可以做一个生活化类比。把数据表想象成一个大型超市的仓库。
行式存储像是按“购物小票”归档——每一张小票(一行)上都完整写着顾客买了什么、花了多少钱、什么时间结账。你拿着小票编号去查其中一笔的完整信息,翻到对应那张纸就能全部看到,非常方便。但如果月底要统计“这个月总共卖了多少金额”,你就得把所有小票全部翻一遍,一张一张加起来,哪怕你只关心金额这一栏。
列式存储则像是把仓库按“商品类别”重新整理——所有小票上的“金额”抄到一个本子上,所有“商品名称”抄到另一个本子上,所有“结账时间”又抄到第三个本子上。月底统计总金额时,你只需要拿出“金额”那个本子逐页相加,其他本子完全不用动,速度和效率都大幅提升。但如果要查“某一张小票的完整明细”,你就得分别去三个本子里找到对应条目再拼起来,反而麻烦。
这个类比能解释绝大部分场景下的性能差异。接下来的对比测试,其实就是这个道理在真实数据上的量化体现。
2. 一场同数据量下的实测对比:行存与列存的性能画像
2.1 测试场景与数据准备
纯讲原理容易让人云里雾里,我直接用一个实测来展示差异。我准备了一张模拟电商订单表,包含6个字段:订单id、用户id、商品名称、订单金额、订单状态、创建时间。我用脚本生成了500万行模拟数据,分别在MySQL 8.0(InnoDB,行式存储)和ClickHouse 24.x(MergeTree引擎,列式存储)中创建同一张逻辑表,并导入相同数据。
建表语句逻辑如下,两张表在字段定义上一一对应:
-- 逻辑表结构(MySQL和ClickHouse均按此字段创建对应表) CREATE TABLE orders ( order_id BIGINT, user_id BIGINT, product_name VARCHAR(64), amount DECIMAL(10,2), status TINYINT, create_time DATETIME );数据生成规则是:用户id范围1到10万,商品名称从50个预设商品名中随机挑选,金额范围1到5000元,状态为0到3之间的整数,创建时间均匀分布在2024年全年的每一天。所有数据完全一致,确保对比公平。
2.2 点查与聚合查询的表现对比
先看OLTP最典型的场景——主键点查,也就是根据订单id查出这一条订单的完整信息:
SELECT * FROM orders WHERE order_id = 2500000;我在MySQL上执行这个查询,冷缓存下耗时约15毫秒,热缓存下耗时约1到2毫秒。在ClickHouse上执行同样的查询,冷缓存下耗时约480毫秒,热缓存下也要40到80毫秒。差距接近30倍。
这背后的原因在上面已经说过:MySQL的数据按主键聚簇存放,定位到数据页后一次IO即可取回整行;ClickHouse需要先通过主键索引定位到各个列的数据块位置,再分别读取6个列的数据才能拼出完整记录。列式存储为点查付出的额外IO和计算代价,在这个场景下完全成了负担。
再看OLAP最典型的场景——聚合统计。我查询“2024年第一季度每个订单状态的总金额”:
SELECT status, SUM(amount) AS total_amount FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2024-04-01' GROUP BY status;这次结果完全反过来。MySQL在冷缓存下执行这条语句耗时约3.7秒,ClickHouse同条件下耗时约110毫秒,差距超过30倍。MySQL需要扫描全表所有数据页,把每一行的status、amount、create_time三个字段都读出来,还要丢弃create_time不匹配的行;ClickHouse只读取它需要的三个列的数据块,在读取过程中完成过滤和聚合,其他三个列全程不参与IO。
2.3 写入性能与磁盘占用对比
除了查询,两个方向上的写入表现也值得关注。
批量写入500万行数据,MySQL使用多行INSERT语句分批提交,耗时约8分30秒;ClickHouse使用一次INSERT INTO...SELECT大batch写入,耗时约40秒。列式存储写入慢的原因在于:一条新插入的行会被拆解到多个列存文件中,每一列都要单独写一段数据,也就会产生多次IO。行式存储写入时直接把行追加到数据页末尾,一次顺序写即可完成。
我这里还需要补充一个重要背景:ClickHouse 24.x的MergeTree引擎默认设置了adaptive index granularity和后台异步合并机制,大批量写入时先以临时part形式落盘,后台再逐步合并成大part,所以如果按单条INSERT逐一写入,性能反而极差。列式存储数据库几乎都是为批量写入设计的,单条写入是它的天然弱项。
磁盘占用数据也很直观。同样的500万行数据,MySQL(InnoDB表+索引)占用约620MB磁盘;ClickHouse默认压缩算法下仅占用约95MB。在某些高重复率列上,列式存储的压缩能力堪称夸张。原因是同一个列的数据类型一致、值域集中,压缩算法能取得远高于混合类型字段的压缩率。
下面把测试结果整理成一张对照表,方便直观查看:
| 测试维度 | MySQL(行式存储) | ClickHouse(列式存储) |
|---|---|---|
| 主键点查(热缓存) | 1-2 ms | 40-80 ms |
| 主键点查(冷缓存) | 约15 ms | 约480 ms |
| 聚合统计(500万行扫描) | 约3.7 s | 约110 ms |
| 批量写入500万行 | 约8分30秒 | 约40秒 |
| 磁盘占用 | 约620 MB | 约95 MB |
| 适合负载类型 | 频繁增删改查、事务 | 海量数据只读分析 |
2.4 为什么会有这种差异:原理层面的总结
把上面的实测结果抽象成一句话:行式存储把“一行”作为IO最小单位,列式存储把“一列”作为IO最小单位。这个单位选择的差异,在点查场景让行存占尽优势,在分析场景让列存一骑绝尘。
再往深一层看,两类系统的设计动机本就不同。行式存储面向的是“高频、小事务”的在线业务——每一次操作的数据量小,但要求响应快、一致性强,它需要尽量少搬动数据。列式存储面向的是“低频、大扫描”的分析任务——每次要处理的是海量行,但只关心其中少数几列,它需要尽量只搬动必要的数据。
这两套设计哲学没有绝对的优劣之分,只有是否匹配场景的问题。我在实际做过的一个项目里,曾经试图把所有数据都塞进一个MySQL实例里做报表统计,结果统计任务一跑,线上业务接口的延迟就从50毫秒飙到2秒以上。后来把分析型查询迁移到列式存储引擎上,线上业务和分析任务各走各的通道,问题立刻消失。这个教训让我深刻意识到:选型不是选“最好的”,而是选“最合适的”。
3. 选型决策:什么场景用行存,什么场景用列存
3.1 先认清业务查询模式再选型
选型的第一步不是看数据库排行榜,而是把业务的核心查询模式梳理清楚。我先问三个问题:每条查询返回多少行?每次查询涉及多少列?查询频率和实时性要求如何?
如果一个系统的主要操作是“根据用户id查资料”“根据订单号查状态”“提交一笔新订单”,每次返回几行到几十行,整体上是典型的OLTP模式,行式存储是毫无疑问的主流选择。MySQL、PostgreSQL在这一类场景下依然是最可靠、生态最成熟的方案。
如果一个系统的主要操作是“统计全量数据某个维度上的分布”“跑大范围时间窗口的汇总报表”“分析用户行为链路”,每次要扫描上万甚至上亿行,但只读取其中少数几个字段,列式存储能带来数十倍的性能提升。ClickHouse、Apache Doris、StarRocks都是这个领域的成熟选项。
3.2 两种架构的核心特性和风险点
下面按维度拆开说两种存储架构各自的长处和短板,方便你按需取用。
行式存储的加分项很明显:单条记录的增删改查效率极高,事务能力强,数据一致性好,从开发者到运维都最熟悉。它的短板同样突出:全表扫描性能差,因为哪怕只需要一列数据,也必须读取所有列;数据压缩率有限,因为不同列的数据类型和值域差异大,压缩算法难以发挥;当单表数据量超过千万甚至亿级别时,即使走了索引,复杂统计分析也很容易拖垮在线业务库。
列式存储的加分项也清晰:分析型查询快,只读取需要的列,在大范围扫描场景下性能优势明显;压缩比高,相同数据量占用的磁盘空间通常只有行式存储的六分之一到三分之一;非常适合处理海量历史数据的离线分析、实时数仓和BI报表。它的短板也需要正视:单行点查性能差,每次查询需要读取或拼接多列数据;写入路径重,不适合高频单条insert;事务支持普遍偏弱,很多列式数据库不支持完整的事务隔离级别和行级更新。
3.3 实际选型中常见的“最优解陷阱”
我在技术交流群里经常看到类似的问题:“我们有个项目,有用户管理功能,又要做数据报表,该选MySQL还是ClickHouse?”这类问题的潜台词是:想用一个数据库同时做好两件差异很大的事。现实是,两者兼顾的方案往往会两头不讨好。
成熟的架构设计通常采用“混搭”思路。在线业务继续跑在行式存储上,保证事务的可靠性和接口的实时响应;同时通过数据同步工具把需要的业务数据以准实时或T+1的节奏同步到列式存储中,由列式存储承担分析查询。这种“HTAP”架构并不需要一套数据库同时支持所有能力,而是让行存和列存各归其位、各司其职。
具体来说:MySQL负责高并发的读写下单,通过Binlog监听或者ETL任务把数据同步到ClickHouse或StarRocks,报表系统和数据分析团队使用列式存储出各种统计指标。这样得到的架构清晰、性能可控,也便于独立扩展两边集群。踩过几次坑之后我越来越觉得,不要试图用一个方案解决所有问题,数据架构某种程度上的“冗余”反而是一种更务实的可靠性设计。
4. 深入实操:列式存储的关键机制与调优重点
4.1 列存表引擎的选择逻辑
选择列式数据库时,表引擎决定了数据如何存储、如何合并、如何处理主键,这一步很关键。以ClickHouse为例,MergeTree是基础引擎,它支持按主键排序存储、按分区组织数据、后台异步合并数据片段。实际生产中,绝大多数场景都是在MergeTree基础上扩展:需要实时去重用ReplacingMergeTree,需要按时间自动删除旧数据用TTL,需要聚合预计算用SummingMergeTree或AggregatingMergeTree。
我最初使用ClickHouse时直接用了最简单的默认配置,没有设置ORDER BY。结果查询性能很一般。后来才意识到,ORDER BY决定数据在物理文件上的排序方式,这直接影响范围查询的过滤效率。对于“按时间范围查询统计”的业务,把时间字段作为ORDER BY的第一顺位,查询性能能有质的提升。
一个比较典型的建表设置如下,假设你要做订单统计分析,按天分区,按用户ID和时间排序:
CREATE TABLE orders_analysis ( order_id UInt64, user_id UInt64, product_name String, amount Decimal(18, 2), status UInt8, create_time DateTime ) ENGINE = MergeTree PARTITION BY toYYYYMM(create_time) ORDER BY (user_id, create_time);PARTITION BY按月分区,好处是查询只扫对应月份的数据,同时便于管理历史数据;ORDER BY按user_id和create_time排序,让同一用户的订单在物理上尽量连续,按用户维度做关联分析时效果更好。
4.2 主键索引、稀疏索引与数据跳过
列式存储的“主键”和行式存储的“主键”完全是两种不同的东西。在ClickHouse里,ORDER BY指定的排序键会生成稀疏主键索引——它不像MySQL那样为每一行建立索引条目,而是每隔若干行才记录一个索引标记,用于在查询时快速跳过不可能包含目标数据的数据块。
这个机制带来的直接效果是:当查询条件能命中排序键前缀时,存储引擎可以直接跳过大量不相关的数据块,只读取可能满足条件的部分。但如果查询条件没有使用排序键前缀字段,列式存储就可能退化为全表扫描,性能会明显下降。
举个实操中的例子。我维护的一张用户行为分析表,ORDER BY是(event_time, user_id)。业务方后来反馈“按user_id查行为记录特别慢”,我想了一下原因就明白了:user_id排在排序键第二位,单独用user_id做过滤条件无法有效利用稀疏索引定位,需要扫描该时间范围内的全部数据块。解决办法有两个方向:一是把user_id调整到排序键第一位,代价是时间维度的范围查询性能受损;二是增加一个物化索引或者使用跳数索引来辅助按user_id查找。
这就是列式存储调优中常见的选择题:排序键的字段顺序直接决定了哪些查询能走“快路径”,优先保障最核心、最高频的那类查询,是排序键设计的第一原则。
4.3 压缩算法选择与底层原理
列式存储的高压缩率主要来自两个方向:一是同一列的数据类型一致,值域集中,重复率高;二是列式存储把大量相邻行的同一字段连续放置,压缩算法可以利用局部性特征获得更好的压缩比。
ClickHouse支持多种压缩算法,常用的有LZ4和ZSTD。LZ4压缩速度快,压缩率相对低;ZSTD压缩率更高,但压缩和解压都要消耗更多CPU。实际项目里,我通常对需要高频查询的热数据列用LZ4,对归档类冷数据列用ZSTD,兼顾查询速度和存储成本。
举一个实际压缩效果的例子。在某用户行为表里,用户的设备类型列只有“iOS”“Android”“Web”三种值,500万行的该列数据在行式存储下约15MB,在列式存储配合ZSTD压缩后不到1MB。相同的数据量,仅仅因为组织方式不同,存储成本差距就能达到10倍以上。在大数据规模下,这种压缩比差异对应的就是实打实的服务器成本和运维成本。
5. 常见问题与排查技巧实录
5.1 列存数据库写入慢怎么解决
不少人第一次把业务数据从MySQL同步到ClickHouse时,都会遇到写入慢的问题。如果逐条执行INSERT语句插入,ClickHouse的写入表现确实让人失望——这取决于MergeTree的工作机制。每一次INSERT都会生成一个新的数据片段(data part),后台线程负责将这些片段异步合并成更大的片段。频繁的小批量写入会导致片段数量爆炸,合并任务永远跟不上生成速度,查询性能也会因为片段过多而下降。
解决思路很明确,就是合并写入请求。数据同步时不一条条insert,而是攒一批数据(例如每5万行或每100MB)用一个INSERT语句批量写入。实际项目中我还见过用DataX或Flink的批量写入插件做同步的,效果比手写逐条INSERT稳定得多。
5.2 列存表为什么有些查询还是慢
另一个常见问题:明明用了列式存储,也建了排序键,为什么统计查询还是不快?我排查过不少类似问题,大部分情况出在查询条件没有命中排序键前缀,或者SELECT写了不必要的通配符列。
SELECT *在列式存储里是很不划算的操作。列式存储的优势在于“需要几列就只读几列”,但一旦用了SELECT *,所有列都会被读取,优势自然荡然无存。另一个常见问题是WHERE条件中的字段和ORDER BY顺序不匹配,导致稀疏索引无法发挥作用。排查时先看执行计划,确认数据扫描范围是否被有效裁剪,再确认没有多余列的读取,问题往往就浮出水面了。
5.3 行存和列存能否互相替代
直接回答:不能。行式存储和列式存储解决的是不同类型的问题,它们不是同一赛道的竞品,而是互补的两种工具。把在线交易库改成列式存储,点查和更新的性能无法接受;把数仓改成行式存储,全表聚合扫描的成本也无法接受。正确的思路是让它们各司其职,通过数据同步协同工作。
下面把两类存储的常见问题整理成速查卡,方便工作中直接参考:
| 问题现象 | 高概率原因 | 处理建议 |
|---|---|---|
| 列存库点查性能远差于预期 | 列式存储本身不适合高频点查 | 在线点查保留在行存库,列存只承担分析查询 |
| 列存库写入慢 | 单条/极小批量INSERT产生过多数据片段 | 攒批写入,单次INSERT数据量尽量大 |
| 列存库分析查询依旧慢 | WHERE条件未命中排序键前缀或查询了多余列 | 调整排序键字段顺序,避免SELECT * |
| 行存库跑大报表拖垮在线业务 | 分析型查询与在线事务共用同一数据库 | 增加列式存储做分析,用同步工具分流负载 |
| 行存库磁盘占用过大 | 混合类型列压缩率差,索引膨胀 | 冷数据归档到列存,或使用压缩页特性 |
6. 混合架构与未来演进方向
6.1 行列混合存储:一个折中方案的典型代表
既然行存和列存各有不可替代的优势,那么把它们放进同一个数据库里,由系统根据负载类型自主选择存储格式,就成了很自然的设计方向。目前市面上的一些数据库已经支持在同一张表中同时维护行式存储和列式存储两种形态的数据,例如TiFlash、PolarDB的某些版本、以及部分HTAP数据库。这类方案在写入时同时写入行列两种格式,查询时优化器根据SQL特征自动选择走哪条路径。
这类混合架构适合的场景是“既有高频交易又有实时分析”的中小团队——单独维护两套系统成本过高,行列混合数据库可以收敛到一套系统里。但代价也明显:存储成本翻倍,写入耗时增加,系统复杂度更高。我在实际项目中见过选型了行列混合数据库的团队,最终因为存储成本膨胀和调优复杂度问题,又改回了行存+列存分离的架构。这类方案是否适合,建议先做POC,用业务真实数据和真实查询做验证。
6.2 计算下推与向量化执行带来的变化
列式存储在物理组织上的优势,为执行引擎的优化提供了更大的空间。正因为同列数据连续存储,CPU可以批量加载大量同类型数据执行同一种运算,这种模式被称作向量化执行。配合SIMD指令集,现代列式数据库在大范围扫描和聚合场景下能做到每核每秒处理数亿行数据。
把计算逻辑下推到存储层也是目前的主流做法——在读取数据的过程中同步完成过滤、聚合、表达式计算,而不是把原始数据全部搬到内存后计算。比如ClickHouse的“聚合下推”和“延迟物化”技术,本质上都是为了尽量减少数据传输量。对开发者而言,理解这些机制的意义在于:写查询时尽量利用列存数据库擅长的大批量、少列扫描模式,避免写出迫使存储引擎逐行处理或大量随机读的SQL。
6.3 选型时还应考虑的日常运维问题
最后提一个容易被忽视但很重要的维度:运维成本。行式存储数据库有非常成熟的监控、备份、迁移工具和社区经验,遇到问题能很快找到解决方案。列式存储数据库生态相对年轻,很多问题需要自己钻研源码或翻阅官方文档。
结合我的个人经验,给正在做技术选型的朋友一个实操建议:先拿真实业务数据在候选数据库上做一轮对比测试,测试SQL要包含你实际的业务查询,而不是用标准的benchmark工具跑分。标准benchmark测的是数据库的极限能力,你真正需要验证的是它在你的数据分布、查询模式下的真实表现。两个看起来文档和口碑都不错的数据库,在同一个业务场景下可能相差数倍到数十倍,只有自己试过才靠谱。
存储引擎选型这件事,说到底就是拿“你最重要的查询模式”去匹配“存储引擎最擅长的工作方式”。行式存储把整行捆在一起,保证的是事务型业务的高效和稳定;列式存储把同列数据聚合,赢的是分析型查询的速度和压缩率。没有哪个更先进,只有哪个更合适。从实际观测来看,真正稳妥的架构往往不是二选一,而是让行存和列存各归其位,通过数据同步把链路串起来——业务系统稳定跑在行存上,分析报表由列存来扛。
我自己踩过最大的坑,就是早年间试图用一个MySQL实例扛下所有查询负载,结果在线业务和分析任务互相拖累,两边都做不好。后来把分析流量引导到列式存储上,才真正体会到“架构清晰带来的省心”。如果你正在为数据库选型纠结,我的建议很简单:把核心业务查询场景列成一张清单,先看清查询返回的行数、涉及的列数、频率和实时性,再对照着决定用行存还是列存。这样的选型,大概率不会走偏。