Python实现Redis三种CAS事务操作及性能对比
本文详解Python中实现Redis CAS(Compare and Set)的三种方案:基于Pipeline的乐观锁、基于同步装饰器的悲观锁以及Lua脚本原子操作。通过多线程并发测试对比发现,高竞争场景下乐观锁性能急剧下降,而Lua脚本因原子执行特性,在50线程1000次操作下表现最优,显著优于其他两种锁机制。
CAS(Compare and Set,比较并设置)是一种典型的事务操作机制。
简而言之,事务的核心目的是在存取数据库中同一数据时,确保操作的隔离性与原子性,从而维持数据的一致性。
传统关系型数据库如MySQL主要依靠加锁机制来保证数据一致性,典型代表是悲观锁。例如,在执行SELECT FOR UPDATE时,MySQL会对指定数据行加锁,直到事务提交(COMMIT)或回滚(ROLLBACK)。若在此期间有其他事务尝试写入该数据,后续事务将被阻塞,直至前一个事务结束。这种机制的弊端在于:持有锁的事务执行越慢,等待解锁的事务阻塞时间越长;此外,还容易引发死锁问题(此前文章已详细讲解死锁成因)。
本文将介绍三种在Redis中实现CAS事务的方法,并解决以下虚拟场景问题:维护一个数值,若该值小于当前时间,则将其更新为当前时间;若该值大于当前时间,则将其更新为当前时间加上30。在单线程环境下,基础代码如下:
# 初始化
r = redis.Redis()
if not r.exists("key_test"):
r.set("key_test", 0)
def inc():
count = int(r.get('key_test')) + 30 #1
# 如果值比当前时间小,则设置为当前时间
count = max(count, int(time.time())) #2
r.set('key_test', count) #3
return count
这段代码在单线程环境中运行良好,但显然无法直接移植到多线程或多进程环境。例如,当进程A和进程B同时执行到#1处时,两者获取了相同的count值,随后执行#2和#3,会导致count值总共只增加了30,而非预期的60。为了在多进程环境下正确运行,我们需要引入其他机制。
py-redis库本身提供了事务操作支持。
Redis提供了几条与事务相关的命令:MULTI、EXEC和WATCH。利用这些命令,可以将多个命令打包,确保它们一次性、按顺序执行,且中间不会被其他命令插入打断。事务从MULTI命令开始,在调用EXEC后触发执行。此外,WATCH命令用于监视任意数量的键;如果在调用EXEC执行事务期间,被监视的任一键被修改,整个事务将不会执行。
以下是使用Redis原生事务解决CAS问题的代码示例。
class CasNormal(object):
def __init__(self, host, key):
self.r = redis.Redis(host)
self.key = key
if not self.r.exists(self.key):
self.r.set(self.key, 0)
def inc(self):
with self.r.pipeline() as pipe:
while True:
try:
#监视一个key,如果在执行期间被修改了,会抛出WatchError
pipe.watch(self.key)
next_count = 30 + int(pipe.get(self.key))
pipe.multi()
if next_count < int(time.time()):
next_count = int(time.time())
pipe.set(self.key, next_count)
pipe.execute()
return next_count
except WatchError:
continue
finally:
pipe.reset()
代码逻辑并不复杂,引入了前文提到的MULTI、EXEC和WATCH命令。熟悉事务操作的读者可以很容易看出,这是一种乐观锁的实现方式(我们假设没有竞争发生,每次获取数据时不加锁,只有在真正有人修改时才处理冲突)。然而,乐观锁在高并发场景下往往显得力不从心,文末的性能对比数据将验证这一点。
接下来介绍基于Redis实现的悲观锁方案。
悲观锁是一种保守策略,每次获取数据时都假设他人也会同时获取,因此先加锁,使用完毕后再释放。Redis本身未直接实现悲观锁,但我们可以利用Redis特性构建一个悲观锁机制。
有了悲观锁的支持,我们的操作便有了保障。基于上述代码,只需添加@synchronized装饰器即可保证同一时间只有一个进程执行。以下是基于悲观锁的解决方案代码。
lock_conn = redis.Redis("localhost")
class CasLock(object):
def __init__(self, host, key):
self.r = redis.Redis(host)
self.key = key
if not self.r.exists(self.key):
self.r.set(self.key, 0)
@synchronized(lock_conn, "lock", 10)
def inc(self):
next_count = 30 + int(self.r.get(self.key))
if next_count < int(time.time()):
next_count = int(time.time())
self.r.set(self.key, next_count)
return next_count
代码量明显减少(得益于引入了synchronized装饰器)。
第三种方案是基于Lua脚本实现。
前两种方法均依赖锁机制,而锁的实现必然涉及竞争处理,区别仅在于竞争发生时的处理策略不同。使用Redis Lua脚本实现CAS操作,可以直接将该操作视为一个<b>原子操作</b>。
我们知道,Redis的一系列操作本身是原子的,且Redis会按顺序执行所有接收到的命令。具体代码如下:
class CasLua(object):
def __init__(self, host, key):
self.r = redis.Redis(host)
self.key = key
if not self.r.exists(self.key):
self.r.set(self.key, 0)
self._lua = self.r.register_script("""
local next_count = redis.call('get',KEYS[1]) + ARGV[1]
ARGV[2] = tonumber(ARGV[2])
if next_count < ARGV[2] then
next_count = ARGV[2]
end
redis.call('set',KEYS[1],next_count)
return tostring(next_count)
""")
def inc(self):
return int(self._lua([self.key], [30, int(time.time())]))
此处先注册了该脚本,后续可直接调用。关于Redis Lua脚本的详细介绍文章众多,感兴趣者可自行搜索,此处不再赘述。
以下是三种方案的性能对比测试。
本次测试较为简单(但足以体现效果差异),测试环境为本地开发机,数据仅供参考数量级。
分别测试了三种操作在单线程、5线程、10线程和50线程情况下,执行1000次操作的耗时,结果如下:
optimistic Lock pessimistic lock lua
1thread 0.43 0.71 0.35
5thread 5.80 3.10 0.62
10thread 17.80 5.60 1.30
50thread 245.00 29.60 6.50
依次对应的是:Redis原生事务实现的乐观锁、基于Redis实现的悲观锁以及Lua脚本实现。
在比较悲观锁和乐观锁之前,需说明一点:本次测试对乐观锁并不完全公平,因为乐观锁的设计初衷是假设并发冲突较少。在单线程环境下,悲观锁表现较差,原因在于多了一次Redis网络交互。随着线程数增加,悲观锁性能逐渐提升,毕竟其设计初衷即为解决高并发竞争环境。在50线程场景下,乐观锁单次操作耗时高达0.245秒,性能极差,若应用于生产环境,几乎不可用。
至于Lua脚本的性能,表现优异,耗时几乎呈线性增长(在50线程场景下,完成1000次操作的总耗时为6.5秒,即在6.5秒内完成了50 * 1000次CAS操作)。
以上测试均在本地Redis环境下进行。若Redis部署在远程服务器,网络交互时间将增加,此时Lua脚本的优势将更加显著。希望对大家的学习有所帮助,也欢迎大家支持本站。
免责声明:本文内容仅供参考,不代表本站观点。转载或引用请注明出处。









