Skip to content

Hive Metastore 的僵尸表锁:一把锁躺了 17 天,谁写这张表谁排队

一个每天例行跑的 Iceberg 元数据治理作业,平时 24 秒跑完,某天突然变成 644 秒。日志里没有报错,只是慢——最后定位到,它在一张表上等 Hive Metastore 的表锁,而这把锁的持有者,17 天前就已经死了。

这是「我的数据空间」(能力页)在 Iceberg + HMS 这条经典组合上撞到的一个生产级问题。这篇讲清楚僵尸锁怎么来的、为什么 HMS 默认永远不清它、以及根治 + 兜底怎么配。

一、僵尸锁是怎么炼成的

Iceberg 的 HiveCatalog 在提交元数据变更时,会先到 HMS 拿一把表级锁(记录在 HIVE_LOCKS 表),提交完释放,长事务期间靠心跳续命。链条上任何一环非正常死亡,锁记录就残留了:

  1. 一个 Flink 作业的 TaskManager 因 OOM 被杀,它正持有某张表的 HMS 锁;
  2. 进程没了,锁的心跳停更,但 HIVE_LOCKS 里那行记录还在;
  3. HMS 4.0 默认 metastore.housekeeping.threads.on=false——超时锁清理线程根本没开,这行记录就永远躺在那里;
  4. 之后任何人提交这张表,都要先等这把不存在的锁:Iceberg 默认等 180 秒才放弃一次,外层 commit 还会重试,最坏一张表就吃掉十几分钟。

于是就有了开头那一幕:治理作业扫到这张表,安静地排了十几分钟的队。更糟的是交互路径——用户改一次 schema,前端转圈十几分钟,没有任何提示。

HMS 僵尸表锁:成因、默认不回收、根治与兜底

二、根治:把 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 的「读-比较-写」不是原子的(HiveAlterHandlergetTable 再比对再写,中间没有锁定读)。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_tableHIVE-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_STATSCAT_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。