1. 压缩整体设计思路拆解
1.1 为什么OLAP数据库非要死磕压缩
先直接说结论:Doris这类OLAP数据库,压缩不是锦上添花,而是命根子。你在Doris里跑查询,本质上是把数据从磁盘或者内存里捞出来算,IO开销往往是最大的瓶颈。数据压缩了,磁盘占用变小、扫描的IO变少、缓存能装下更多数据,链路每个环节都受益。
但如果单纯为了压得狠,选一个最高压缩比的通用算法(比如gzip的极限模式),那查询时解压开销又会拖后腿。所以Doris的压缩设计,核心矛盾就两个:压缩率要够高,解压速度要够快。而且这两个指标还得根据你的实际使用场景做取舍,没有一套配置能通吃所有业务。
有朋友可能问,Doris底层不是有列存吗?列存天然就比行存好压,但还不够。列存只是让同一列的数据聚集在一起,提供了压缩的“素材”,真正压得狠不狠,还要靠列存基础上的编码算法——比如前缀编码、字典编码、ZigZag这些。Doris的压缩体系是“列编码 + 块压缩”两层结构,编码解决的是数据本身的信息冗余,块压缩解决的是字节流层面的符号冗余。
1.2 编码与压缩的双层架构
我画了个很朴素的逻辑链帮大家理解:原始列数据 → 列编码(Prefix/Dict/Bitmap/ZigZag) → 压缩算法(LZ4/ZSTD/Snappy) → 落盘。
第一层的列编码,只存在于列存表里,它根据列的数据分布特点,把整数、字符串、日期这类常见类型做一次“预压缩”。比如整数列里有大量0,那就用BitMap或者RLE(游程编码)思路处理;字符串列有大量共享前缀,就用前缀编码。
第二层的块压缩,类似通用压缩工具做的事,对编码后的字节流再压一遍。Doris默认采用LZ4,兼顾速度和压缩率。如果你的场景里磁盘比较紧张,可以切ZSTD,压缩率明显更高,代价是CPU开销上浮。
这两层必须配合使用。如果只做块压缩,很多列级冗余(比如相同前缀、字段取值集合很小)是压不下去的;如果只做列编码,原始数据里的字节级重复还得不到清理。理解这一点,后面优化才有方向。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心编码与压缩算法实操解析
2.1 每一种编码算法的适用场景与原理
Prefix Encoding(前缀编码)
大概思路是:把数据按字典序排列后,同一数据块内相邻行的公共前缀只存一份,后面的行只记录差异部分。通俗讲,像你存一批用户地址,全国几十个省份开头的地址一堆一模一样的“省市区”,按前缀编码之后,前面相同的部分不用反复写。
这种编码对字符串有序列非常有效,尤其是排序键的前缀列。Doris对STRING类型做了优化,如果你把高基数字符串放在ORDER BY前列,前缀编码效果一般;但低基数且排序良好的列,效果立竿见影。
Dict Encoding(字典编码)
当一个字符串列的取值种类不多(比如状态字段只有success、failed、pending)时,字典编码会把这些枚举值建成一个字典,每行只存一个整数编号。相当于把字符串比较变成了整数比较,压缩和查询速度都受益。
实现时Doris会在数据块内部构建局部字典,不一定需要全局唯一。这就引出一个小技巧:如果你的枚举值很多,但每个数据块内部的取值分布很集中,字典编码依然有不错的效果,因为它按块建字典。
BitMap Encoding与ZigZag Encoding
这两个常配合使用,对整数列特别友好。BitMap适合枚举值少、重复多的列;ZigZag则把有符号整数映射为无符号整数,使得正负数交替出现的数据也能压缩,适合存放差值、增量这类数据。比如你用BIGINT存时间戳差值,负数很多,ZigZag会先把数据转换成更容易压的形式。
2.2 通用压缩算法选型:LZ4、ZSTD 与 Snappy的取舍
Doris块压缩层的可选算法有LZ4、LZ4F、ZSTD、Snappy这几种。默认是LZ4,大多数场景下不用改。但不同场景需要不同偏好:
| 压缩算法 | 压缩率 | 解压速度 | CPU开销 | 适用场景 |
|---|---|---|---|---|
| LZ4 | 中 | 极快 | 低 | 默认选项,查询密集场景 |
| LZ4F | 中 | 快 | 低 | 兼容LZ4框架格式,跨系统需要 |
| ZSTD | 高 | 较快 | 中 | 冷数据、磁盘空间紧张、IO敏感 |
| Snappy | 中低 | 快 | 低 | 与Hadoop生态混用、导入导出兼容 |
压缩算法的切换不需要改表结构。你可以在建表时指定,也可以修改表属性dynamic。例如:
sql复制ALTER TABLE your_table SET ("compression" = "zstd");
但改完之后,新导入的数据会使用新压缩方式,Compaction产生的历史数据也会逐步被重写。这期间你可能会看到压缩率变化不是立即生效,是正常的。
2.3 压缩等级那条“隐藏的旋钮”
如果你用ZSTD,Doris支持配置压缩等级。ZSTD等级越高压缩率越高,但CPU开销也明显上涨。实测下来,常规业务用ZSTD的级别3-5就能拿掉一部分LZ4压不动的内容,级别拉到10+以后,压缩率收益就开始边际递减。
注意:不要在生产环境一上来就把ZSTD等级拉到最高。压缩率好看不代表查询快。数据压得太小,解压所需的CPU可能反而让查询慢下来。
我自己的经验是:如果是热数据为主、查询频繁,保持LZ4别折腾;如果是数仓分层里的汇总层或者历史归档层,换成ZSTD能节省可观的存储成本。
3. 表结构设计对压缩率的影响与实操
3.1 三种表模型如何影响数据可压缩性
这里必须先讲清楚,Doris表模型的选择,直接影响数据写入和排序方式,也就直接影响压缩效果。
Duplicate Key模型:数据不做任何聚合,保留明细,适合日志稽核、行为明细查询。因为不聚合,每一行都会落盘,但这也意味着重复前缀的概率高。如果业务允许,把重复度高的时间字段放在排序键前列,前缀编码效果会很理想。
Aggregate Key模型:同一排序键的数据会做聚合,重复键会合并成一行。这实际上是“逻辑去重+物理合并”,对降低存储量作用明显,尤其适合统计类报表汇总。你的排序键选得越合理,聚合率越高,压缩收益就越大。
Unique Key模型:主键唯一,用UPSERT语义写入。旧版本靠整行替换实现,存储上不利于压缩,因为新写入的整行数据可能和旧数据差异不大,却要完整存一遍。新版本通过Merge-on-Write和主键索引优化,情况好了不少,但仍要注意:主键越长,每行存储的索引开销越大,压缩率越差。所以主键别贪多,够用就好。
3.2 分区分桶设计对压缩的隐性影响
很多人忽略的一点:分桶的粒度直接影响单块数据的一致性和压缩效率。
分区对应数据按时间切分,通常我们要求数据写入尽量落在分区范围内,避免跨分区写入导致的IO放大。分桶则是在分区内按哈希值切分,数据块的大小由分桶数决定。
分桶数设太多,单个桶的数据量很小,数据块太小,编码和压缩都发挥不出来。分桶数设太少,单桶过大,Compaction压力大,查询扫描时也会读入过多无关数据。
按经验:单个桶的数据量建议在1-5GB之间,并确保单个Tablet的副本比较均匀。比如你的每小时分区大约500MB数据,那单个分区分4-8个桶是合理的。这个经验值不是死的,和列数、压缩率都有关,但作为初始设计足够。
3.3 ORDER BY 列顺序:被低估的压缩开关
Doris的排序键不解决问题,但排序键的顺序对压缩率影响极大。排序键字面意思是“数据排序的依据”,实质上它也决定了数据在磁盘上的物理布局。
排序键怎么设?
把重复度最高、基数最低的列放在最前面。例如时间字段,如果按小时分区,理论上分区内时间几乎一样,放前面做前缀编码收益就最大。然后是业务维度字段,比如ID、状态码。这样设计后,同一分片里相同前缀的数据紧密相邻,前缀编码的命中率会高得惊人。
反之,如果排序键第一列是高基数的UUID或者用户标识,每行的开头都几乎不同,前缀编码几乎失效,数据在磁盘上的分布也更散,压缩率直接掉一截。
我在实际项目里见过同一张表,只是把ORDER BY列调整了一下顺序,存储占用下降了接近25%。这是最便宜的优化,不需要改业务SQL,只需要在建表时多花十分钟思考数据分布。
3.4 列类型选择里的压缩门道
列类型这里再多说一句。固定长度类型(如INT、BIGINT、DATE)天然比可变长度类型(如VARCHAR)更容易压缩,因为它们不会引入长度标识之类的额外开销。
常见优化方向:
- 能用INT、BIGINT的,别用VARCHAR。
- 状态码、枚举值这类少量取值的字段,优先考虑TINYINT、SMALLINT,或者直接保持STRING然后用字典编码。
- 日期时间字段,能用DATE就别用DATETIME,能用DATETIME就别用VARCHAR。
联合编码之后,这些优化会放大压缩率差异。以前有人拿Doris做过压缩率实验,同一份数据VARCHAR存和INT存,最终落盘体积能差好几倍。所以表结构设计永远是第一位的,压缩算法只是最后一道兜底。
4. Compaction机制与压缩优化策略
4.1 数据版本与Base + Cumulative Compaction的关系
Doris的数据写入不是直接改写已有数据文件,而是每次导入生成一个新的数据版本(Rowset)。版本过多时,查询时要合并多个版本的数据,性能和存储都会恶化。Compaction就是负责把这些版本合并成更少、更大的数据文件。
Compaction分两类:
- Base Compaction:把基线数据和增量的累积数据做一次完整合并,生成新的基线版本。
- Cumulative Compaction:把增量小文件逐步合并成中文件,减少版本数,为下一次Base Compaction铺路。
从压缩角度看,Compaction每一次合并都意味着新数据块的生成,而新数据块会重新做编码与压缩。所以Compaction做得好不好,直接影响最终落盘的数据有没有被压到最佳状态。
4.2 Compaction策略:size_based 与 time_series
Doris提供两种Compaction策略供你根据场景选择。
size_based:Doris 1.2之前的默认策略。它根据数据文件大小相近的Rowset进行合并,让每个输出文件大小尽量均衡。这种策略对泛型业务比较友好,但对时序类数据不太友好——因为时序分区数据写入是有规律的,按时间增长,固定大小合并反而会产生很多跨时间边界的文件。
time_series:更适合时序类日志和监控数据。它按时间窗口组织文件,合并时倾向于把相邻时间窗口的文件合并在一起。这样对后续按时间范围扫描的查询非常友好,查询时IO更小。如果你做的是监控指标库、服务器日志库,这种策略更合适。
切换策略:
sql复制ALTER TABLE your_table SET ("compaction_policy" = "time_series");
但注意,这个参数在你建表后可以改,但已经生成的Rowset不会立即重新组织,需要等后续的Compaction任务慢慢重写。所以最好在表设计阶段就定好策略。
4.3 落盘压缩的最佳实践:冷热数据分离
Doris集群里的数据有冷热之分。热数据查询频繁,压缩算法要偏重解压速度;冷数据几乎不查,压缩率就应该优先。
这里有个常见的做法:周期性地把历史分区迁移到ZSTD压缩。Doris支持修改已有分区的压缩方式。
例如:
sql复制ALTER TABLE your_table PARTITION(p202401) SET ("compression" = "zstd");
改成ZSTD之后,这个分区的数据在后继Compaction中会被重新压缩。你可以写一个定时脚本,把超过90天的分区统一切成ZSTD列存,这样既保留了热数据的查询性能,又能省下可观的冷数据存储成本。
我在生产环境里做过一次对比,历史300天的日志数据,原先用LZ4占用大约5TB,切成ZSTD级别5之后,降到了3.2TB。这个收益不需要改动任何SQL,只是后台调度里加了一个任务。
4.4 单副本压缩:用存储换成本的进阶方案
如果你的Doris底层存储支持多副本或者远程存储,Doris提供了单副本压缩这种特殊模式。原理是:底层存储本身有副本保障,不需要Doris内部每个Tablet都维持多副本。这样每份数据只需在Doris内保留一个副本,再配合压缩,整体存储成本能大幅下降。
但要注意几点:
- 单副本压缩的前提是底层存储可靠,一般在K8s环境配合云盘,或与HDFS配合使用。
- 一旦底层存储故障,Doris需要重新拉取并构建副本,恢复时间会变长。
- 这个模式不是默认开启的,要在表属性里显式设置,并且对运维团队的故障演练能力有要求。
如果你是中小团队,没有专门的存储团队和巡检机制,我不建议贸然开启单副本。这不是算法问题,是可用性风险问题。
5. 常见问题排查与性能调优实录
5.1 压缩率低得离谱,从哪几个方向排查
我复盘过好几次压缩率异常的项目,绝大多数都不是算法本身的问题,而是使用方式出了偏差。这里整理一个排查表格。
| 现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 压缩率远低于预期 | 表模型选错,明细表当汇总表用 | 检查表类型和排序键设计 | 评估是否改表模型,或调整排序键 |
| 列存数据没有生效 | 导入时指定了行存格式 | 看建表语句和导入配置 | 统一使用列存格式 |
| 压缩率波动大 | 分桶数不合理,部分桶数据太少 | 查看各Tablet大小分布 | 调整分桶数,或使用动态分区合理规划 |
| 字符串列压不动 | VACHAR和STRING布满了随机性 | 分析列基数和枚举值 | 用字典编码或减少高基数VARCHAR字段 |
| 数据重复度极高但压缩率低 | 排序键顺序设计不当 | 检查ORDER BY列顺序 | 把低基数列调到排序键前面 |
这套排查思路的核心,是先看数据特征,再看引擎行为,最后才考虑换压缩算法。多数情况下,前两层调整完,压缩率已经能改善大半。
5.2 压缩CPU开销过高怎么办
ZSTD压缩算法解压时CPU开销确实比LZ4高,如果在查询测试中观察到CPU飙高,大概率是数据块的压缩等级设置太高,或者应该换回LZ4。
一个实操判断方法:用EXPLAIN ANALYZE看执行计划里的Scanner线程耗时,如果大部分时间花在解压而不是读IO上,那就是解压开销过大了。
解决办法有几个方向:
- 将高频查询的表切回LZ4。
- 降低ZSTD压缩级别。
- 通过查询缓存,减少重复的扫表解压。
另外,Doris的Page Cache会缓存最近读过的数据块。如果缓存命中率足够高,同一批数据的解压操作会被复用,CPU整体开销就不那么明显。合理设置storage_page_cache_limit也能缓解压力。
5.3 Compaction卡住导致数据膨胀
还有一类问题不是算法参数引起的,而是Compaction跟不上写入速度,导致版本数一直堆积,压缩任务迟迟跑不完。表现在监控上,就是Tablet版本数持续增长,磁盘占用也越来越大。
最稳的解法是先降低写入频率,让Compaction跑一阵追上节奏。如果业务不允许,就把Compaction线程数和并行度调大。
Doris里涉及的主要参数包括:
- compaction_tasks_per_tablet:单Tablet的Compaction并发上限。
- cumulative_compaction_num_threads_per_disk:每块盘上累积合并线程数。
- base_compaction_num_threads_per_disk:每块盘上基础合并线程数。
但不是盲目调大就好。线程多了,磁盘和CPU的竞争也会加剧,反而会影响正常查询。正确做法是先分析磁盘IO占用率,再决定是加线程还是加盘。
5.4 一个真实压测案例:LZ4换成ZSTD后的效果对比
最后分享一个我做过的真实案例。
背景是一张行为日志表,每天新增大约1亿条记录,分区按天,单分区数据量大约350GB(LZ4状态下)。查询模式大多是扫描最近7天数据做PV/UV聚合,同时也保留90天内的数据供运营临时查询。
操作过程:
- 先查生产环境基础指标,确定当前LZ4压缩状态下,单分区实际扫描IO约2.1GB/s。
- 挑一个测试表,把历史分区切到ZSTD级别5,将最近3天数据留在LZ4。
- 对比结果:LZ4分区查询平均P99延迟约230ms,ZSTD分区查询平均P99延迟约310ms,但存储占用从350GB降到240GB左右。
- 评估业务容忍度后,将保留30天以上的分区全部切成ZSTD,最近30天保持LZ4。
最终整体集群存储降低近28%,查询端最核心的最近7天窗口未受到影响。
这类优化思路的核心,永远是根据真实数据分布和真实查询模型做权衡,而不是拿着默认参数用一辈子。Doris提供了灵活的手段,用不好,多半是没理解自己的数据特征。
5.5 一些值得长期坚持的运维习惯
我在多个Doris集群上踩过坑之后,逐渐养成了几个习惯。
定期看Tablet的Size分布。如果某个分桶的Tablet异常大或者异常小,说明分桶设计或者Compaction出了问题,早发现早修复。可以写个简单的脚本,定期从SHOW TABLET里拉数据,超过阈值就报警。
每个新表上线前,都做一次压缩率评估。不需要完整造数据,可以取几天的真实样例导入测试表,观察实际落盘体积和查询性能。这个操作成本很低,但能避免很多表上线后才发现压缩率不理想的问题。
对压缩率变化做监控阈值。比如某个表压缩前后占比浮动超过30%,说明表结构或者数据特征发生了变化,值得排查。Doris不直接提供这个指标,但可以通过监控文件总大小和实际数据量做间接估算。
这些都不是什么高深算法,但它们才是让压缩优化真正落地的前提。压缩算法的选择只是其中一个环节,前后端的数据分析习惯和集群调优节奏,才是决定整体存储成本的关键。
