
从一张订单表说起为什么有的查询快如闪电有的慢到怀疑人生做数据库选型或者调优时我经常被问到同一个问题“同样是几百万行的表为什么在MySQL里跑一个聚合统计要好几秒换到ClickHouse里几百毫秒就出结果了”答案往往不在于数据库本身的优化能力差距而是它底层存储数据的方式从根本上就不一样。这就是行式存储Row-based Storage和列式存储Column-based Storage的区别。简单来说行式存储把一整行的所有字段存在一起像写日记一样按时间顺序记录列式存储则把所有行的同一个字段存在一起像做表格时把每一列单独拎出来归档。这个看似不大的差异决定了两种存储引擎在写入效率、查询性能、压缩率、适用场景上的天壤之别。这篇文章我会从存储物理结构讲起结合我用MySQL和ClickHouse做过的实际对比测试把两种模式的工作原理、选型依据、常见误区和避坑经验一次讲透。不管你是在做数据仓库选型、业务库性能优化还是单纯想搞明白“为什么这两个数据库差别这么大”这篇都适合你。1. 两种存储架构的本质差异与设计动机1.1 行式存储一切以“整行记录”为中心先看行式存储的物理组织方式。以MySQL的InnoDB为例数据页是存储的基本单元每个数据页默认16KB。数据行按照主键顺序紧密排列在数据页中同一行的所有列——主键id、用户名、手机号、订单金额、创建时间——在磁盘上是连续存放的。假设一行订单记录占200字节一个16KB的数据页大约能存放80行。当你要查询“id5000的那一笔订单”存储引擎只需定位到包含该行的数据页把这一个页读入内存解析出那一行就返回了全部所需字段。整个过程只需一次或少数几次IO。行式存储的核心优势在于“一次IO拿到完整记录”。对于OLTP在线事务处理场景比如订单创建、用户登录、修改个人信息绝大多数操作都是先根据某个键值找到一行然后读取或更新这一行的全部字段。行式存储的组织方式与这种访问模式天然匹配——数据写下去就是一行读的时候也按一行取出来没有任何多余的数据搬移。这也解释了为什么MySQL、PostgreSQL、Oracle这些传统关系型数据库统治OLTP领域几十年。事务的原子性、隔离性都建立在“一行数据是完整原子单元”的基础上。行锁、回滚日志、双写缓冲这些机制全部围绕行的维度和数据页展开。如果把一行数据打散到不同文件里事务的实现复杂度会爆炸式上升。1.2 列式存储把“同一属性”聚合到连续空间列式存储换了一个组织维度。还是那张订单表列式存储不会把“id5000”这一行的所有字段放在一起而是把表中所有行的“订单金额”字段连续存放在一个数据块里所有行的“创建时间”字段存放在另一个数据块里以此类推。用列式存储查询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.0InnoDB行式存储和ClickHouse 24.xMergeTree引擎列式存储中创建同一张逻辑表并导入相同数据。建表语句逻辑如下两张表在字段定义上一一对应-- 逻辑表结构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万行数据MySQLInnoDB表索引占用约620MB磁盘ClickHouse默认压缩算法下仅占用约95MB。在某些高重复率列上列式存储的压缩能力堪称夸张。原因是同一个列的数据类型一致、值域集中压缩算法能取得远高于混合类型字段的压缩率。下面把测试结果整理成一张对照表方便直观查看测试维度MySQL行式存储ClickHouse列式存储主键点查热缓存1-2 ms40-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”这类问题的潜台词是想用一个数据库同时做好两件差异很大的事。现实是两者兼顾的方案往往会两头不讨好。成熟的架构设计通常采用“混搭”思路。在线业务继续跑在行式存储上保证事务的可靠性和接口的实时响应同时通过数据同步工具把需要的业务数据以准实时或T1的节奏同步到列式存储中由列式存储承担分析查询。这种“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实例扛下所有查询负载结果在线业务和分析任务互相拖累两边都做不好。后来把分析流量引导到列式存储上才真正体会到“架构清晰带来的省心”。如果你正在为数据库选型纠结我的建议很简单把核心业务查询场景列成一张清单先看清查询返回的行数、涉及的列数、频率和实时性再对照着决定用行存还是列存。这样的选型大概率不会走偏。