无锁并发写入 — 乐观并发保护什么、不保护什么
一句话总结
Iceberg 的写入方不加锁,各自创建新的 metadata,然后尝试在 Catalog 中做条件式替换,输了就读取新状态再重新叠加。像追加这样总是可以重新应用的变更,会自动解决;像覆盖写入这样可能与这期间的变更重叠的变更,则会被验证拦下而中止。但是,你的代码用以前读到的值计算出的结果,没有任何人会替你验证。
为什么不使用锁
向一张表写入的作业通常有好几个。流式加载每隔几分钟追加一次,夜间批处理修改昨天的分区,清理作业合并小文件。如果给整张表加锁,最慢的作业就会让所有作业都停下来。而且,在对象存储之上,也没有可靠的分布式锁。
规范中的乐观并发控制一节选择了另一条路。写入方假设在自己提交之前 current 不会改变,据此创建 metadata,然后把指针从基础版本改到新版本。如果基础快照已经不是 current 了,就以新的 current 为基础重新来过。读取的一方会始终看到自己打开的那个快照,所以不需要加锁。
工作原理——假设与动作
Reliability 文档把提交解释为假设与动作。发生冲突时,写入方会确认这个假设在当前状态下是否依然成立,成立就重新应用动作并提交。规范的冲突解决一节为每种操作规定了这个假设。
| 操作 | 重新应用之前要确认的事 |
|---|---|
| append | 无——总是可以重新应用 |
| replace(文件合并等) | 要删除的文件是否仍在表中 |
| delete(指定文件) | 要删除的文件是否仍在表中 |
| schema、spec 变更 | 这期间 schema 是否没有变化 |
追加之所以总能重新应用,是因为“添加新文件”这个动作,无论这期间进来了什么,依然是正确的。文档还写道,重试的成本也已经降低——追加只写一次新清单,不会在每次尝试时重写。
重试次数和间隔是表属性。commit.retry.num-retries 默认 4,commit.retry.min-wait-ms 100,commit.retry.max-wait-ms 60000,总限制 commit.retry.total-timeout-ms 30 分钟。重试用完之后,就会抛出提交失败异常。
隔离级别——把什么视为冲突
规范指出,“验证什么条件,就是隔离级别”。Spark 的 DELETE、UPDATE、MERGE 通过表属性 write.delete.isolation-level(update、merge 也是同样的名称)来选择,默认是 serializable。serializable 的含义是:在我读取并修改的范围内,如果这期间有人新增或删除了内容,就会失败;snapshot 则只把删除视为冲突。Spark 写入选项中的 DataFrame 覆盖写入也有同样的两个级别。越宽松,失败越少,但可能在不知道这期间进来的行的情况下把它们覆盖掉。
库不会替你守住的东西
想一想计数器。两个工作者读到 hits = 10,各自加 5,然后覆盖写入 15。先到的一方获胜。输的一方在条件式替换中落败,重试时,会被“这期间有符合我所覆盖条件(name = hits)的文件进来了”这项验证拦下而中止。到这一步为止,是库替你做的。
问题在于之后。如果捕获了异常,原样重写以前算出的 15,这次提交就会成功。表里是 15,而一方的 +5 消失了——这就是丢失更新。库只验证文件层面的假设,并不知道你的值是从哪一次读取得来的。正确的重试是重新读取、重新计算,并以这次读取为前提重新提交。
在现场相遇的样子
文件合并与加载发生冲突。文件合并(replace)在要删除的文件仍然存在时,即使加载作业插进来,也能重新应用。反过来,如果 MERGE 修改了同一个文件,文件合并就会失败,下个周期再做就行了。
失败提交留下的痕迹。在提交中落败的写入方,已经写好的数据文件不属于任何快照,就这样留了下来。它们不影响表,却占用空间,清理这类文件就是孤儿文件清理。
把重试设为 0。如果相信向一张表写入的作业只有一个,就关掉重试,那么在偶尔运行的清理作业与它冲突的那天,作业就会无缘无故地失败。
实际工作中真正重要的事
- 追加即使竞争,两边都能写入。重试会在新状态之上重新应用。
- 覆盖写入、删除、MERGE 可能被验证拦下。默认隔离级别是 serializable。
- “读取、计算、写入”的代码,在重试时必须重新读取。用以前的值重写,就是丢失更新。
- 失败的提交会留下孤儿文件。需要清理作业。
下一项实验要做什么
用 pyiceberg 把两个写入方做成同一进程中的两个 Table 对象,让它们读取相同的 metadata 后依次追加,看输的一方自动重新应用,使快照连成一条线。把重试关成 0,重做同样的竞争,找出异常的名称和输的一方留下的孤儿文件。最后围绕一个计数器,让覆盖写入互相竞争,看到验证异常,再重新读取、重新计算,使计数器恰好为 20。