20260309.ddl卡死doris环境的一次记录

5 阅读5分钟

20260309.ddl卡死doris环境的一次记录

如果只能用一句话概括单机数据库与分布式数据库在异常处理上的最大差异,我会这样说:单机系统里的一个语法错误通常只会引发一条隔离的红色报错日志,而在分布式系统的元数据管理节点中,一个未经严密捕获的边界异常,足以让整个集群的控制平面陷入不可逆的死锁。近期在 Doris 2.1 候选版本环境中排查的一起极端 DDL 挂起事件,剥开了并发锁管理脆弱的一面。

表象背后的排查迷局

问题的表象是一条普通的建表语句迟迟无法返回结果。通过观测集群的 SHOW PROCESSLIST,可以发现大量后续完全合法的 CREATE TABLE 请求全部处于长时间的 SleepQuery 状态,排队时间累计达到数千秒。机器级的资源监控显示 CPU 和内存均处于极低负载,BE(Backend)节点的磁盘空间也十分充裕。这意味着系统并没有发生资源枯竭或 I/O 拥塞。

在常规的认知中,中断这类长期挂起的查询只需要通过 MySQL 客户端执行 KILL 命令即可。然而在这个场景下,KILL 指令仅仅切断了客户端与代理层的 TCP 连接。由于根本原因是内部的元数据死锁,底层的 Java 工作线程依然死死捏着全局写锁。这种表现指向了一个明确的结论:这不是一次查询超时,而是 FE(Frontend)节点的内部状态机彻底停滞。

Unique Key 与 Sequence Column 的逻辑悖论

追溯导致集群挂起的“始作俑者”,是一条带有明显业务逻辑缺陷但缺乏语法强校验的建表语句。

    
    
    
  CREATE TABLE `bill_account_flow` (  `id` bigint NOT NULL COMMENT "主键id(自增)",  `customer_code` varchar(255) NULL COMMENT "客户代码",  `bill_type` varchar(255) NULL COMMENT "流水类型",  `cost_type` varchar(255) NULL COMMENT "业务类型",  `bill_code` varchar(255) NULL COMMENT "账单代码",  `source_code` varchar(255) NULL COMMENT "来源业务单据代码",  `pay_money` decimal(16,2) NULL COMMENT "支付金额",  `account_money` decimal(16,2) NULL COMMENT "账户余额",  `currency` varchar(30) NULL COMMENT "币种",  `bill_date` datetime NULL COMMENT "账单时间",  `remark` varchar(255) NULL COMMENT "备注",  `create_time` datetime(3) NOT NULL COMMENT "创建时间",  `update_time` datetime(3) NULL COMMENT "更新时间",  `is_delete` bigint NOT NULL DEFAULT "0" COMMENT "删除标识 0-未删除 1-已删除",  `version` bigint NOT NULL DEFAULT "0" COMMENT "版本号 用于乐观锁") ENGINE=OLAPUNIQUE KEY(`id`)DISTRIBUTED BY HASH(`id`) BUCKETS 16PROPERTIES ("replication_allocation" = "tag.location.default: 1","min_load_replica_num" = "-1","is_being_synced" = "false","storage_medium" = "hdd","storage_format" = "V2","inverted_index_storage_format" = "V1","enable_unique_key_merge_on_write" = "true","light_schema_change" = "true",-- 核心错误:将sequence_col指向id,导致死锁"function_column.sequence_col" = "id","disable_auto_compaction" = "false","enable_single_replica_compaction" = "false","group_commit_interval_ms" = "10000","group_commit_data_bytes" = "134217728","enable_mow_light_delete" = "false");

在 Doris 的 Unique Key 模型(特别是开启了 Merge-on-Write 机制后)中,为了解决高并发写入时数据乱序到达导致的新数据被旧数据覆盖的问题,引擎引入了 sequence_col 属性。它的核心逻辑是:当相同主键的多条数据发生冲撞时,通过比对 sequence_col 指定字段(如时间戳或版本号)的大小,来决定哪一条数据最终落盘。

致命的逻辑裂痕出现在这里:原始的建表语句同时指定了 UNIQUE KEY(id) 并且在属性中强行设置 "function_column.sequence_col" = "id"。用主键本身去作为解决主键冲突时的排序依据,在语义上构成了自指悖论。

元数据锁泄漏的工程切面

当这样一条充满逻辑矛盾的 DDL 被推送到 FE 节点时,灾难在临界区内发生了。FE 在处理表结构变更时,为了保证分布式元数据的强一致性,必须先获取 Catalog 层面的读写锁(ReentrantReadWriteLock 中的 Write Lock)。拿到写锁后,代码进入语义校验阶段。

底层的校验逻辑发现了 sequence_col 与主键重合的悖论,并顺理成章地抛出了一个语义异常。但在特定版本(如 2.1.11-rc01 及其早期迭代)的代码分支中,这个由边缘逻辑触发的异常未被上层建筑妥善捕获,或者在处理抛错的跳转逻辑中,隐式地跳过了 finally 块中的 lock.unlock() 释放动作。线程因异常终止,但它持有的数据库写锁永远留在了内存堆中。后续所有企图变更表结构的线程,在尝试 writeLock.lock() 时全部被无限期挂起。整个集群的 DDL 能力因此被单行配置彻底瘫痪。

重构的边界与工程实现

随着 Doris 2.1 版本对原生分布式自增主键(AUTO_INCREMENT)的全面支持,数据导入的门槛被大幅降低。但这同样要求开发者在数据建模时,更加清晰地剥离“身份标识”与“状态演进”这两个正交的概念。

    
    
    
  CREATE TABLE `bill_account_flow` (  `id` bigint NOT NULL AUTO_INCREMENT COMMENT "主键id(自增)",  -- 业务字段省略  `version` bigint NOT NULL DEFAULT "0" COMMENT "版本号") ENGINE=OLAPUNIQUE KEY(`id`)DISTRIBUTED BY HASH(`id`) BUCKETS 16PROPERTIES (  "enable_unique_key_merge_on_write" = "true",  "function_column.sequence_col" = "version" );

上述修正后的代码展示了 Merge-on-Write 架构下最稳健的拓扑形态。通过将 id 彻底委托给底层的 AUTO_INCREMENT 生成器来保证全局唯一性,同时将用于解决乱序覆盖冲突的 sequence_col 锚定在独立的 version 字段上。这种设计在物理层面隔离了身份键与排序键,从根源上消除了触发底层解析器死循环或异常漏判的理论可能。

剖开这次锁泄漏的整个切面,如果只记住一件事,那就是:在分布式系统的控制面设计中,防御性编程的优先级永远高于特性实现。面对不受控的外部输入,任何未经穷举的语义组合如果不加甄别地带入加锁的临界区进行校验,最终的代价必然是全局可用性的剥夺。重启 FE 节点仅仅是运维层面的物理妥协,真正的系统级韧性,始终取决于引擎内部那层严丝合缝的锁释放闭环。