Optimistic Locking Pessimistic Locking#

什么是分布式锁#

分布式锁是一种在分布式系统中对共享资源进行同步访问控制的机制. 它确保在同一时间只有一个进程或线程可以访问共享资源, 防止多个进程同时修改数据而导致的数据不一致问题. 分布式锁通过将锁状态保存在一个所有进程都可以访问的地方 (如分布式缓存或数据库) 来实现.

一个典型的分布式锁应用场景是在线购票系统. 假设有很多用户同时在线购买同一场次的演出门票, 票量有限. 为了防止出现多个用户同时购买同一张票导致超卖的情况, ticketing system 需要使用分布式锁. 当一个用户尝试购买某张票时, 系统会尝试获取对应票的分布式锁. 如果获取锁成功, 该用户就可以独占该票进行接下来的购买操作; 同时其他用户因无法获取锁, 就无法购买这张票, 直到锁被释放.这样通过分布式锁就保证了同一时刻只能有一个用户购买某一张特定的票, 避免了系统超卖门票的问题.

什么是乐观锁和悲观锁#

分布式锁有两种常见的实现, 乐观锁 (Optimistic Locking) 和悲观锁 (Pessimistic Locking). 两者的区别在于对锁的获取方式不同, 乐观锁是先尝试操作, 在操作完成后再检查是否有其他进程对资源进行了修改, 而悲观锁则是先获取锁, 然后再进行操作.

乐观锁的实现#

我们用数据库系统来实现乐观锁时, 需要有一个专门用于乐观锁的字段. 通常这个字段是一个自增的版本号. 举例来说, 当用户准备购票时, 先从数据库中读取票的数据, 同时获得了这个版本号. 然后在确定购买时就更新数据库时将这个版本号加 1. 注意更新数据库的同时时候会检查这个字段跟你之前的版本号是不是一致, 如果不一致说明有人在你之前修改了这个数据, 那么你就需要重新读取数据, 再次尝试购买. 所以本质上乐观锁并不涉及数据库中加行锁 (在关系数据库中由 SELECT … FOR UPDATE 实现), 所以乐观锁本质上不是锁. 大家都比较乐观的觉得冲突的概率不大, 于是先做了再说, 发现冲突再重试. 这也是乐观锁名字的来历.

在用数据库系统来实现乐观锁时数据库系统必须支持这两个功能:

  1. 所有对同一条数据的写操作需要在服务器端按顺序进行, 前一个写操作没有执行完不得执行下一个. 这种隔离机制叫 Isolation Level. 这条在一些非关系, 分布式数据库中并不能保证.

  2. 数据库更新动作需要支持条件判断. 这在 SQL 中是通过 UPDATE ... WHERE version = ... 实现的. 不是所有的数据库都支持这个操作, 例如 Redis 就不支持.

乐观锁的优点:

  • 简单, 只有一个读和一个写操作, 无需频繁加锁 (因为它没有加锁动作), 减少了锁的开销.

  • 适用于并发冲突较少的场景, 性能较好.

  • 非阻塞式, 不会造成线程等待.

乐观锁的缺点:

  • 如果在高并发竞争激烈的场景下, 大量的 worker 大部分时间在不断地重新读取数据 + 重试. 从统计上说, 假设有 K 个 worker 竞争同一个资源, 业务处理的时间是 A, 在无冲突的情况下 A * K 时间内就能搞定, 而有冲突的情况下极端情况下会出现, 第一次尝试消耗了 A * K 时间但只有一个人成功, 第二次尝试消耗了 A * (K - 1), 然后 A * (K - 2), … 最终消耗了 A * K * K / 2 的时间.

悲观锁的实现#

我们用数据库系统来实现悲观锁时, 需要有两个专门用于悲观锁的字段, 一个是锁本身, 一个是上锁的时间. 其中这个锁本身是一个字符串, 我们用 “NA” 来表示它没有上锁, 然后用随机生成的 UUID 来表示已经上锁. 而上锁时间是为了防止这个资源被 worker 上锁后然后 worker 程序崩溃导致锁无法被释放. 我们可以约定一个锁的 expire 时间, 例如 1 分钟 (这个时间基于假设业务操作时间不会超过 1 分钟). 当锁上锁时间超过 1 分钟, 哪怕有锁我们也当没锁.

使用悲观锁的业务流程如下:

  1. 每当 worker 需要获得某个资源时, 先获得这条资源并检查这个锁是不是 NA 或是已过期 (我们后面就简单称之为已上锁或没上锁). 如果已上锁则直接停止整个流程.

  2. 如果发现没上锁, 则使用一个随机生成的 UUID 并更新这条数据. 这个操作在 SQL 中是通过 UPDATE ... WHERE lock = 'NA' 实现的. 所以悲观锁也需要数据库更新动作要支持条件判断. 如果更新操作失败, 说明在这个 worker 检查锁, 但还没有上锁前就被别人上锁了.

  3. 这个上锁操作可以继续优化为这个 UPDATE ... WHERE lock = 'NA' OR lock = 'your_UUID'. 这个操作的含义是如果数据库端的锁和 worker 这边的锁相同, 那么说明这个锁就是我上的, 虽然它已经上锁了, 但是我还是可以继续执行业务逻辑, 或者更新锁的时间. 这适合于一个 worker 在 release 锁之前, 自己还想重新更新锁的应用场景.

  4. 当 worker 完成业务逻辑后, 释放锁. 这个操作是通过 UPDATE ... WHERE lock = 'your_UUID' 实现的. 逻辑上这个更新操作不可能失败, 因为如果锁不一样那么在第二步就该停止了.

总结下来, 悲观锁是逻辑意义上的锁, 而不是数据库行锁这种物理意义上的锁. 如果大家不遵守规定, 无视掉了这个锁, 那么这个锁就没有任何意义了. 所以悲观锁的实现需要依赖于业务逻辑, 不能完全依赖于数据库系统.

悲观锁的优点:

  • 确保数据强一致性, 不会出现脏读.

悲观锁的缺点:

  • 并发性能较差, 存在锁竞争问题.

  • 容易出现死锁.

  • 加锁代价大, 性能开销高.

用关系数据库实现乐观锁和悲观锁#

大部分关系数据库都能保证对同一行的更新操作顺序执行, 并且支持 ``UPDATE … WHERE … `` 操作. 所以关系数据库都能实现乐观锁和悲观锁.

用 Amazon DynamoDB 实现乐观锁和悲观锁#

DynamoDB 能保证对同一行的更新操作顺序执行, 并且 Update 操作支持 Condition Expression (和 UPDATE ... WHERE ... 类似). 所以 DynamoDB 能实现乐观锁和悲观锁.

Reference:

用 MongoDB 实现乐观锁和悲观锁#

MongoDB 能保证对同一行的更新操作顺序执行, 并且支持 Query for Update 操作 (和 UPDATE ... WHERE ... 类似). 所以 MongoDB 能实现乐观锁和悲观锁.

Reference: