基于redis的cas实现

  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耗时一些,但事务的方式要远高于其他两个

  对于这个试验我要做个说明:

  1. 为了减小语言本身多线程并发的开销,我选择了go语言
  2. 测试前做了预热
  3. 没把建立连接的时间算进去
  4. 看似500并发的测试,其实还是受物理机CPU核数影响比较大,所以并不能真正模拟出实际高并发的场景
  5. 两个结果中,网络的延迟应该比redis处理速度占时更多,甚至远多于
  6. 这是一个非正式的测试结果,仅供横向对比
  7. 即使4,5两条成立,依旧不会影响lua脚本更好的结论,因为毕竟同样的功能都跑了50w次,lua要比事务省时间

  最后留下测试代码以供参考: github地址

  

  作者:cz

 

posted @ 2018-01-27 21:07  Anti-Archs  阅读(3257)  评论(1编辑  收藏  举报