Skip to content

如何从理论上避免这类并行任务交错执行时的冲突问题 #32

Description

@n0099

原文发表于 https://www.v2ex.com/t/908047

假设有一个进程,其内部有一个全局的锁和两个(或多个)互相不知道对方存在的异步任务正在运行

和一个外部数据库中的某个表,假设表只有一个字段并且是UNIQUE约束。数据库每SESSION的隔离级别是READ COMMITTED

任务的目的是向表中插入一些行

  1. 但会先查询表中已有行(此时有START TRANSACTION),然后排除掉表中已有的
  2. 再向进程范围的全局锁声明占用了即将插入的已排除了表中已有行的行
  3. 然后向表插入这些行,在COMMIT表明成功插入后
  4. 再去进程全局锁释放此前占有的行

因此当存在两个并行执行的这个任务时可能有符合如下uml时序图的流程:

线程1->数据库: 读取已有行
数据库->+线程1:
进程锁->线程1: 已被锁的行
线程1->进程锁: 锁新插入行
    线程2->数据库: 读取已有行
    数据库->+线程2:
    进程锁->线程2: 已被锁的行
    线程2->进程锁: 锁新插入行
线程1->-数据库: 插入未被锁的行
线程1->进程锁: 释放新插入行锁
    线程2->-数据库: 插入未被锁的行
note right of 数据库: DUPLICATED
    线程2->进程锁: 释放新插入行锁

利用 https://www.websequencediagrams.com 在线渲染:

DUPLICATED表示线程2试图插入已经被线程1插入了的行,因此违反了数据库层的UNIQUE约束

对上图填充实际数据后

线程1->数据库: 读取已有行\nSTART TRANSACTION\nSELECT * FROM t
数据库->+线程1: 0 row returned
进程锁->线程1: 已被锁的行\n0行
线程1->进程锁: 锁新插入行\n1行:a
    线程2->数据库: 读取已有行\nSTART TRANSACTION\nSELECT * FROM t
    数据库->+线程2: 0 row returned
线程1->-数据库: 插入未被锁的行\nINSERT INTO t\nVALUES ('a');\nCOMMIT;
线程1->进程锁: 释放新插入行锁\n0行
    进程锁->线程2: 已被锁的行\n0行
    线程2->进程锁: 锁新插入行\n1行:a
    线程2->-数据库: 插入未被锁的行\nINSERT INTO t\nVALUES ('a');\nCOMMIT;
note right of 数据库: #1062\nDuplicate entry 'a'
    线程2->进程锁: 释放新插入行锁\n0行

可以看出在符合这个时序图的流程中进程锁和数据库层事务都无法阻止这种冲突,因为

  1. 线程2访问数据库表中已有行的时机早于线程1``COMMIT他的INSERT,所以线程2无法预见线程1将在未来插入行a(由于READ COMMITED事务隔离级别)
  2. 线程2访问进程锁的时机又晚于线程1完成COMMIT和释放进程锁中的行a,所以线程2也不知道此前线程1已经插入了行a

提出的解决方法:

1.延后线程2查询数据库的时机:

如果让线程1等待线程2释放了进程锁之后才开始查询表,那么线程1就可以从表中得知行a此前已经被插入了(但线程1并不知道这是线程2插入的)

这实际上就是serializability(linearizability和serializability的区别),即完全放弃了并行度,同一时间只能有一个任务在工作。这也意味着进程锁此时也是完全无用的,因为他的主要目的是让其他线程知道当前有哪些行即将插入表(但还没有COMMIT)

2.延后线程1释放进程锁的时机:

如果让线程1等待线程2查询进程锁之后再去释放锁,那么线程2就可以知道不应该插入行a

缺点:

  • 无法确定进程锁中的行是否已经插入还是即将插入(如果不额外查询表)
  • (1和2的共同缺点)任务之间相互等待造成了逻辑耦合,即线程1必须知晓线程2的存在并等待线程2开始查询进程锁后线程1才能释放锁并完成他的任务销毁线程。实际上线程不应该知道其他线程也在同时运行,他们本应只需要与进程范围的全局锁通信
  • 实践中并非所有要插入的行都是大概率冲突的,那么会造成大量线程一直在等待自己插入的行被其他线程查询,然后再释放他们,也就是潜在的内存泄露。当然这可以通过增加超时或定长FIFO stack(见下)来缓解

3.数据库事务隔离级别从READ COMMITED降至READ UNCOMMITED

回顾经典之

可见降至READ UNCOMMITED后允许dirty read的发生,也就是对于如下时序:

线程2可以在线程1已经向数据库发送了INSERT,但还没发送COMMIT从而提交事务之前就得知行a已经被插入了

缺点:
对于最初的时序流程

仍然不适用,因为没有任何约束使得线程2不能在线程1向数据库发送INSERT之前就查询表,自然也就没有发生dirty read

4.缓存最近插入的行的进程范围全局FIFO stack

线程1->数据库: 读取已有行\nSTART TRANSACTION\nSELECT * FROM t
数据库->+线程1: 0 row returned
进程锁->线程1: 已被锁的行\n0行
FIFO->线程1: 最近插入的行\n0行
线程1->进程锁: 锁新插入行\n1行:a
    线程2->数据库: 读取已有行\nSTART TRANSACTION\nSELECT * FROM t
    数据库->+线程2: 0 row returned
线程1->-数据库: 插入未被锁的行\nINSERT INTO t\nVALUES ('a');\nCOMMIT;
线程1->FIFO: 追加最近插入的行\na
线程1->进程锁: 释放新插入行锁\n0行
    进程锁->线程2: 已被锁的行\n0行
    FIFO->线程2: 最近插入的行\n1行:a
    线程2->进程锁: 锁新插入行\n1行:b
    线程2->-数据库: 插入未被锁的行\nINSERT INTO t\nVALUES ('b');\nCOMMIT;
    线程2->FIFO: 追加最近插入的行\na
    线程2->进程锁: 释放新插入行锁\n0行

如果线程1保证在释放进程锁的行a之前先将其push,那么线程2就可以在pop时发现行a已经被插入,尽管这时进程锁中表明行a没有被锁(即正准备插入但尚未COMMIT)

缺点:

  • pop时只能获得最近一次其他线程所插入的一个行,假如在线程1push行a后又有线程3push或pop了其他行,那么线程1仍然不知道行a已经被插入。因此实践中需要使用List等可随机访问的结构来在所有历史中查找已插入的行(这相当于直接去数据库查表)
  • 控制stack的容量使其不应该比表中所有行还多,也不应该比单次任务所插入的行数还少(而这两个都是运行时变量不可能提前预知),实践中应该能够缓存最近数次或时间上最近数分钟(同样需要根据运行时tunning)内完成的任务所插入的行
  • 即便容量再大也仍然无法从理论上避免这类冲突(频繁查表也不能)

5.2PC(两阶段提交)或2PL(两阶段锁)以协调任务

6.外部程序控制锁以协调任务,如各类message queue或zookeeper

由于我对分布式/并行计算理论不甚了解,所以希望v2ex的各位v友们能够补充更多理论上以及实践中如何处理这类问题的方案

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions