Skip to content Skip to main navigation Skip to footer

Linux:江南白衣的“关于Redis的常识”

转载按:本文说是常识,实在非常深入,对于Redis的使用者来说是不可多得的佳文。

版本:V3.0 2013-6-29 (江南白衣版权所有,转载请保留出处)

1. Overview

1.1 资料

1.2 优缺点

非常非常的快,有测评说比Memcached还快(当大家都是单CPU的时候),而且是无短板的快,读写都一般的快,所有API都差不多快,也没有MySQL Cluster、MongoDB那样更新同一条记录如Counter时慢下去的毛病。

丰富的数据结构,超越了一般的Key-Value数据库而被认为是一个数据结构服务器。组合各种结构,限制Redis用途的是你自己的想象力。

因为是个人作品,Redis目前只有2.3万行代码,Keep it simple的死硬做法,使得普通公司而不需淘宝那个级别的文艺公司也可以吃透它。Redis宣言就是作者的自白,我最喜欢其中的“代码像首诗”,”设计是一场与复杂性的战斗“,“Coding是一件艰苦的事情,唯一的办法是享受它。如果它已不能带来快乐就停止它。为了防止这一天的出现,我们要尽量避免把Redis往乏味的路上带。”

让人又爱又恨的单线程架构,使得代码不用处理平时最让人头痛的并发而大幅简化,但也带来CPU的瓶颈,而且单线程被慢操作所阻塞时,其他请求的延时变得不确定。

那Redis不是什么?

  • Redis 不是Big Data,数据都在内存中,无法以T为单位。
  • 在Redis-Cluster发布并被稳定使用之前,Redis没有真正的平滑水平扩展能力。
  • Redis 不支持Ad-Hoc Query,提供的只是数据结构的API,没有SQL一样的查询能力。

1.3 Feature速览

  • 所有数据都在内存中。
  • 五种数据结构:String / Hash / List / Set / Ordered Set。
  • 数据过期时间支持。
  • 不完全的事务支持。
  • 服务端脚本:使用Lua Script编写,类似存储过程的作用。
  • PubSub:捞过界的消息一对多发布订阅功能,起码Redis-Sentinel使用了它。
  • 持久化:支持定期导出内存的Snapshot 与 记录写操作日志的Append Only File两种模式。
  • Replication:Master-Slave模式,Master可连接多个只读Slave,暂无专门的Geographic Replication支持。
  • Fail-Over:Redis-Sentinel节点负责监控Master节点,在master失效时提升slave,独立的仲裁节点模式有效防止脑裂。
  • Sharding:开发中的Redis-Cluser。
  • 动态配置:所有参数可用命令行动态配置不需重启,并重新写回配置文件中,对云上的大规模部署非常合适。

1.4 八卦

  • 作者是意大利的Salvatore Sanfilippo(antirez),又是VMWare大善人聘请了他专心写Redis。
  • antirez和我一样不喜欢搞什么咨询服务,不过最近VMWare旗下的Pivotal公司开始招聘Redis Commericial Engineer。
  • 默认端口6379,是手机按键上MERZ对应的号码,意大利歌女Alessia Merz是antirez和朋友们认为愚蠢的代名词。

2. 数据结构

2.1 Key

  • Key 不能太长,比如1024字节,但antirez也不喜欢太短如”u:1000:pwd”,要表达清楚意思才好。他私人建议用”:”分隔域,用”.”作为单词间的连接,如”comment:1234:reply.to”。
  • Keys,返回匹配的key,支持通配符如 “keys a*” 、 “keys a?c”,但不建议在生产环境大数据量下使用。
  • Sort,对集合按数字或字母顺序排序后返回或另存为list,还可以关联到外部key等。因为复杂度是最高的O(N+M*log(M))(N是集合大小,M 为返回元素的数量),有时会安排到slave上执行。
  • Expire/ExpireAt/Persist/TTL,关于Key超时的操作。默认以秒为单位,也有p字头的以毫秒为单位的版本, Redis的内部实现见2.9 过期数据清除。

2.2 String

最普通的key-value类型,说是String,其实是任意的byte[],比如图片,最大512M。 所有常用命令的复杂度都是O(1),普通的Get/Set方法,可以用来做Cache,存Session。

Incr/IncrBy/IncrByFloat/Decr/DecrBy,可以用来做计数器,做自增序列。key不存在时会创建并贴心的设原值为0。IncrByFloat专门针对float,没有对应的decrByFloat版本?用负数啊。

SetNx, 仅当key不存在时才Set。可以用来选举Master或做分布式锁:所有Client不断尝试使用SetNx master myName抢注Master,成功的那位不断使用Expire刷新它的过期时间。如果Master倒掉了key就会失效,剩下的节点又会发生新一轮抢夺。

其他Set指令:

  • SetEx, Set + Expire 的简便写法,p字头版本以毫秒为单位。
  • GetSet, 设置新值,返回旧值。比如一个按小时计算的计数器,可以用GetSet获取计数并重置为0。这种指令在服务端做起来是举手之劳,客户端便方便很多。
  • MGet/MSet/MSetNx, 一次get/set多个key。
  • 2.6.12版开始,Set命令已融合了Set/SetNx/SetEx三者,SetNx与SetEx可能会被废弃。

GetBit/SetBit/BitOp,与或非/BitCount, BitMap的玩法,比如统计今天的独立访问用户数时,每个注册用户都有一个offset,他今天进来的话就把他那个位设为1,用BitCount就可以得出今天的总人数。

Append/SetRange/GetRange/StrLen,对文本进行扩展、替换、截取和求长度,只对特定数据格式如字段定长的有用,json就没什么用。

2.3 Hash

Key-HashMap结构,相比String类型将这整个对象持久化成JSON格式,Hash将对象的各个属性存入Map里,可以只读取/更新对象的某些属性。这样有些属性超长就让它一边呆着不动,另外不同的模块可以只更新自己关心的属性而不会互相并发覆盖冲突。

另一个用法是土法建索引。比如User对象,除了id有时还要按name来查询。可以有如下的数据记录:

  • (String) user:101 -> {“id”:101,”name”:”calvin”…}
  • (String) user:102 -> {“id”:102,”name”:”kevin”…}
  • (Hash) user:index-> “calvin”->101, “kevin” -> 102

底层实现是hash table,一般操作复杂度是O(1),要同时操作多个field时就是O(N),N是field的数量。

2.4 List

List是一个双向链表,支持双向的Pop/Push,江湖规矩一般从左端Push,右端Pop——LPush/RPop,而且还有Blocking的版本BLPop/BRPop,客户端可以阻塞在那直到有消息到来,所有操作都是O(1)的好孩子,可以当Message Queue来用。当多个Client并发阻塞等待,有消息入列时谁先被阻塞谁先被服务。

还有RPopLPushBRPopLPush,弹出来返回给client的同时,把自己又推入另一个list,LLen获取列表的长度。

还有按值进行的操作:LRem(按值删除元素)、LInsert(插在某个值的元素的前后),复杂度是O(N),N是List长度,因为List的值不唯一,所以要遍历全部元素,而Set只要O(log(N))。

按下标进行的操作:下标从0开始,队列从左到右算,下标为负数时则从右到左。

  • LSet ,按下标设置元素值。
  • LIndex,按下标返回元素。
  • LRange,不同于POP直接弹走元素,只是返回列表内一段下标的元素,是分页的最爱。
  • LTrim,限制List的大小,比如只保留最新的20条消息。

复杂度也是O(N),其中LSet的N是List长度,LIndex的N是下标的值,LRange的N是start的值+列出元素的个数,因为是链表而不是数组,所以按下标访问其实要遍历链表,除非下标正好是队头和队尾。LTrim的N是移除元素的个数。

在消息队列中,并没有JMS的ack机制,如果消费者把job给Pop走了又没处理完就死机了怎么办?

  • 解决方法之一是加多一个sorted set,分发的时候同时发到list与sorted set,以分发时间为score,用户把job做完了之后要用ZREM消掉sorted set里的job,并且定时从sorted set中取出超时没有完成的任务,重新放回list。
  • 另一个做法是为每个worker多加一个的list,弹出任务时改用RPushLPop,将job同时放到worker自己的list中,完成时用LREM消掉。如果集群管理(如zookeeper)发现worker已经挂掉,就将worker的list内容重新放回主list。

2.5 Set

Set就是集合,以将可能重复的元素随便放入而Set会自动去重,底层实现也是hash table

2.6 Sorted Set

有序集,元素放入集合时还要提供该元素的分数。

Sorted Set的实现是hash table(element->score, 用于实现ZScore及判断element是否在集合内),和skip list(score->element,按score排序)的混合体。 skip list有点像平衡二叉树那样,不同范围的score被分成一层一层,每层是一个按score排序的链表。

ZAdd/ZRem是O(log(N)),ZRangeByScore/ZRemRangeByScore是O(log(N)+M),N是Set大小,M是结果/操作元素的个数。可见,原本可能很大的N被很关键的Log了一下,1000万大小的Set,复杂度也只是几十不到。当然,如果一次命中很多元素M很大那谁也没办法了。

2.7 事务

Multi(Start Transaction)、Exec(Commit)、Discard(Rollback)实现。 在事务提交前,不会执行任何指令,只会把它们存到一个队列里,不影响其他客户端的操作。在事务提交时,批量执行所有指令。《Redis设计与实现》中的详述

注意,Redis里的事务,与我们平时的事务概念很不一样:

  • 它仅仅是保证事务里的操作会被连续独占的执行。因为是单线程架构,在执行完事务内所有指令前是不可能再去同时执行其他客户端的请求的。
  • 它没有隔离级别的概念,因为事务提交前任何指令都不会被实际执行,也就不存在”事务内的查询要看到事务里的更新,在事务外查询不能看到”这个让人万分头痛的问题。
  • 它不保证原子性——所有指令同时成功或同时失败,只有决定是否开始执行全部指令的能力,没有执行到一半进行回滚的能力。在redis里失败分两种,一种是明显的指令错误,比如指令名拼错,指令参数个数不对,在2.6版中全部指令都不会执行。另一种是隐含的,比如在事务里,第一句是SET foo bar, 第二句是LLEN foo,对第一句产生的String类型的key执行LLEN会失败,但这种错误只有在指令运行后才能发现,这时候第一句成功,第二句失败。还有,如果事务执行到一半redis被KILL,已经执行的指令同样也不会被回滚。

Watch指令,类似乐观锁,事务提交时,如果Key的值已被别的客户端改变,比如某个list已被别的客户端push/pop过了,整个事务队列都不会被执行。

2.8 Lua Script

Redis2.6内置的Lua Script支持,可以在Redis的Server端一次过运行大量逻辑,就像存储过程一样,避免了海量中间数据在网路上的传输。

  • Lua自称是在Script语言里关于快的标准,Redis选择了它而不是流行的JavaScript。
  • 因为Redis的单线程架构,整个Script默认是在一个事务里的。
  • Script里涉及的所有Key尽量用变量,从外面传入,使Redis一开始就知道你要改变哪些key。(but why?)
  • Eval每次传输一整段Script比较费带宽,可以先用Script Load载入script,返回哈希值。然后用EvalHash执行。因为就是SHA-1,所以任何时候执行返回的哈希值都是一样的。
  • 内置的Lua库里还很贴心的带了CJSON,可以处理json字符串。
  • 一段用Redis做Timer的示例代码,下面的script被定期调用,从以触发时间为score的sorted set中取出已到期的Job,放到list中给Client们blocking popup。
    -- KEYS: [1]job:sleeping, [2]job:ready
    -- ARGS: [1]currentTime
    -- Comments: result is the  job id
    local jobs=redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1])
    local count = table.maxn(jobs)
    if count>0  then
      -- Comments: remove from Sleeping Job sorted set
      redis.call('zremrangebyscore', KEYS[1], '-inf', ARGV[1])
      -- Comments: add to the Ready Job list
      -- Comments: can optimize to use lpush id1,id2,... for better performance
      for i=1,count do
        redis.call('lpush', KEYS[2], jobs[i])
      end
    end
    

2.9 过期数据清除

官方文档 与 《Redis设计与实现》中的详述,过期数据的清除从来不容易,为每一条key设置一个timer,到点立刻删除的消耗太大,每秒遍历所有数据消耗也大,Redis使用了一种相对务实的做法:

当client主动访问key会先对key进行超时判断,过时的key会立刻删除。

如果clien永远都不再get那条key呢? 它会在Master的后台,每秒10次的执行如下操作: 随机选取100个key校验是否过期,如果有25个以上的key过期了,立刻额外随机选取下100个key(不计算在10次之内)。可见,如果过期的key不多,它最多每秒回收200条左右,如果有超过25%的key过期了,它就会做得更多,但只要key不被主动get,它占用的内存什么时候最终被清理掉只有天知道。

0 Comments

There are no comments yet

Leave a comment

Your email address will not be published.