主题
Hive Metastore 的僵尸表锁:一把锁躺了 17 天,谁写这张表谁排队
一个每天例行跑的 Iceberg 元数据治理作业,平时 24 秒跑完,某天突然变成 644 秒。日志里没有报错,只是慢——最后定位到,它在一张表上等 Hive Metastore 的表锁,而这把锁的持有者,17 天前就已经死了。
这是「我的数据空间」(能力页)在 Iceberg + HMS 这条经典组合上撞到的一个生产级问题。这篇讲清楚僵尸锁怎么来的、为什么 HMS 默认永远不清它、以及根治 + 兜底怎么配。
一、僵尸锁是怎么炼成的
Iceberg 的 HiveCatalog 在提交元数据变更时,会先到 HMS 拿一把表级锁(记录在 HIVE_LOCKS 表),提交完释放,长事务期间靠心跳续命。链条上任何一环非正常死亡,锁记录就残留了:
- 一个 Flink 作业的 TaskManager 因 OOM 被杀,它正持有某张表的 HMS 锁;
- 进程没了,锁的心跳停更,但
HIVE_LOCKS里那行记录还在; - HMS 4.0 默认
metastore.housekeeping.threads.on=false——超时锁清理线程根本没开,这行记录就永远躺在那里; - 之后任何人提交这张表,都要先等这把不存在的锁:Iceberg 默认等 180 秒才放弃一次,外层 commit 还会重试,最坏一张表就吃掉十几分钟。
于是就有了开头那一幕:治理作业扫到这张表,安静地排了十几分钟的队。更糟的是交互路径——用户改一次 schema,前端转圈十几分钟,没有任何提示。

二、根治:把 HMS 的 housekeeping 打开,但别全开
治本就是让 HMS 自己回收超时锁——开 housekeeping。但这里有两个实测踩出来的坑:
坑一:清理任务别全开,只留 AcidHouseKeeperService。 HMS 的 housekeeping 是一组后台任务,把与 ACID 压缩相关的任务一起开,会在初始为空的 AUX_TABLE 上撞 InnoDB 的 gap lock 死锁——两个后台线程互相等,结果一把锁都清不掉,日志里全是 deadlock retry。只开负责超时锁/超时事务回收的 AcidHouseKeeperService,干净利落。
坑二:超时阈值必须大于客户端心跳间隔。 锁的回收依据是「多久没心跳」,而 Iceberg 客户端默认每 240 秒续一次心跳。如果 metastore.txn.timeout 设得比 240 秒还短,HMS 会把活着的持锁者当僵尸清掉,提交直接失败。留出余量,300 秒起步。
多副本补充:housekeeping 只能有一个实例跑。 HMS 起多副本时要用 metastore.housekeeping.leader.hostname 指定唯一 leader,否则多个实例的 housekeeper 抢同一张 AUX_TABLE mutex——和坑一是同一类问题,只是从「多线程」换成了「多实例」。
三、兜底:锁等待要有上限,且按场景分级
housekeeping 只保证「僵尸锁最终会被清」,清之前的窗口期仍要自己兜。原则是撞锁要快速失败,而不是安静排队,并且不同路径的容忍度不同:
- 后台治理作业:等 20 秒,拿不到就把这张表标记失败、继续处理下一张——85 张表里 1 张被堵,整轮 138 秒跑完其余 84 张,而不是全员陪等;
- 用户请求线程(改 schema、写元数据):等 5 秒,拿不到立刻报错给用户,附上锁的持有者信息——转圈十几分钟才是最差体验。
另一个顺手修掉的隐患:治理失败不再静默。此前单表失败只记在审计里,作业实例照样显示成功,治理链停摆没人知道。改成有任何失败项就让实例判 FAILED,配合作业告警(核心能力总览 里长这样),问题当天就会浮出来:


还试过用 Iceberg 表属性把锁等待封顶(commit.retry 一族),结论是:这类属性在建表时固化进表元数据,对存量表覆盖不生效——最终收敛为在连接层统一设置,一处生效、全部路径同一口径。
四、试过一条更彻底的路:干脆不用这把锁
上面三层,都建立在「这把锁必须存在」的前提上。而 Iceberg 1.3+ 支持无锁提交——关掉 HMS 表锁,改由 HMS 对表的元数据指针做 CAS:
- 客户端关锁:Hadoop
Configuration里设iceberg.engine.hive.lock-enabled=false,或表属性engine.hive.lock-enabled=false(表属性优先,写进表元数据后所有引擎都认); - 提交改成 CAS:提交时把「我读到的旧
metadata_location」随alter_table一起发给 HMS(expected_parameter_key/expected_parameter_value),HMS 比对,值变了就抛The table has been modified...,Iceberg 转成CommitFailedException走乐观重试。
锁不存在,僵尸锁自然也不存在。看起来是治本中的治本,于是我们真去切了一版,并做了实测——结论是:在 HMS 4.0.0 上不能开,会丢提交。
四组探针(一次性表,跑完即删):
| 探针 | 结果 |
|---|---|
| 无锁表 / 取锁表各插一把僵尸锁后提交 | 无锁表 1.16s 成功;取锁表 48.4s 后 Timed out ... waiting for lock —— 机制确实绕开了锁 |
| Iceberg 两线程并发提交同一张无锁表(各 25 轮) | 50 次提交只推进约 29 个 metadata 版本,23 次被静默覆盖 |
| 直接在 thrift 层测 HMS 的 CAS:顺序发一个过期提交 | 正确拒绝 ✓ |
| 直接在 thrift 层测 HMS 的 CAS:并发两线程基于同一旧值提交(20 轮) | 20/20 轮两个都被接受 ✗ |
根因在 HMS 侧:CAS 的「读-比较-写」不是原子的(HiveAlterHandler 先 getTable 再比对再写,中间没有锁定读)。MySQL 后端下,两个并发 alter_table 各自读到旧值,都通过检查,后写者覆盖前者。补丁是 HIVE-28121(改表参数走 direct SQL),fixVersion 4.0.1 / 4.1.0——Iceberg 官方文档也把它列为「HMS 由 MySQL/MariaDB 支撑时」启用无锁提交的必要条件。把 datanucleus.transactionIsolation 调成 serializable 也没用:CAS 那段事务在代码里写死了 repeatable-read。
所以启用无锁提交要同时满足三条:HMS ≥ 4.0.1(含 HIVE-26882 + HIVE-28121)、所有写入者的 Iceberg ≥ 1.3、所有写入者一次性一起切(仍取锁的写入者发的是不带期望值的 alter,不做 CAS,会覆盖无锁写入者刚提交的指针)。第三条建议用表属性下发而不是在各引擎 conf 里对齐——属性存在表元数据里、优先级高于 conf,切换以「表」为单位原子生效。
那把 HMS 升上去不就行了?我们真升了一版 4.2.0,结论是:补丁和不兼容落在同一版。
| 版本 | thrift get_table | HIVE-28121(CAS 走 direct SQL) |
|---|---|---|
| 4.0.0 | ✅ 有 | ❌ 无 |
| 4.0.1 | ❌ 已删,只留 get_table_req | ✅ 有 |
| 4.1.0 | ❌ 已删 | ✅ 有 |
| 4.2.0 | ❌ 已删 | ✅ 有 |
- 升级本身很顺:
schematool -upgradeSchema依次跑完 4.0.0→4.1.0→4.2.0 约 10 秒,HMS 4.2.0 正常启动; - 但 Spark 一读 Iceberg 表就挂:
TApplicationException: Invalid method name: 'get_table'; - 因为 Spark 3.5 / Flink 1.20 自带的是 hive-metastore 2.3.9 客户端,只会调
get_table,Iceberg 的HiveCatalog也走这个客户端; - 也就是说,在「引擎自带老客户端」的栈上,拿到 CAS 修复的代价是引擎读不到表。出路不是等新版本,而是换协议:HMS 4.2 自带
hive-standalone-metastore-rest-catalog(Iceberg REST Catalog),走 REST 既不碰老 thrift 方法,也不碰 HMS 表锁。
两个升级前要知道的事实:Hive 4.1.0 起要 Java 21;4.0.0→4.1.0 的升级脚本会 DROP COLUMN(TAB_COL_STATS/PART_COL_STATS 的 CAT_NAME 等)且没有 downgrade 脚本——是单向门,升级前先 dump。
顺便一个方法论:顺序探针会骗人。先提交一次、再拿过期视图提交一次,HMS 会正确拒绝,看起来「CAS 生效 ✓」——但那只证明比较发生过,没证明比较和写是原子的。要证伪必须构造真并发。
五、四句话总结
- Iceberg + HMS 的组合里,锁的生死要有人管:HMS 4.0 默认不回收超时锁,
metastore.housekeeping.threads.on必须显式打开,且只留AcidHouseKeeperService; - 回收阈值 > 客户端心跳间隔(Iceberg 默认 240s),否则清的是活人;
- housekeeping 治本、锁等待上限兜底、治理失败大声报——三层缺一层,僵尸锁就能安静地躺半个月。
- 「无锁提交」是更彻底的路,但前提要查清:HMS 由 MySQL 支撑时须带 HIVE-28121(≥4.0.1),否则 CAS 在并发下失效、会静默丢提交(实测 20/20);而 4.0.1 起 thrift 的
get_table被删,自带老客户端的引擎又连不上——真要走这条路,该换的是 catalog 协议(Iceberg REST Catalog),不是只升 HMS。
这套元数据自动治理(快照过期/孤儿文件/小文件合并/锁回收)是「我的数据空间」的一部分——一套可私有化部署的数据平台,支持 OEM 合作。看能力页与核心能力总览,交流合作 QQ:1559851993。