cas是我们常用的一种解决并发问题的手段,小到CPU指令集,大到分布式存储,都能看到cas的影子。本文假定你已经充分理解一般的cas方案,如果你还不知道cas是什么,请自行百度
我们在进行关系型数据库的更新操作时,基于cas的更新常常是保证数据业务逻辑语义下的一致性的终极手段,一般用来解决“写偏序”问题。关系型数据库有基于where的条件更新,一些NoSQL也都有对cas的支持,可为什么redis在原生语义上不支持cas操作呢?例如:
setcas key oldvalue newvalue
很多人不理解,redis处理速度本就很快,还需要cas么?我承认redis对于单个指令的处理速度很快,但很多时候我们要解决的是网络问题,和应用程序STW(stop the world,一般指java那种长时间GC)
一旦发生这种问题,形成了 get->判断->停顿 ->set,就可能出现写偏序或者更新丢失,redis也没办法帮你了
为什么redis不支持原生的cas?
这种功能对redis来说实现起来几乎不费力气:原本对数据处理的操作就是基于单线程的,压根不会出现像其他语言的那种内存不可见问题,或者什么性能损失
我找到了09年redis的一个mail list (要FQ),redis的作者Salvatore Sanfilippo 开始解释了为什么他不想加入cas功能,理由是至少没法说服我,社区中很多人也表示“我们只需要关于string类型的cas操作就好啦”。然而时至今日你依旧没有在redis.io的command列表中找到cas操作的踪迹
幸好,我们有两种方式可以自己实现cas,且并不费力
基于Lua脚本的cas实现
目前我们使用的redis版本,都支持lua脚本的执行,并且性能非常好。甚至对于比较复杂的功能,redis-cli还提供了lua脚本的调试工具。下面是我自己实现的一个string的cas功能,相信已经能满足大多数场景了:
local v = redis.call("get", KEYS[1]) local r = 1 if v == KEYS[2] or v == false then redis.call("set", KEYS[1], KEYS[3]) else r = 0 end return {r, v}
不好意思,我用空格代替了换行,因为语句实现在是太简单。此脚本中的KEYS[1](lua的数组从1开始)代表你要修改的key, KEYS[2]代表原值,KEYS[3]代表要修改为的值。最终返回两个值:第一个值为1或者0,1代表修改成功,0代表修改失败,无论成功失败,第二个值会返回原值,这是为了方便你直接在cas失败后重新进行计算,而不需要再get一下
调用时依照一下方式:
eval 'lua脚本' 3 key oldvalue newvalue
但我更建议你将这个脚本加载到redis中,在shell中执行:
> redis-cli script load 'lua脚本'
> "74ff40a09af2913b2651bfbc68d7bab7220daecd"
第二行返回的就是这个脚本的sha1的哈希码,下次调用这个脚本你可以直接:
evalsha 74ff40a09af2913b2651bfbc68d7bab7220daecd 3 key oldvalue newvalue
你可能疑惑脚本中 v==false的意义,原因是,如果你调用redis.call去获取一个不存在的key,会返回false。由于我使用的go-redis中无法把nil作为old value发送给redis (redis-clie也不行),所以这个脚本会在key不存在的情况下cas成功,无论你把oldvalue赋予了什么值。我想这在大多数场景中都不成问题。对于任意语言的redis框架,对应参数传个空字符串就可以了。对于第二个返回值,这种情况下会返回nil, 能被框架成功解析成对应语言的null值(比如go就是nil)
以下是实际的例子, 在redis-cli下:
> evalsha 74ff40a09af2913b2651bfbc68d7bab7220daecd 3 nosuchkey a b
> 1) (integer) 1
> 2) (nil)
> evalsha 74ff40a09af2913b2651bfbc68d7bab7220daecd 3 nosuchkey b c
> 1) (integer) 1
> 2) "b"
基于Watch和Multi的cas实现
如果你尝试过自己搜索一下redis cas的解决方案,我想你看到的大多数文章都是基于“redis 事务”的,即watch和multi。曾经我做面试官的时候,询问面试者一个他们解决方案,我说既然用到了redis,为什么不尝试用“redis 事务”解决一下这个问题。他表示“不知道redis 事务”,而且根据“事务”二字顺理成章的认为“事务会大大影响redis性能”
实际上所谓的redis事务并不像关系型数据库的事务那么复杂,举个例子, 使用了redis 某种语言框架的伪代码:
client = redis.newClient() //创建客户端
client.watch("teacher") // 对应redis的指令 watch teacher
client.multi() // 对应redis的指令 multi
a = client.get("teacher") // 对应redis的指令 get teacher
if a == "annie"
client.set("teacher", "joe") // 对应redis的指令 set teacher joe
else
client.set("teacher", "han") // 对应redis的指令 set teacher han
client.exec() // 对应redis的指令 exec
服务器为每一个被watch的key维护了一个链,当你的客户端执行到watch teacher时,会被加到这个链上去。之后exec之前的所有get, set操作其实仅仅是进入了一个指令队列,待到exec时,如果watch 的key 没发生变更,则一起执行,否则不执行
拿这种机制与数据库事务对比,会发现无论这个所谓的"redis事务"中间隔了多长时间,其实也并不影响其他指令或者事务,而且一旦队列中的指令执行,也是无法插入其他指令的,保证了隔离性
性能上的对比
好了,现在我们有两个方案了,那个更好一点呢?我倾向于lua脚本的方案,一是因为这个脚本相对易读,通用,减小开发人员代码量。二就是因为性能。我进行了两个简单的实验, 基于我的笔记本上的虚拟机中的docker...,虚拟机分配了2核2G内存
单线程实验
三种交互方式:set——直接对测试key进行set操作, cas——通过lua脚本进行set,并且故意设计成一半成功一半不成功,watch——先watch,再set,最后exec
并发数:1
循环次数:10000
跑了若干次的结果:
set | cas | watch |
2.0s-2.3s | 2.1s-2.9s | 4.3s-4.9s |
并发实验
三种交互方式:set——对测试key先get后set操作, cas——先get,再通过lua脚本进行set,watch——先watch,再get,再set,最后exec
并发数:500
循环次数:1000
跑了若干次的结果:
set | cas | watch |
1m13s-1m33s | 1m30s-1m49s | 2m23s-2m32s |
从以上结果可以看出来,在模拟对一个key进行高并发的操作时,lua脚本会略微比set耗时一些,但事务的方式要远高于其他两个
对于这个试验我要做个说明:
- 为了减小语言本身多线程并发的开销,我选择了go语言
- 测试前做了预热
- 没把建立连接的时间算进去
- 看似500并发的测试,其实还是受物理机CPU核数影响比较大,所以并不能真正模拟出实际高并发的场景
- 两个结果中,网络的延迟应该比redis处理速度占时更多,甚至远多于
- 这是一个非正式的测试结果,仅供横向对比
- 即使4,5两条成立,依旧不会影响lua脚本更好的结论,因为毕竟同样的功能都跑了50w次,lua要比事务省时间
最后留下测试代码以供参考: github地址
作者:cz