乐观锁与悲观锁 # W08

锁存在的意义

在并发环境中,如果有多个线程对某项资源同时进行“写”操作,那么就可能会出现常说的“并发问题”,数据的一致性会被破坏。这时候,需要锁,来防止这种情况发生。锁本质上是限制了多个线程对同一资源在同一时间进行写操作。要实现这个限制,常规的有两种级别: 乐观并发控制和悲观并发控制。

悲观锁与乐观锁

”悲观“是假设: 我在修改数据的时候,一定会有其他人也来改,这样必会导致数据不一致。为了防止别人在我改数据的时候也来捣乱,那么就设计一套机制: 要修改某个数据,先得对这个数据加锁,加锁成功后才能进行操作,加锁失败则就等着别人处理完了再来获取锁。 只有获取到锁的的人,才能进行操作,操作完毕后就把锁释放掉,让下一个继续。

用人话说就是: 我在修改数据的时候你走开,等我处理完了你再来

悲观锁需要对资源进行锁定,强制所有的操作串行化。串行化的过程中会有加锁/释放锁的消耗。

“乐观”是假设我在修改数据的时候没人会来捣乱,所以我不锁定数据,直接修改数据。如果我发现别人也在修改,那么我自动放弃本次操作,不在“同一时刻”对资源进行操作。

用人话说就是: 我准备改数据,但如果我发现你刚改完,那么我就不改了

悲观锁可以锁定资源来实现,那么乐观锁怎么实现“你刚改完,那么我就不改了”呢?

常规的办法是通过“版本控制”。 每一次实际的更新操作对应着数据的一个版本变化,从一个旧版本到新版本。

 比如,这项资源有个version字段,每次成功更新这个字段都会加1。 每次更新都是在一个旧版本的基础上,将它变成新版本。

那么我怎么发现“你也在改呢”?  我准备改的时候会获取数据当前的current_version,如果在准备保存数据的时候,发现数据当前的version比current_version大,那么说明在我保存之前,已经有别人执行了更新操作,那么本次操作会被驳回。反之,没有人改,那么就放心地保存。

乐观控制,没有锁定资源的消耗,吞吐量相对大,失败后,再次重试修改即可。

悲观锁的实现需要“获取锁”的操作是原子性的,只能有一个人能拿到锁。乐观锁的实现需要“检测版本号和保存数据”的操作是原子性的。 这些都依赖计算机或数据库更底层的实现。

那到底乐观锁是怎么实现的呢? 经过资料查阅,发现了CAS, CAS是乐观锁的一种实现方式,全名叫做compare and swap,先做比较,再做交换。有三个变量: V内存位置, A预期值, B更新值, 思路是: 如果位于V地址的值是A,那么就把它更新为B,否则返回失败。 在ES中就是,如果我要更新的数据(V)的current_version值等于我所持有的version(A),那么就把他更新为B(新的version,一般是加1),否则更新失败。CAS一般由CPU指令实现原子性,避免了并发问题。此问题暂时超出楼主的知识储备,这篇文章讲的还不错

乐观锁使用场景

redis 事务中的watch
      WATCH key
      val = GET key
      val = val + 1
      MULTI
      SET key $val
      EXEC

当开始watch某个key之后,redis会监控key对应的数据有没有被修改。 如果在EXEC命令执行之前,key对应的数据被修改了,那么就放弃执行MULTI 和 EXEC之间的命令,不然则顺利执行(redis会保证MULTI和EXEC之间的代码原子性执行)

ElasticSearch中的更新操作

ES中每个文档都会有一个version字段,标记数据当前的版本。更新某片文档之前会得到文档当前的version,当ES保存数据的时候会检测你所持有的version和当前数据的version,如果两个version一致,说明没有人捣乱,可以正常保存。不然会驳回本次修改,因为你所持有的version已经过期了。

通过这样ES保证了最新的数据不会被旧数据覆盖。常见的旧数据就是在拥堵的网络环境中,先进行的网络请求比后进行的网络请求后到达的情况(理论上先发包就先到)。

悲观锁应用

MySQL Update 操作

innodb引擎在更新数据的时候会使用行级锁。要更新某行数据,就先锁定这行数据,下一个更新请求需要等待本次更新操作完毕后才能进行。针对同一行的更新请求完全串行化。

缓存系统

在大流量的系统中,一般都会有一层缓存,来降低DB的实际负荷,实现高吞吐量。

数据获取的流程:

请求-> 读缓存-> 有数据,则直接返回给请求 -> 完毕

请求 -> 读缓存 -> 没有数据,去DB获取数据 -> 更新缓存 -> 返回数据给请求 -> 完毕

在第二个流程中,如果某种数据失效了,但是同时有大量的请求同时过来,发现没有缓存数据,则会同时去DB获取数据。这时DB完全可能因为负荷太高一下子垮掉。 为防止这种情况,这里就需要使用悲观锁。

对 “去DB获取数据和更新缓存”这个操作加上悲观锁,获取到锁的线程,才能进行,没有获取到锁的线程则等待数据更新之后再去读数据。流程大概为:

请求 ->读缓存-> 缓存失效-> 获取锁成功-> 读DB-> 更新缓存-> 返回数据给请求 -> 完毕

请求 ->读缓存 ->缓存失效 ->获取锁失败-> 等待-> 读最新缓存-> 返回数据给请求 ->完毕

对比

悲观锁相对于乐观锁来说消耗更大,特别是单次锁定的数据比较多的时候,会导致其他更新操作等待,降低系统吞吐量。那么该如何选择锁的类型呢:

  • 如果需要很高的响应速度,使用乐观锁,成功就执行,不成功就重试,没有锁定数据的消耗
  • 如果冲突频率非常高,建议采用悲观锁,保证成功率。重试过多,代价也会很大
  • 如果重试代价大,建议采用悲观锁
关键在于根据实际的情况进行权衡取舍。


猜你喜欢

转载自blog.csdn.net/u012671917/article/details/80116329