Doris更新删除机制解析:从模型选择到实时同步与Compaction调优

发布时间:2026/9/10 11:04:52
Doris更新删除机制解析:从模型选择到实时同步与Compaction调优 1. 先搞懂存储模型再谈更新删除1.1 MPP架构下的数据文件为什么只追加很多人从MySQL或PostgreSQL切到Doris之后第一件事就是习惯性地执行UPDATE、DELETE然后遇到各种奇奇怪怪的现象。比如一条UPDATE执行成功但查询结果没有变化DELETE之后表和磁盘占用一点没少大批量更新之后查询越来越慢最后直接报too many versions。这些问题的根源全在Doris的存储架构上。Doris是一个典型的MPP大规模并行处理架构的OLAP数据库它底层的数据文件是列式存储的每个Tablet的数据以不可变的Rowset文件形式落在磁盘上。什么叫不可变就是当你写入一批数据时Doris不是打开一个文件往里追加一行修改而是直接生成一个新的Rowset文件。新数据不会改动老文件老文件也不会因为新写入而重写。这和MySQL的B树原地更新行数据有本质区别。你可以把Doris的Rowset理解成快递包裹每次来一批货就贴上新的面单放进仓库即便这批货是同一批商品也只是堆在旁边。查询时Doris需要把涉及的所有版本包裹全部扫描一遍再按照版本顺序合并出最终结果。所以更新和删除在Doris里并非改掉旧数据而是新写入一个版本把旧版本标记为不可见。这个设计是OLAP场景下的大数据写入约定俗成的做法。列式存储实现行级原地更新极其昂贵直接放弃原地更新把成本转移到后台的合并任务Compaction上反而能让写入吞吐做得非常大。所以Doris的更新删除逻辑本质上都是围绕如何快速生成新版本、如何让查询跳过不可见版本来设计的。1.2 三种数据模型的更新语义差别Doris建表时必须指定数据模型这一点和传统数据库完全不同也是新手最容易被绊倒的地方。三种模型分别是Duplicate、Aggregate和Unique更新删除能力依次增强。Duplicate模型是明细模型建表时不指定任何模型或指定DUPLICATE KEY它完全不做主键去重。这意味着往同一张表里写两行相同主键的数据两行都会保留。Duplicate模型没有更新语义也没有删除语义唯一能做的清理就是DROP分区或者TRUNCATE整表。需要保留明细、不关心重复的数据比如日志流水、订单明细用这个模型最合适。Aggregate模型是聚合模型建表时指定AGGREGATE KEY并为非聚合列指定SUM、MAX、MIN、REPLACE等聚合类型。它的更新语义来自REPLACE聚合方式相同聚合键的新数据进来后查询时用新值替换旧值。这是Doris最早用来实现更新的方式但它有个先天限制聚合列在写入时就会做部分预聚合如果业务既要做SUM又要做到期替换语义很容易混在一起而且REPLACE聚合在Merge-on-Write出现之前查询时需要实时合并所有版本查询延迟比较高。Unique模型是唯一键模型也是目前最常用、最重要的更新删除承载模型。它保证主键唯一同样主键的值后写入覆盖先写入。Unique模型有两种实现路径旧版是把Unique当成Aggregate的特殊REPLACE场景来实现写入时不对旧版本做处理查询时合并所有版本新版则采用Merge-on-Write机制简称MOWDoris 2.x中配置项为enable_unique_key_merge_on_write写入时就直接定位到对应主键所在的Rowset生成DeleteBitmap标记旧数据不可见让查询不需要实时做版本合并。两者一个倾向写优化一个倾向读优化实际使用中MOW的查询性能稳定得多推荐在新集群上直接使用。提示选择数据模型时要想清楚这张表未来要不要更新而不是等到业务跑上线了再改。模型在创建表时确定虽然可以通过ALTER TABLE做少量调整但改动成本和风险极高尤其是数据量上来之后。1.3 新旧版Unique模型的取舍表结构设计直接决定删除成本在实际生产里我见过太多人一上来就建Duplicate模型等同步任务跑了两周才发现业务要更新历史数据只好重建表重新导数据。所以如果你判断数据主键有唯一性要求或者将来可能要按主键更新、删除直接上Unique模型省得后面折腾。用Unique模型建表时主键的选择非常讲究。Doris的主键并不像MySQL那样自动建索引而是用于分桶、排序、去重和定位数据。分桶键默认取主键前几个字段分桶数量由BUCKETS指定。主键数量不宜太多一般建议控制在3个以内类型尽量用整型或短字符串减少索引占用的内存空间。实践中用bigint自增ID、订单ID或用户ID做单列主键是最稳妥的。同一张Unique表还要考虑分桶键和数据模型的关系。如果主键是订单ID但查询经常按用户ID过滤那可以考虑用用户ID作为分桶键不过要注意这可能导致同一用户的不同订单落在不同桶中对更新定位会产生额外开销。分桶键的设计没法一步到位需要根据真实查询模式反复调前期不要怕麻烦多建几个测试表验证一下比上线后改表代价小得多。2. 实时同步链路中的更新与删除不止是写入2.1 主流链路Flink CDC如何把Binlog转发成Doris的upsertDoris MySQL实时同步是搜索热词里出现频率很高的组合原因很直接大量业务系统数据源还是MySQL/Oracle而Doris负责对外提供OLAP分析两套库之间的数据流转扛起了大部分实时数仓的底座。主流方案是MySQL主库开启Binlog用Flink CDC组件实时捕获Binlog变更事件经过Flink任务处理后通过Flink Doris Connector写入Doris。这条链路能感知到源库的INSERT、UPDATE、DELETE三类操作并在Doris里做对应处理。Flink CDC把Binlog事件解析成三类RowData插入事件在Doris中对应新增一条主键记录更新事件在Doris Unique模型下会以新值覆盖同主键的旧值删除事件比较特殊需要Doris侧提供对应的删除语义支持。目前flink-doris-connector的新版本对CDC删除事件已经支持得不错核心原理是当Doris表启用Unique模型且开启MOW时删除事件会在Target端生成一条带删除标记的主键记录通过Stream Load写入后Doris在删除Bitmap里把这个主键对应的旧版本全部标记为不可见。这条链路让实时同步的数据一致性能达到秒级甚至毫秒级基本可以满足绝大多数实时数仓的业务要求。2.2 Sequence列防止乱序数据覆盖正确结果实时同步场景有一个很容易被忽略的坑乱序。Binlog从MySQL传到Kafka再被下游多个Flink任务消费同一个主键的更新顺序完全可能错位。比如订单表里先发出一条支付成功的UPDATE后发出创建订单的INSERT理论上创建订单应该在前但如果网络抖动或者消费者处理速度不一致支付成功的记录先到Doris创建订单的记录后到按照后写入覆盖先写入的规则最终数据会退回到创建订单状态业务数据就错了。Doris的Unique模型提供了Sequence列也叫版本列来解决这个问题。建表时指定一列作为Sequence列写入时Doris会比较同主键下Sequence列的值只有新写入的Sequence值大于当前值才允许覆盖。实战中最简单的方案是直接把MySQL数据表中自带的更新时间字段如update_time作为Sequence列或者用Binlog的写入时间戳。这两个字段在源端天然单调递增能最大限度保证按事件发生的真实顺序合并。Sequence列配置非常简单CREATE TABLE orders_unique ( order_id BIGINT NOT NULL, status VARCHAR(32), update_time DATETIME ) UNIQUE KEY(order_id) DISTRIBUTED BY HASH(order_id) BUCKETS 10 PROPERTIES( function_column.sequence_type DATETIME, function_column.sequence_col update_time );注意如果源端更新时间字段精度不统一毫秒和秒混用会导致Sequence比较失败表现为数据不更新。我遇到过不止一次因为MySQL的DATETIME精度是秒、Doris侧用DATETIME毫秒导致解析出的时间一致更新被丢弃的情况。可以把源端的update_time转成bigint型的时间戳放进Sequence列最不容易出问题。2.3 同步工具怎么选Flink CDC、Kettle插件与常规ETL的边界搜索热词里还出现了kettle8.3 doris插件和ldap的syncrepl实时同步方案说明不少人试图用传统ETL工具把Doris纳入数据同步体系。我的观点是Kettle这类工具不是不能用但要分清边界。Kettle的Doris插件适合做离线批量同步、一次性全量导入、历史数据补数操作界面可视化业务人员也能上手。但它对Binlog的实时捕获支持很弱属于轮询或定时拉取模式数据延迟至少分钟级而且无法天然感知源端DELETE事件。如果你只需要每天把生产库数据同步一份到Doris做T1分析用Kettle完全没问题注意批量写入时一把梭几十万行往往触发Doris的导入事务限制建议分批或使用Doris官方推荐的分片方式。但如果是实时数仓或者要求数据变更秒级可见的场景Flink CDC就是当前最合适的选择。Flink具备分布式计算能力可以同时消费多个MySQL实例、附带维表关联、窗口聚合等处理逻辑并且通过Checkpoint配合Doris Stream Load的两阶段提交实现端到端Exactly-Once语义。下面是一个Flink SQL同步MySQL到Doris的最小示例CREATE TABLE mysql_orders ( order_id BIGINT, status STRING, update_time TIMESTAMP(3), PRIMARY KEY(order_id) NOT ENFORCED ) WITH ( connector mysql-cdc, hostname 127.0.0.1, port 3306, username root, password xxx, database-name app_db, table-name orders ); CREATE TABLE doris_sink ( order_id BIGINT, status STRING, update_time TIMESTAMP(3) ) WITH ( connector doris, fenodes 127.0.0.1:8030, table.identifier app_db.orders_unique, username root, password xxx, sink.label-prefix syn_order_cdc, sink.properties.format json, sink.properties.read_json_by_line true ); INSERT INTO doris_sink SELECT order_id, status, update_time FROM mysql_orders;同步链路跑起来之后建议第一时间做一次源端删除记录的验证具体做法在MySQL里删掉一条数据然后立刻在Doris里查询对应主键。如果数据还在说明删除事件没有正确同步需要检查Flink版本和Doris Connector版本是否支持CDC删除事件或者升级表模型到MOW。3. 替换Elasticsearch之后的删除和更新差异3.1 Lucene的删除与Doris标记删除殊途同归但代价不同Doris替换ES也是很多团队在做的事情。原因很现实ES在日志检索、模糊搜索、全文索引方面确实占优势但如果场景是结构化数据的多维聚合分析、大宽表实时更新ES的写入瓶颈和内存压力会非常明显。很多团队开始把一部分原本放在ES里的业务数据迁移到Doris上显著降低机器成本。但替换的前提是理解两套引擎在更新删除上的差异否则很容易踩坑。ES底层的Lucene存储是不可变的Segment文件删除文档时并不会真正物理删除而是在Segment的位图里把文档标记为deleted查询时过滤掉。真正释放空间要等后台Merge把多个Segment合并重写物理丢掉标记数据。Doris的删除机制几乎是一模一样的思路删除操作生成一个新的删除版本查询时通过DeleteBitmap跳过被标记的主键物理空间回收依赖Compaction任务。两者看起来很像但代价差异很大。ES的删除单位是文档Lucene是倒排索引为主的结构删除标记只需要改动位图成本相对可控。Doris的删除单位是主键在Unique模型里删除意味着要遍历定位到主键所在的Rowset和列然后写入DeleteBitmap。如果删除的目标是没有规律的大范围数据产生的DeleteBitmap可能非常庞大导致查询时Bitmap合并开销巨大。所以Doris里删除操作更适合按主键精确删除和按分区整体删除两种场景而不适合UPDATE ... WHERE status expired这类高频条件删除。3.2 从ES迁移到Doris时模型设计的调整ES里一个索引的文档结构是JSON嵌套的字段可以任意扩展。迁移到Doris第一件事就是把嵌套结构拍平成宽表并且确定主键。ES里天然有_id字段迁移时可以直接用_id作为Doris的Unique Key这是最省事的方案。但有一个严重差异要提前想清楚ES支持单文档局部字段更新也就是你可以只更新某一个字段其他字段不变。Doris的Unique模型在MOW开启前更新都是整行覆盖即便开启MOW也要通过Doris的partial update能力指定只更新部分列。如果你在ES里高频执行局部更新迁移到Doris前必须评估是否需要把改动收敛到整行写入或者改造同步逻辑让Doris侧也走partial update。Doris 2.0之后对partial update的支持已经比较成熟Flink Doris Connector也提供了相关配置。需要说明的是partial update对写入性能有一定影响因为底层还是要定位到对应主键所在Rowset再执行列级别的变更建议先用数据量验证一下再推广。从ES迁移时另一个要注意的点是删除查询模式。ES里常见做法是先search出需要删除的文档再逐个delete这种模式搬到Doris后如果按主键逐条DELETE会产生大量小版本严重拖垮查询性能。更合理的做法是通过一个离线任务把待删除主键集合导出构造一个临时标识用做分区替换或者一次性大批量删除尽量把删除操作收敛成少数大版本。3.3 典型的Doris替换ES场景拆解我在实际项目中见过一个做得比较成功的替换案例场景是订单检索与分析平台。原来用ES承载订单的明细查询、状态筛选和聚合报表每天有几千万订单写入同时业务侧会退款、取消订单需要更新状态。ES集群高峰期CPU一直打满而且状态更新通过update by query实现一条更新语句能触发大量文档重建集群频频抖动。替换方案是订单明细表用Doris Unique模型主键为订单ID分桶键是商家ID退款、取消等操作在下游通过实时同步链路直接对订单表做整行覆盖不产生ES那种文档重建开销。订单多维度筛选查询走Doris的前缀索引或倒排索引功能聚合分析直接走SQL。因为更新和删除都被收拢到主键粒度Doris侧版本管理非常干净Compaction压力也小集群稳定性比ES方案好很多。这个话题顺带联系到搜索热词里的doris和clickhouse的选型。如果只是从更新删除角度看ClickHouse的Mutation更新是异步后台重写列文件代价极高官方明确不建议频繁使用Doris的MOW模型更适合高频UPSERT和数据同步场景。如果你的业务在实时同步链路上频繁变更数据Doris是更合理的选择如果数据只进不改且查询以超大宽表聚合为主ClickHouse的强项则更匹配。选型从来不是看热搜而是看业务到底写得多还是读得多、更新频率高不高。4. 批量清理实战DELETE、TRUNCATE、分区裁剪与Temp Partition4.1 什么时候能用DELETE什么时候千万别用DELETE语句在Doris里的实现和MySQL完全不同。Doris执行DELETE并不会逐行删除数据文件而是在表上生成一个删除谓词查询时所有行都会经过这个谓词过滤。所以在Doris里DELETE的即时物理删除是不存在的它只是在逻辑层面把符合条件的主键标记为不可见。正因为这个机制DELETE的适用边界很明确适合小批量、低频的按主键删除比如运维订正误入的几百行脏数据适合清理某种明确条件的数据但总量不能太大绝对不能用于高频的逐行删除否则版本数量增长飞快查询和Compaction都会遭殃具体操作就是一条标准SQLDELETE FROM orders WHERE order_id IN (100001, 100002, 100003);也可以带更复杂的WHERE条件但不建议条件覆盖行数过大。我在生产里拿一张千万级表做过测试一次DELETE扫过几百万行执行虽然成功了但接下来30分钟该分片的查询全部变慢因为删除谓词让查询额外做了一次大范围过滤。那次之后团队彻底定了一条规范凡是单次删除可能超过百万行的一律不走DELETE。4.2 按时间分区的清理策略真正高效的批量清理核心是分区。Doris的分区粒度通常是天数据进入对应分区后清理过期数据最直接的手段是DROP PARTITION这个操作是物理删除直接丢掉对应分区目录下的所有Rowset文件磁盘空间立刻释放不会产生任何版本负担。推荐做法是建表时明确分区字段并开启动态分区让Doris自动创建未来N天的分区。下面是一个按天分区并自动清理的建表示例CREATE TABLE user_events ( event_time DATETIME NOT NULL, user_id BIGINT NOT NULL, event_type VARCHAR(32) ) DUPLICATE KEY(event_time, user_id) PARTITION BY RANGE(event_time) () DISTRIBUTED BY HASH(user_id) BUCKETS 10 PROPERTIES( dynamic_partition.enable true, dynamic_partition.time_unit DAY, dynamic_partition.start -30, dynamic_partition.end 2, dynamic_partition.prefix p, dynamic_partition.replication_num 1 );动态分区可以保证未来几天的分区提前存在防止导入边界小时报错。要清理30天前的数据直接用一条命令ALTER TABLE user_events DROP PARTITION p20250101;或者用脚本批量把过期的分区名算出来循环DROP。我这里写了一个简单的伪代码流程实际生产里放到定时调度平台算出需要清理的最早保留分区日期比如今天减去30天查询information_schema.partitions获取当前所有分区列表遍历分区名日期早于保留阈值的分区执行DROP PARTITION这套方式我在多个项目里验证过千万行级分区的DROP操作基本秒级完成磁盘空间回收也是立即生效的是对Doris最友好的清理方式。4.3 Temp Partition交换实现数据订正批量清理之外还有一种高频需求是数据订正某一天的数据因为上游口径变化需要整体重刷。常规做法是DELETE掉那天的数据再重新导入。但DELETE大范围数据会引发之前说的性能问题更优雅的方案是Temp Partition交换。Temp Partition允许你先建一个临时的分区结构往里面导入修正后的数据验证无误后用一条命令把临时分区和正式分区做原子交换。整个过程对外几乎无感知数据不会出现中间状态为空的窗口期。具体流程分三步第一步给表增加一个和待订正分区范围一致的临时分区ALTER TABLE user_events ADD TEMPORARY PARTITION tp20250101 VALUES LESS THAN (2025-01-02);第二步把订正数据导入临时分区。这步可以用Stream Load、Broker Load或者Insert Into目标指定临时分区名INSERT INTO user_events PARTITION(tp20250101) SELECT ... FROM source_table WHERE event_time BETWEEN 2025-01-01 AND 2025-01-02;第三步交换临时分区和正式分区ALTER TABLE user_events REPLACE PARTITION (p20250101) WITH TEMPORARY PARTITION (tp20250101);交换完成后旧分区的数据还在但已经和表脱离关联可以再通过DROP PARTITION清理掉。这个方案对比DELETE的合理不伤性能优势非常明显一次订正只需要两次分区级操作不产生大量小版本查询性能完全不受影响。5. 更新删除的代价版本膨胀与Compaction调优5.1 记住这个数字默认1024个版本上限Doris每个Tablet都维护一个版本列表从0开始递增。每次导入一批数据、每次DELETE语句、每次ALTER TABLE操作都会在Tablet上新生成一个版本。版本数量本身不是问题但如果版本增长速度远大于Compaction的合并速度就会出问题。BE端有个配置叫max_tablet_version_num默认值是500也有版本默认1000或1024具体情况取决于Doris版本。当Tablet的版本数量超过这个阈值查询会直接报错too many versions这是Doris里特别典型的故障原因几乎都是频繁小批量导入或者高频小范围DELETE。举个最容易触发的例子同步任务每个Flink checkpoint就写一次Doris每次checkpoint间隔30秒一天光写入就产生2880个版本。如果Compaction跟不上几天之内某张热表的Tablet版本就会飙到几百查询延迟急剧上升。实际排查时会看到SHOW PROC /tablets里某几个Tablet的versionCount数值巨大整个集群的查询性能都被这几个热点拖垮。5.2 Compaction机制与参数调整Compaction是Doris后台的版本回收工负责把多个小版本Rowset合并成大版本从而降低查询时的版本合并代价。Doris的Compaction分两类Cumulative Compaction负责合并最近的小版本Base Compaction负责把所有版本合并成一个基础大版本。两者交替执行目标是让版本数量保持在一个低位。当更新删除频率偏高时Compaction线程可能忙不过来。BE配置可以这样调整max_compaction_threads控制Compaction线程数默认值偏低IO和CPU有余量的机器可以调大cumulative_compaction_num_singleton_impls控制单次Cumulative Compaction最多合并的小Rowset数量base_compaction_interval_seconds控制Base Compaction触发间隔如果版本长期降不下来可以适当缩短调整后需要重启BE生效建议先在测试环境观察IO压力不要盲目把线程数拉到很高。Compaction本身很消耗磁盘IO如果和查询高峰重叠容易造成查询抖动。更稳妥的做法是把Compaction和业务低峰对齐比如凌晨清理数据、上午导出报表的场景把重Compaction放在凌晨执行。也可以通过SHOW PROC /compactions查看当前集群Compaction任务的状态里面能看到排队数量、执行中任务和失败原因。如果发现Compaction长期排队不执行优先检查磁盘IO是否被打满再看版本数量是否超限。5.3 清理后的空间释放问题DELETE之后空间为什么没少不少人在这个坑里反复打转。前面说了DELETE是标记删除数据文件还躺在磁盘上要等后续Compaction把被标记的行真正从Rowset中剔除。如果删除的数据恰好集中在某个大Rowset里而Compaction迟迟不合并这个大Rowset空间占用就会一直居高不下。解决这个问题有两条路。第一条是等待让Compaction自然完成适合删除量不大、不着急缩容的场景。第二条是主动优化如果表是分区表直接DROP分区物理释放最干净如果必须保留表数据但不能保留某些行可以考虑使用Temp Partition替换方案新旧分区交换后把旧分区DROP掉空间立刻释放。另外要注意CREATE TABLE时设置的副本数。Doris默认副本数是3生产环境建议至少3副本DROP分区时会同时释放所有副本的数据但前提是表设置了适当的replication_num否则可能只有部分副本释放出现空间占用不一致的情况。这一点在缩容前一定要检查。5.4 关于Doris安装部署的补充最后补一句实操层面的话如果想在自己的机器上快速验证上面这些更新删除机制不需要一上来就搭整套生产集群。Doris官方提供了All-in-One的Docker镜像一条命令就能起一个包含FE、BE和MySQL协议端口的单机环境非常适合做模型验证、SQL测试和机制学习。FE和BE的配置都在对应目录下的conf文件里看到涉及内存、Compaction、版本上限的配置项动手改一改观察效果比看十遍文档都有用。写在最后的实战体会从实时同步到批量清理Doris的更新删除机制并不复杂核心记住一句话数据文件不可变所有变更都靠版本堆叠最终由Compaction兜底。我自己实际带队维护的集群里因为更新删除操作导致性能问题的案例几乎都能归到三个原因模型选错、删除方式不当、同步频率过高。模型选错建表时就埋了雷后续任何更新删除都会付出额外代价删除方式不当把DELETE当MySQL一样高频用版本直接爆炸同步频率过高checkpoint间隔太短导致小版本堆积查询性能一天比一天差。要规避这些问题我的做法是建表前花十分钟确认模型和主键数据模型定了之后不再随意变更凡是超过百万行的逻辑删除需求一律走分区或者Temp Partition方案实时同步的写入频率控制在每分钟至少一次批式写入而不是每秒一个事务。做好这三件事Doris的更新删除就是一套非常趁手的工具而不是一个随时会爆的雷。