锁存在的意义
在并发环境中,如果有多个线程对某项资源同时进行“写”操作,那么就可能会出现常说的“并发问题”,数据的一致性会被破坏。这时候,需要锁,来防止这种情况发生。锁本质上是限制了多个线程对同一资源在同一时间进行写操作。要实现这个限制,常规的有两种级别: 乐观并发控制和悲观并发控制。
悲观锁与乐观锁
”悲观“是假设: 我在修改数据的时候,一定会有其他人也来改,这样必会导致数据不一致。为了防止别人在我改数据的时候也来捣乱,那么就设计一套机制: 要修改某个数据,先得对这个数据加锁,加锁成功后才能进行操作,加锁失败则就等着别人处理完了再来获取锁。 只有获取到锁的的人,才能进行操作,操作完毕后就把锁释放掉,让下一个继续。
用人话说就是: 我在修改数据的时候你走开,等我处理完了你再来。
悲观锁需要对资源进行锁定,强制所有的操作串行化。串行化的过程中会有加锁/释放锁的消耗。
“乐观”是假设我在修改数据的时候没人会来捣乱,所以我不锁定数据,直接修改数据。如果我发现别人也在修改,那么我自动放弃本次操作,不在“同一时刻”对资源进行操作。
用人话说就是: 我准备改数据,但如果我发现你刚改完,那么我就不改了。
悲观锁可以锁定资源来实现,那么乐观锁怎么实现“你刚改完,那么我就不改了”呢?
常规的办法是通过“版本控制”。 每一次实际的更新操作对应着数据的一个版本变化,从一个旧版本到新版本。
比如,这项资源有个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-> 更新缓存-> 返回数据给请求 -> 完毕
请求 ->读缓存 ->缓存失效 ->获取锁失败-> 等待-> 读最新缓存-> 返回数据给请求 ->完毕
对比
悲观锁相对于乐观锁来说消耗更大,特别是单次锁定的数据比较多的时候,会导致其他更新操作等待,降低系统吞吐量。那么该如何选择锁的类型呢:
- 如果需要很高的响应速度,使用乐观锁,成功就执行,不成功就重试,没有锁定数据的消耗
- 如果冲突频率非常高,建议采用悲观锁,保证成功率。重试过多,代价也会很大
- 如果重试代价大,建议采用悲观锁