@
n0099 https://github.com/n0099/TiebaMonitor/issues/32#issuecomment-1403243840github.com/n0099/TiebaMonitor/issues/32#issuecomment-1401828929> Interlocked 操作应该是封装的处理器提供的指令。x86 的指令本就提供原子性,而且大都可以直接加前缀 LOCK (例如 ADD [data],eax 变成 LOCK ADD [data],eax )就提供强内存序保障的原子性。而微软早就把这样的操作封装成了 Interlocked 开头的 API 函数。合理推测 .NET 的这些原语遵循了类似的命名。
xp 时有有`Interlock*()`的 win32api 了:
learn.microsoft.com/en-us/windows/win32/api/winnt/nf-winnt-interlockedcompareexchange.NET CLR 不过就是直接 P/Invoke 过去调`kernel32.dll`而已,linux 可能是调 syscall 甚至直接生成 asm
interlock 的命名词源可能是
en.wikipedia.org/wiki/Interlock---
> 听上去有点有趣,希望能搞明白是为什么就好了。我是觉得你这里理应是不需要多一个进程锁的。
我也认为引入`SELECT ... FOR UPDATE`所在数据库层产生的`IX 锁`后其已经代替了进程锁的职责那就应该删除有关代码减少复杂度
然而在现代后端中台微服务娱乐圈带架构师们眼中看来反而不应该像 90s 的企业级 COBOL 程序员那样写上千行的 PL/SQL 以依赖于数据库层而不是后端程序的逻辑:
www.v2ex.com/t/909780#r_12600576所以他们更喜欢在程序中实现进程锁,或是引入各种 mq zookeeper 那样专门负责协调任何并行任务的消息 middleware 来重新实现数据库层的锁,并将这称为从数据库层解耦出了业务逻辑还避免了 vendor lock (尽管大型系统中本就极难更换 RDBMS )
---
> 比起说是过度应用一致性,我看不如说是不理解一致性。START TRANSACTION 和 COMMIT 并不是只要加了就没有一致性方面问题的魔法,需要正确理解和运用。
本来把多个 SQL 语句套进一个事务里就只是为了让他们变成一个原子操作,使得这些语句所造成的影响(`INSERT/UPDATE`造成写)要么都执行成功(`COMMIT`),要么都执行失败(`ROLLBACK`),所以保证了数据一致性
而这的所谓原子很明显不保证在并行事务时不会有任何`race condition`,只有事务隔离级别才能用来控制允许哪些类型的`race condition`发生
数据一致性也只是保证不会发生在一个事务中两个`INSERT`语句只有一个所产生影响实际生效了而另一个却消失了(比如 duplicated 错误导致另一个`INSERT`被`ROLLBACK`),事务在此同样不能保证在并行事务时不会发生两个事务都`SELECT+INSERT`了相同的行(也就是本帖最初的问题)
RDBMS 厂商们为了实现 ANSI SQL 4 大事务隔离级别都不约而同的选择了主要基于阻塞等待锁的实现而不是主要基于无锁数据结构所封装的 cpu 指令集提供的无锁原子操作
---
> 话说比起说是小政治家、哲学家和神学家的崇拜,我倒感觉像是伊欧那样的程序员会有的崇拜((
与此同时截止 2023 年 1 月,`四叶沙其马里 1 群皇帝日冕开发者`伊欧神仍在步`奥利金德 rust 研究潮`高强度 star 相关 repo:
![image](
user-images.githubusercontent.com/13030387/214503869-fafca63d-3b7a-471e-85a9-0b36c0adacf4.png)
---
> 是的,它是乐观的。然后这个问题的话……我知道了,它通过 SELECT 只能把乐观的条件放在某个行上,但是不可能放在满足某种条件的行现在还不存在这件事上,是不是?
一个朴素的`SELECT`不可能产生这种约束,所以有`FOR UPDATE`后缀所产生的`IX 锁`(以及`FOR SHARE`产生`IS 锁`),而产生`X 锁`很明显是会造成其他事务阻塞的
因此 EFCore 开发者 MSFT 员工将其归类为悲观并发控制:
github.com/dotnet/efcore/issues/26042---
> 想要的行为是数据库在满足对应条件的行被 INSERT 时打破这一乐观锁,但既然这样的行现在还不存在,就没法把这个条件绑定在哪个行上面,对不对?
因为乐观并发控制依赖于一个已有的`ROW_VERSION`值,如果行根本不存在那您只能定义个 NULL 来表示
---
> 那样的话,也许一个可行的办法是:首先 INSERT“空的”行(除了标识符 /主键那样的东西以外不包含有意义的数据,只有 dummy 值),失败也行。换言之哪个线程抢到了这个 INSERT 的机会根本无所谓。
这就是`INSERT IGNORE`
---
> 然后在将空行 UPDATE 为有意义的行这个操作上做乐观锁。
然而乐观并发控制本就依赖于观察`UPDATE`所返回的`affected rows`是 0 还是 1 来得知是否有其他事务已经修改了`ROW_VERSION`
那也同样可以观察`INSERT IGNORE`所返回的`affected rows`是 0 还是 1
所以
www.v2ex.com/t/908047#r_12564068 的 @
codehz 早已道明:
> 我来捋一捋,这一大段先查询再插入的目的是防止重复的插入?有没有一种可能用 INSERT ON DUPLICATE 来解决呢?直接忽略重复插入的冲突有影响吗
他所说的`INSERT INTO ... ON DUPLICATE KEY UPDATE`实际上就是数据库层的 CAS 原子操作:
dev.mysql.com/doc/refman/8.0/en/insert-on-duplicate.htmlstackoverflow.com/questions/45652775/thread-safety-of-insert-on-duplicate-key-updatestackoverflow.com/questions/27544540/how-exactly-is-insert-on-duplicate-key-update-atomic> 并不需要`INSERT INTO ... ON DUPLICATE KEY UPDATE`( PGSQL 又称`UPSERT`)因为这是仅插入而没有更新或删除(即 CRUD 只有 C ) 也可以直接`INSERT IGNORE`: [
dev.mysql.com/doc/refman/8.0/en/insert.html](
dev.mysql.com/doc/refman/8.0/en/insert.html)
>
> > If you use the IGNORE modifier, ignorable errors that occur while executing the INSERT statement are ignored. For example, without IGNORE, a row that duplicates an existing UNIQUE index or PRIMARY KEY value in the table causes a duplicate-key error and the statement is aborted. With IGNORE, the row is discarded and no error occurs. Ignored errors generate warnings instead.
>
> 然后在每次`INSERT`后[`SELECT ROW_COUNT`](
dev.mysql.com/doc/refman/8.0/en/information-functions.html#function_row-count)就可以知道少了多少行没有被插入(由于 DUPLICATE 或其他错误)(但只有行的数量,而非精确的对应关系,如果需要知道具体少插入了哪些行仍然需要`SELECT`之前插入的行范围)
>
> 但不论 UPSERT 还是 IGNORE 都是从数据库层面缓解问题,他不是保证永不发生 DUPLICATE 错误,而是保证发生 DUPLICATE 错误后您的程序也能跑(因为一个改成了 UPDATE ,一个将 ERR 降级到 WARN )
然而又有新的问题:
> 而我更需要避免的是这种类似 DUPLICATE 造成了数据冗余,但又完全符合数据库层的 UNIQUE 约束的问题:
> ![](
camo.githubusercontent.com/cad0a329cf8e554d8a58c361ac7f8db8c3c5d0bbef0f45579d676c171f8d8005/68747470733a2f2f692e696d6775722e636f6d2f6c3332676451412e706e67)
> 可以看到两个线程都插入了“完全一致”的行,除了 time 字段值分别是 1674453494 和 1674453492 (因此两者 INSERT 时都不会触发 DUPLICATE 错误) 而这是因为右侧线程在左侧线程于`12:39:54.874436`时间`COMMIT`之前就已经`SELECT`了,所以右侧不知道左侧即将`INSERT` time 为 1674453492 的“重复”行
> 对此问题我当然可以选择写一个基于[window function](
learnsql.com/blog/sql-window-functions-cheat-sheet/)的`DELECT`的后台 crontab (或是线程每次`INSERT`后都尝试`DELETE`一次)来定期执行删除这类冗余的“重复”行 但这跟`UPSERT/INSERT IGNORE`类似仍然是缓解问题而不是解决问题 而且`DELETE`作为事(`INSERT`)后补救也不可能解决更罕见(线程在同一秒内完成所有任务)的两个线程插入的所有字段都相同(也就是触发 DUPLICATE 错误)的场景
---
> 不这不是。ABA 问题,顾名思义,就是说 CAS 的时候读到的值跟之前读到的值是一样的,所以 CAS 会成功,但其实这个值已经被其它线程修改过又改回来了,不应该让这个 CAS 成功。如果这里的正确行为只依赖于被 CAS 的这个值本身的话这是不成问题的,成问题的情况是虽然这个共享变量本身是一样的但因为修改过所以已经不能当作仍然满足条件了。最典型的就是它是个指针,被修改过又改回来了,但它指向的东西已经不一样了,这种变化却不能被这个指针变量上的 CAS 捕获。
en.wikipedia.org/wiki/Compare-and-swap#ABA_problem 进一步指出:
> 有可能在读取旧值和尝试 CAS 之间,某些其他处理器或线程两次或多次更改内存位置,以便它获取与旧值匹配的位模式。如果这个看起来与旧值一模一样的新位模式具有不同的含义,就会出现问题:例如,它可能是回收地址或包装版本计数器。
---
> ABA 问题比这个单调递增计数器的问题困难得多。
> 跟 ROW_VERSION 基本上是一个功能只是叫法可能不一样的一个整数。
> 顺带一提,跨线程共享内存的同步问题里也有 ROW_VERSION 的类似做法,也就是使用两倍宽度的 CAS ,存放一个指针+一个版本记号。
然而 DCAS 中的额外自增就类似乐观并发控制中使用的自增`ROW_VERSION`:
> 对此的一般解决方案是使用双倍长度的 CAS (DCAS)。例如,在 32 位系统上,可以使用 64 位 CAS 。下半场用于举行柜台。操作的比较部分将指针和计数器的先前读取值与当前指针和计数器进行比较。如果它们匹配,则交换发生——新值被写入——但新值有一个递增的计数器。这意味着如果发生 ABA ,虽然指针值相同,但计数器极不可能相同
---
> 然后会碰到另一种问题,就是版本记号可能会回卷。
回顾经典之 logrotate:
> 对于 32 位值,必须发生 2^32 的倍数操作,导致计数器 wrap 并且在那一刻,指针值也必须偶然相同
en.wikipedia.org/wiki/ABA_problem#Tagged_state_reference:> 如果“tag”字段回绕,针对 ABA 的保证将不再有效。然而,据观察,在当前现有的 CPU 上,并使用 60 位标签,只要程序生命周期(即不重新启动程序)被限制为 10 年,就不可能进行回绕;此外,有人认为,出于实际目的,通常有 40-48 位的标签就足以保证不会回绕。由于现代 CPU (特别是所有现代 x64 CPU )倾向于支持 128 位 CAS 操作,这可以提供针对 ABA 的可靠保证。
---
> 很简单:某个线程写入了这个指针,然后把不再被用到的旧指针释放了。然后某个线程又做了一遍这个过程。但之前被释放了的指针可能又被分配到,于是此期间一直没有读过这个变量的另一个线程 compare 到了跟它之前读到的相同的指针,但这个相同的指针指向的值其实已经不一样了。所以 ABA 。
en.wikipedia.org/wiki/ABA_problem 指出:
> 如果一个项目从列表中移除,删除,然后分配一个新项目并将其添加到列表中,由于[MRU](
en.wikipedia.org/wiki/Cache_replacement_policies#Most_recently_used_(MRU))内存分配,分配的对象与删除的对象位于同一位置是很常见的。因此,指向新项的指针通常等于指向旧项的指针,从而导致 ABA 问题。
---
> 而在 GC 环境下不用显式释放这个指针,GC 引擎只会在真的没有别的线程在引用这个指针了之后才会释放它(上面看到一样的指针以为数据也没变的那个线程,也保持着对它的一个引用,从而也会避免它被 GC ),所以就没有这个问题。
enwiki 同时声称:
> 另一种方法是推迟回收已删除的数据元素。延迟回收的一种方法是在具有[自动垃圾收集器](
en.wikipedia.org/wiki/Garbage_collection_(computer_science))的环境中运行算法;然而,这里的一个问题是,如果 GC 不是无锁的,那么整个系统就不是无锁的,即使数据结构本身是无锁的。
---
> 读取的时候当然不应该修改 ROW_VERSION 吧。这个版本记号显然应该跟踪写入而不是读取。
真这样做最现实的问题这些行基本上就没法被其他事务读取了(如果自增`ROW_VERSION`是在数据库层静默执行(如通过`TRIGGER`)实现的而不是执行 SQL 的程序主动`SELECT+UPDATE`,除非是后者那么程序可以在只读不写的事务中省略`UPDATE SET ROW_VERSION += 1`来避免惊扰其他并行的后续事务使其以为乐观并发控制的资源竞争失败了(也就是 CAS 中同样存在的`false-positive`))
---
> 当然了。但组成它的两个操作本身只有 共识数 1 ,而 test-and-set 具有 共识数 2 ——如果您只有两个线程的话,让它们彼此等待对方就好啦。
经典 mutex 阻塞锁
然而我无法理解
en.wikipedia.org/wiki/Consensus_(computer_science)#Consensus_number 表格中为什么声称 CAS 等原子操作的共识数是 $\infty$ 所以他们可以用于包裹任何操作
---
> 我不相信。况且您都能说出 changeset 这个词,怎么想这东西都应该早已出现在 RDBMS 领域当中了吧。
RDBMS 中的 changeset 是 MVCC 中的`SNAPSHOT`,主要用于实现`REPEATABLE READ`中禁止`non-repeatable read`的需求
github.com/n0099/TiebaMonitor/issues/32#issuecomment-1401625868 早已道明:
> > 虽然`TRANSACTION`中的不同语句可以间隔任意久的时间,但数据库引擎对于开着的`TRANSACTION`肯定是要保持某些状态记录的
>
> 也就是 mysql 默认事务隔离级别`REPEATABLE READ`下需要对每个事务每个`SELECT`所读到的每一行都做缓存(被称作 SNAPSHOT )[
dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html](
dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html) 这也是其他使用 MVCC 的 RDBMS 实现 ANSI SQL 中要求的 4 个事务隔离级别之`REPEATABLE READ`的常规做法
http://mbukowicz.github.io/databases/2020/05/01/snapshot-isolation-in-postgresql.html www.postgresql.org/docs/current/transaction-iso.html企业级 orm 如 EFCore 中的 changeset 是 changetracking 的结果集:
learn.microsoft.com/en-us/ef/core/change-tracking/tbm.Crawler 中的 changeset 是基于 EFCore changetracking 的输出对每次爪巴后对一些表的影响的集合:
github.com/n0099/TiebaMonitor/blob/2f84a4ab96c07e0e1d7055d945ce9bcae9085a90/crawler/src/Tieba/Crawl/Saver/SaverChangeSet.cs#L11github.com/n0099/TiebaMonitor/blob/2f84a4ab96c07e0e1d7055d945ce9bcae9085a90/crawler/src/Tieba/Crawl/Saver/BaseSaver.cs#L29---
> 还是说,RDBMS 实在是不希望迫使它们的调用者在没有出错的情况下被迫 ROLLBACK ,而且可能反复地?
真这样做可能会违反`ANSI SQL`,当然从历史上看是先有 DB2 后有标准,而在 RDBMS 厂商们最初引入事务这个包裹复数 SQL 的概念时可能就业已设计为了`COMMIT`几乎不会失败:
>
github.com/n0099/TiebaMonitor/issues/32#issuecomment-1401433577>
> > 但不太能理解的是为什么它一定要让其它事务阻塞等待,而不是先返回不可靠的`SELECT`结果,如果有冲突的话再让`COMMIT`失败,整个事务被`ROLLBACK`,而且`截至 COMMIT 成功之前调用者必须把 SELECT 结果视为不可靠的,不能当真`呢?
>
>
github.com/n0099/TiebaMonitor/issues/32#issuecomment-1401199725 早已做出循环论证:
>
> > 因为 COMMIT 本就极少会产生错误(
stackoverflow.com/questions/3960189/can-a-commit-statement-in-sql-ever-fail-how )
而从现在的分布式网络角度来看更容易遇到的是不知道`COMMIT`是否成功了(比如 node 卡死了无法响应,或是网络故障导致响应丢包),也就是一个介于确定性的二值`成功 /失败`之间的状态
---
> 而 Intel TSX 指令集 的做法是只是试一试能不能用这种机制无锁地完成,只要发生任何冲突、中断或者其它原因就让所有冲突方 transaction abort ,此时调用者需要回退到更可靠(性能差一点)的实现,例如全局互斥锁。也因此当他们想要禁用这个指令集的时候就直接让所有事务总是立即失败就行啦。
这就像在使用乐观并发控制时要判断`INSERT/UPDATE`所返回的`affected rows`是 0 还是 1 ,而在封装了此类操作的 orm 如[EFCore](
learn.microsoft.com/en-us/ef/core/saving/concurrency)中会直接给您 throw 一个比通用的数据库服务端异常更具体的[`DbUpdateConcurrencyException`](
learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.dbupdateconcurrencyexception)异常---
> 显然我在写伪代码。而且我显然不喜欢这样写代码
然而我也看不懂中文编程之
github.com/wenyan-lang/wenyan/issues/617> 只不过在照顾某位依赖机器翻译的人罢了。
不开机翻我也慢慢读懂( 30\~100words/min ),而开机翻更快( 500\~700 字 /分钟)也方便大段引用复制粘贴