集群并发下的数据覆盖问题
背景
为保证数据库与缓存的一致性,采用的是数据变更时触发缓存刷新的策略。
具体的业务场景为:当某一运营策略发生变更时,需要刷新该策略有关的所有缓存数据。
如运营新增了一条关联到门店 A、B、C 的策略,则需要分别刷新门店 A、B、C 的缓存。
问题
分布式并发的场景中,极端情况下存在这种可能,导致新的数据被老数据覆盖:
线程 1:
任务一新增策略 a,门店 A 的策略为 A(a)(括号内表示该门店相关的所有策略)
任务一触发刷新任务,读取到门店 A 的策略为 A(a)
线程 2:
任务二新增策略 b,门店 A 的策略为 A(a,b)
任务二触发刷新任务,读取到门店 A 的策略为 A(a,b)
任务二更新门店 A 的缓存,更新为 A(a,b)
线程 1:
任务一更新门店 A 的缓存,更新为 A(a)
以上过程,较老的数据(a)覆盖了新的数据(a,b),由此带来数据并发数据覆盖的问题
方案
以上问题,在于 read 和 flush 操作之间存在间隔,若能保证 read 与 flush 的原子性,即可解决覆盖的问题
方案一 分布式锁
分布式锁是可以解决这个问题的,在 read 之前加锁,在 flush 后解锁,即可保证 read、flush 操作的原子性。
但是使用分布式锁的弊端在于:
1.开发成本高,需要额外开发并且维护一个锁,并且还有锁失效、死锁等诸多问题
2.性能差,采用悲观锁的方式,任何请求来了都得加锁解锁,多增加了两次远程调用
方案二 乐观锁
在方案一悲观锁的铺垫下,尝试使用乐观锁的方式来提高性能。 与悲观锁一样,我们要把 read、flush 的操作做成原子性,就要把他们框到一起,在 read 前面或者同时获取到版本号,在 flush 的同时利用 cas 检查版本号。 在当前场景下,read a 读取的是数据库,我们并不能拿到读取数据库的版本号。所以版本号就存在缓存里面,每次操作+1。
该方案对比悲观锁,减少了一次远程调用。
方案三 其他可能的优化方案
方案二有了一定的性能提升,而当前的场景并非一定要求 read、flush 的原子性,只要能保证新的数据不要被老的刷新即可,所以是否还有性能的提升空间。
比如说:
1.若乐观锁产生了冲突,说明缓存已经被更新为新的数据了,当前任务可以直接放弃这次刷新,而不需要再补偿一次刷新。
2.又或者是,用时间戳记录查询数据的时间,通过时间来判断数据的新老,从而减少没有必要的刷新次数。
以上两点理论上可行,但在实现上各存在一些问题:
1.乐观锁产生了冲突,也有可能是因为刷新了不是最新的数据,毕竟只要刷新了数据,版本号就会更新。如果最新的数据所触发的任务又因此而放弃的话,数据就不是最新的了。这个问题又绕回到:如何知道数据本身的版本号?
2.分布式系统下会产生时钟问题,即无法准确判断远程时间与机器时间的先后,即使当前时间比数据附带的时间大,也不能代表当前操作更晚发生,因为每台机器的时间可能是不一样的。而时钟问题比较好的一个替代方案就是使用单调时间或版本号,即方案二。
其他暂时没有想到好的办法,就先用方案二了。
总结
在解决数据库与缓存一致性问题上,为防止新老数据的覆盖问题,可采用乐观锁版本号的方案。
在缓存 key 上记录版本号,每次操作版本号加一,写入时使用 cas 检查并更新版本号。
版权声明: 本文为 InfoQ 作者【苏格拉格拉】的原创文章。
原文链接:【http://xie.infoq.cn/article/d3f9f7add92e101b0bb7db3fb】。文章转载请联系作者。
评论