Redis 简介

Redis 是一个速度非常快的非关系型数据库(non-relational database)/ NoSQL 数据库,是一个高性能的 key-value 数据库。

Redis 不使用表,也不会预定义或者强制去要求用户对 Redis 存储不同的数据进行关联。

Redis具备LRU淘汰、事务实现、以及不同级别的硬盘持久化等能力,并且支持副本集和通过Redis Sentinel实现的高可用方案,同时还支持通过Redis Cluster实现的数据自动分片能力。

Redis 与其他 key - value 缓存产品有以下三个特点:

  • Redis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
  • Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储。
  • Redis支持数据的备份,即master-slave模式的数据备份。

Redis 为什么速度非常快?

  • 纯内存操作
    数据存在内存中,类似于 HashMap。HashMap 的优势就是查找和操作的时间复杂度都是 O(1)
  • 数据结构简单
    不仅数据结构简单,而且对数据操作也简单
  • 单线程
    避免了频繁的上下文切换,也不存在多进程或者多线程导致的切换而消耗 CPU 资源,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗

Redis 优势

  • 性能极高 – Redis能读的速度是110000次/s,写的速度是81000次/s 。(因为使用非阻塞式IO,且大部分命令的算法时间复杂度都是O(1))
  • 丰富的数据类型 – Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets 数据类型操作。
  • 原子 – Redis的所有操作都是原子性的。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。
  • 丰富的特性 – Redis还支持 publish/subscribe, 通知, key 过期等等特性。

Redis 数据结构

Redis 存储键(key)和 5 种不同类型的值(value)之间的映射。这 5 中类型分别为:

结构类型 结构存储的值
STRING 字符串、整数或者浮点数
LIST 一个链表,链表上的每个节点都包含了一个字符串
HASH 包含键值对的无序集合,即map
SET 包含字符串的无序收集器
ZSET 字符串成员(member)与浮点数(score)分值之间的有序映射,
元素的排列顺序由分值大小决定
Bitmap 将String作为Bitmap使用的方法。可以理解为将String转换为bit数组
HyperLogLog 主要用于数量统计,与Set类似,维护一个不可重复的String集合

1. String

String 是 redis 最基本的类型,你可以理解成与 Memcached 一模一样的类型,一个 key 对应一个 value。
String 类型是二进制安全的。意思是 redis 的 String 可以包含任何数据。比如jpg图片或者序列化的对象。
String 类型是 Redis 最基本的数据类型,String 类型的值最大能存储 512MB。

字符串示例,键为 hello,值为 world:image.png

  • 字符串命令: | 命令 | 行为 | 时间复杂度 | | —- | —- | —- | | GET | 获取存储在给定键中的值 | O(1) | | SET | 设置存储在给定键中的值 | O(1) | | DEL | 删除存储在给定键中的值(这个命令可以用于所有类型) | O(1) | | INCR | 自增 | O(1) | | DECR | 自减 | O(1) |

  • 使用

    1. 127.0.0.1:6379> set hello world
    2. OK
    3. 127.0.0.1:6379> get hello
    4. "world"
    5. 127.0.0.1:6379> del hello
    6. (integer) 1
    7. 127.0.0.1:6379> get hello
    8. (nil)
    1. 127.0.0.1:6379> set num 1
    2. OK
    3. 127.0.0.1:6379> incr num
    4. (integer) 2
    5. 127.0.0.1:6379> get num
    6. "2"

2. LIST

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。
列表最多可存储 232 - 1 元素 (4294967295, 每个列表可存储40多亿)。
list-key 是一个包含 3 个元素的列表键,列表中的元素是可以重复的:

image.png

  • 列表命令: | 命令 | 行为 | 时间复杂度 | | —- | —- | —- | | LPUSH | 将给定值推入链表的左端 | O(N) | | RPUSH | 将给定值推入链表的右端 | O(N) | | LRANGE | 获取列表在给定范围上的所有值 | O(N) | | LINDEX | 获取列表在给定位置上的单个元素 | O(N) | | LPOP | 从链表的左端弹出一个值,并返回被弹出的值 | O(1) |

  • 使用

    1. 127.0.0.1:6379> rpush list-key item
    2. (integer) 1
    3. 127.0.0.1:6379> rpush list-key item2
    4. (integer) 2
    5. 127.0.0.1:6379> rpush list-key item
    6. (integer) 3 # 返回的列表长度
    1. 127.0.0.1:6379> lrange list-key 0 -1
    2. 1) "item"
    3. 2) "item2"
    4. 3) "item"
    5. # 使用 0 为范围的开始索引,-1 为范围索引的结束索引,可以去除列表包含的所有元素
    6. 127.0.0.1:6379> lindex list-key 1
    7. "item2"
    1. 127.0.0.1:6379> lpop list-key
    2. "item"
    3. 127.0.0.1:6379> lrange list-key 0 -1
    4. 1) "item2"
    5. 2) "item"

3. HASH

Redis hash 是一个键值(key=>value)对集合。
Redis hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象。
每个 hash 可以存储 232 -1 键值对(40多亿)

hash-key 是一个包含两个键值对的散列键:image.png

  • 散列命令: | 命令 | 行为 | 时间复杂度 | | —- | —- | —- | | HSET | 在散列表中关联其给定的键值对 | O(1) | | HGET | 获取指定散列键的值 | O(1) | | HGETALL | 获取散列包含的所有键值对 | O(N) | | HDEL | 如果给定键存在于散列表中,那么移除这个键 | O(N) |

  • 使用

    1. 127.0.0.1:6379> hset hash-key sub-key1 value1
    2. (integer) 1
    3. 127.0.0.1:6379> hset hash-key sub-key2 value2
    4. (integer) 1
    5. 127.0.0.1:6379> hset hash-key sub-key1 value2
    6. (integer) 0
    7. 127.0.0.1:6379> hgetall hash-key
    8. 1) "sub-key1"
    9. 2) "value2"
    10. 3) "sub-key2"
    11. 4) "value2"
    1. 127.0.0.1:6379> hdel hash-key sub-key2
    2. (integer) 1
    3. 127.0.0.1:6379> hdel hash-key sub-key2
    4. (integer) 0
    5. 127.0.0.1:6379> hget hash-key sub-key1
    6. "value2"
    7. 127.0.0.1:6379> hgetall hash-key
    8. 1) "sub-key1"
    9. 2) "value2"

4. SET

Redis 的 Set 是 不可重复的无序string 集合。
集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
注意:根据集合内元素的唯一性,第二次插入相同的元素将被忽略。
set-key 是一个包含 3 个元素的集合键:image.png

  • 集合命令: | 命令 | 行为 | 时间复杂度 | | —- | —- | —- | | SADD | 将给定元素添加到集合 | O(N) | | SMEMBERS | 返回集合包含的所有元素 | O(N) | | SREM | 如果给定的元素存在于集合中,那么移除这个元素 | O(N) | | SISMEMBER | 检查给定元素是否存在于集合中 | O(1) |

  • 使用

    1. 127.0.0.1:6379> sadd set-key item
    2. (integer) 1
    3. 127.0.0.1:6379> sadd set-key item2
    4. (integer) 1
    5. 127.0.0.1:6379> sadd set-key item3
    6. (integer) 1
    7. 127.0.0.1:6379> sadd set-key item
    8. (integer) 0
    9. 127.0.0.1:6379> smembers set-key
    10. 1) "item"
    11. 2) "item3"
    12. 3) "item2"
    1. 127.0.0.1:6379> sismember set-key item4
    2. (integer) 0
    3. 127.0.0.1:6379> sismember set-key item
    4. (integer) 1
    1. 127.0.0.1:6379> srem set-key item
    2. (integer) 1
    3. 127.0.0.1:6379> srem set-key item
    4. (integer) 0
    5. 127.0.0.1:6379> smembers set-key
    6. 1) "item3"
    7. 2) "item2"

5. ZSET

Redis zset 和 set 一样也是string类型元素的集合,且不允许重复的成员。
不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。

zset的成员是唯一的,但分数(score)却可以重复。

zset-key 是已一个包含 2 个元素的有序集合键:image.png

  • 有序集合命令: | 命令 | 行为 | 时间复杂度 | | —- | —- | —- | | ZADD | 将一个带有给定分值的成员添加到有序集合里面 | O(Mlog(N)) | | ZRANGE | 根据元素在有序排列中所处的位置,从有序集合中获取多个元素 | O(log(N)+M) | | ZRANGEBYSCORE | 获取有序集合在给定范围内的所有元素 | O(log(N)+M) | | ZREM | 如果给定成员存在于有序集合,那么移除这个成员 | O(Mlog(N)) |

  • 使用

    1. 127.0.0.1:6379> zadd zset-key 728 member1
    2. (integer) 1
    3. 127.0.0.1:6379> zadd zset-key 982 member2
    4. (integer) 1
    5. 127.0.0.1:6379> zadd zset-key 982 member2
    6. (integer) 0
    7. 127.0.0.1:6379> zrange zset-key 0 -1 withscores
    8. 1) "member1"
    9. 2) "728"
    10. 3) "member2"
    11. 4) "982"
    1. 127.0.0.1:6379> zrangebyscore zset-key 0 800 withscores
    2. 1) "member1"
    3. 2) "728"
    1. 127.0.0.1:6379> zrem zset-key member1
    2. (integer) 1
    3. 127.0.0.1:6379> zrem zset-key member1
    4. (integer) 0
    5. 127.0.0.1:6379> zrange zset-key 0 -1 withscores
    6. 1) "member2"
    7. 2) "982"

数据持久化

Redis提供了将数据定期自动持久化至硬盘的能力,包括RDB 快照和 AOF 日志两种方案,两种方案分别有其长处和短板,可以配合起来同时运行,确保数据的稳定性。

Redis 4.0 以后将快照和 AOF 混合持久化(默认关闭,使用 aof-user-rdb-preamble开启),AOF 重写时将快照写入 AOF 文件开头。

这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据,当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

数据持久化机制可关闭

Redis的数据持久化机制是可以关闭的。如果你只把Redis作为缓存服务使用,Redis中存储的所有数据都不是该数据的主体而仅仅是同步过来的备份,那么可以关闭Redis的数据持久化机制。
但通常来说,仍然建议至少开启RDB方式的数据持久化,因为:

  • RDB方式的持久化几乎不损耗Redis本身的性能,在进行RDB持久化时,Redis主进程唯一需要做的事情就是fork出一个子进程,所有持久化工作都由子进程完成
  • Redis无论因为什么原因crash掉之后,重启时能够自动恢复到上一次RDB快照中记录的数据。这省去了手工从其他数据源(如DB)同步数据的过程,而且要比其他任何的数据恢复方式都要快
  • 现在硬盘那么大,真的不缺那一点地方

RDB

采用RDB持久方式,Redis会定期保存数据快照至一个rbd文件中,并在启动时自动加载rdb文件,恢复之前保存的数据。

  • 可以在配置文件中配置Redis进行快照保存的时机:

    1. // 意为在[seconds]秒内如果发生了[changes]次数据修改,则进行一次RDB快照保存
    2. save [seconds] [changes]
    3. // 会让Redis每60秒检查一次数据变更情况,若发生了100次或以上的数据变更,则进行RDB快照保存
    4. save 60 100
  • 可以配置多条save指令,让Redis执行多级的快照保存策略。

Redis默认开启RDB快照,默认的RDB策略如下:

  1. save 900 1
  2. save 300 10
  3. save 60 10000
  4. stop-writes-on-bgsave-error yes
  5. # 表示备份进程出错的时候,主进程就停止接收新的写入操作,
  6. # 是为了保护持久化数据的一致性。
  7. rdbcompression no
  8. # RDB 的压缩设置为 no,因为压缩会占用更多的 CPU 资源。
  • 也可以通过BGSAVE命令手工触发RDB快照保存。

客户端向 Redis 发送 BGSAVE 命令来创建一个快照。对于支持 BGSAVE 命令的平台来说(基本上所有平台支持,除了Windows平台),Redis 会调用 fork 来创建一个子进程,然后子进程负责将快照写入硬盘,而主进程则继续处理命令请求

RDB的优点:

  • 对性能影响最小。Redis在保存RDB快照时会fork出子进程进行,几乎不影响Redis处理客户端请求的效率。
  • 每次快照会生成一个完整的数据快照文件,所以可以辅以其他手段保存多个时间点的快照(例如把每天0点的快照备份至其他存储媒介中),作为非常可靠的灾难恢复手段。
  • 使用RDB文件进行数据恢复比使用AOF要快很多。

RDB的缺点:

  • 快照是定期生成的,所以在Redis crash时或多或少会丢失一部分数据。
  • 如果数据集非常大且CPU不够强(比如单核CPU),Redis在fork子进程时可能会消耗相对较长的时间(长至1秒),影响这期间的客户端请求。


AOF

采用AOF持久方式时,Redis会把每一个写请求都记录在一个日志文件里。在Redis重启时,会把AOF文件中记录的所有写操作顺序执行一遍,确保数据恢复到最新。

AOF默认是关闭的,如要开启,进行如下配置:

  1. appendonly yes

AOF提供了三种fsync配置,always/everysec/no,通过配置项[appendfsync]指定:

  • appendfsync no:不进行fsync,将flush文件的时机交给OS决定,速度最快
  • appendfsync always:每写入一条日志就进行一次fsync操作,数据安全性最高,但速度最慢
  • appendfsync everysec:折中的做法,交由后台线程每秒fsync一次



随着AOF不断地记录写操作日志,必定会出现一些无用的日志,例如某个时间点执行了命令SET key1 “abc”,在之后某个时间点又执行了SET key1 “bcd”,那么第一条命令很显然是没有用的。大量的无用日志会让AOF文件过大,也会让数据恢复的时间过长。
所以Redis提供了AOF rewrite功能,可以重写AOF文件,只保留能够把数据恢复到最新状态的最小写操作集。
AOF rewrite可以通过BGREWRITEAOF命令触发,也可以配置Redis定期自动进行

  1. auto-aof-rewrite-percentage 100
  2. auto-aof-rewrite-min-size 64mb
  3. // Redis在每次AOF rewrite时,会记录完成rewrite后的AOF日志大小,
  4. // 当AOF日志大小在该基础上增长了100%后,自动进行AOF rewrite。
  5. // 同时如果增长的大小没有达到64mb,则不会进行rewrite。

AOF的优点:

  • 最安全,在启用appendfsync always时,任何已写入的数据都不会丢失,使用在启用appendfsync everysec也至多只会丢失1秒的数据。
  • AOF文件在发生断电等问题时也不会损坏,即使出现了某条日志只写入了一半的情况,也可以使用redis-check-aof工具轻松修复。
  • AOF文件易读,可修改,在进行了某些错误的数据清除操作后,只要AOF文件没有rewrite,就可以把AOF文件备份出来,把错误的命令删除,然后恢复数据。

AOF的缺点:

  • AOF文件通常比RDB文件更大
  • 性能消耗比RDB高
  • 数据恢复速度比RDB慢

内存管理与数据淘汰机制

最大内存设置

默认情况下,在32位OS中,Redis最大使用3GB的内存,在64位OS中则没有限制。

在使用Redis时,应该对数据占用的最大空间有一个基本准确的预估,并为Redis设定最大使用的内存。否则在64位OS中Redis会无限制地占用内存(当物理内存被占满后会使用swap空间),容易引发各种各样的问题。

通过如下配置控制Redis使用的最大内存:

  1. maxmemory 100mb

在内存占用达到了maxmemory后,再向Redis写入数据时,Redis会:

  • 根据配置的数据淘汰策略尝试淘汰数据,释放空间
  • 如果没有数据可以淘汰,或者没有配置数据淘汰策略,那么Redis会对所有写请求返回错误,但读请求仍然可以正常执行

在为Redis设置maxmemory时,需要注意:

  • 如果采用了Redis的主从同步,主节点向从节点同步数据时,会占用掉一部分内存空间,如果maxmemory过于接近主机的可用内存,导致数据同步时内存不足。所以设置的maxmemory不要过于接近主机可用的内存,留出一部分预留用作主从同步。


    数据过期策略

    定时过期
    每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即对key进行清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
    惰性过期
    只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
    定期过期
    每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。

Redis中同时使用了惰性过期定期过期两种过期策略。

  • 假设Redis当前存放30万个key,并且都设置了过期时间,如果你每隔100ms就去检查这全部的key,CPU负载会特别高,最后可能会挂掉。
  • 因此,redis采取的是定期过期,每隔100ms就随机抽取一定数量的key来检查和删除的。
  • 但是呢,最后可能会有很多已经过期的key没被删除。这时候,redis采用惰性删除。在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间并且已经过期了,此时就会删除。

数据淘汰机制

Redis提供了5种数据淘汰策略:

  • volatile-lru:使用LRU算法进行数据淘汰(淘汰上次使用时间最早的,且使用次数最少的key),只淘汰设定了有效期的key
  • allkeys-lru:使用LRU算法进行数据淘汰,所有的key都可以被淘汰
  • volatile-random:随机淘汰数据,只淘汰设定了有效期的key
  • allkeys-random:随机淘汰数据,所有的key都可以被淘汰
  • volatile-ttl:淘汰剩余有效期最短的key

注意:最好为Redis指定一种有效的数据淘汰策略以配合maxmemory设置,避免在内存用满后发生写入失败的情况。

一般来说,推荐使用的策略是volatile-lru,并辨识Redis中保存的数据的重要性。对于那些重要的,绝对不能丢弃的数据(如配置类数据等),应不设置有效期,这样Redis就永远不会淘汰这些数据。对于那些相对不是那么重要的,并且能够热加载的数据(比如缓存最近登录的用户信息,当在Redis中找不到时,程序会去DB中读取),可以设置上有效期,这样在内存不够时Redis就会淘汰这部分数据。

配置方法:

  1. maxmemory-policy volatile-lru #默认是noeviction,即不进行数据淘汰

Pipelining

Pipelining

Redis是一种基于客户端-服务端模型以及请求/响应协议的TCP服务。这意味着通常情况下一个请求会遵循以下步骤:

  • 客户端向服务端发送一个查询请求,并监听Socket返回,通常是以阻塞模式,等待服务端响应。
  • 服务端处理命令,并将结果返回给客户端。

Redis 管道技术可以在服务端未响应时,客户端可以继续向服务端发送请求,并最终一次性读取所有服务端的响应。

Redis提供许多批量操作的命令,如MSET/MGET/HMSET/HMGET等等,这些命令存在的意义是减少维护网络连接和传输数据所消耗的资源和时间。
例如连续使用5次SET命令设置5个不同的key,比起使用一次MSET命令设置5个不同的key,效果是一样的,但前者会消耗更多的RTT(Round Trip Time)时长,永远应优先使用后者。

然而,如果客户端要连续执行的多次操作无法通过Redis命令组合在一起,例如:

  1. SET a "abc"
  2. INCR b
  3. HSET c name "hi"

此时便可以使用Redis提供的pipelining功能来实现在一次交互中执行多条命令。
使用pipelining时,只需要从客户端一次向Redis发送多条命令(以\r\n)分隔,Redis就会依次执行这些命令,并且把每个命令的返回按顺序组装在一起一次返回,比如:

  1. $ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
  2. +PONG
  3. +PONG
  4. +PONG

大部分的Redis客户端都对Pipelining提供支持,所以开发者通常并不需要自己手工拼装命令列表。

Pipelining的局限性
Pipelining只能用于执行连续且无相关性的命令,当某个命令的生成需要依赖于前一个命令的返回时,就无法使用Pipelining了。

通过Scripting功能,可以规避这一局限性

事务与Scripting

Pipelining能够让Redis在一次交互中处理多条命令,然而在一些场景下,我们可能需要在此基础上确保这一组命令是连续执行的。

比如获取当前累计的PV数并将其清0

  1. > GET vCount
  2. 12384
  3. > SET vCount 0
  4. OK

如果在GET和SET命令之间插进来一个INCR vCount,就会使客户端拿到的vCount不准确。

Redis的事务可以确保复数命令执行时的原子性。也就是说Redis能够保证:一个事务中的一组命令是绝对连续执行的,在这些命令执行完成之前,绝对不会有来自于其他连接的其他命令插进去执行。

Redis 事务可以一次执行多个命令, 并且带有以下三个重要的保证:

  • 批量操作在发送 EXEC 命令前被放入队列缓存。
  • 收到 EXEC 命令后进入事务执行,事务中任意命令执行失败,其余的命令依然被执行。
  • 在事务执行过程,其他客户端提交的命令请求不会插入到事务执行命令序列中。

一个事务从开始到执行会经历以下三个阶段:

  • 开始事务。
  • 命令入队。
  • 执行事务。

通过MULTI和EXEC命令来把这两个命令加入一个事务中,它先以 MULTI 开始一个事务, 然后将多个命令入队到事务中, 最后由 EXEC 命令触发事务, 一并执行事务中的所有命令,Redis会把队列中的所有命令连续顺序执行,并以数组形式返回每个命令的返回结果。:

  1. > MULTI
  2. OK
  3. > GET vCount
  4. QUEUED
  5. > SET vCount 0
  6. QUEUED
  7. > EXEC
  8. 1) 12384
  9. 2) OK

可以使用DISCARD命令放弃当前的事务,将保存的命令队列清空。

需要注意的是,Redis事务不支持回滚
如果一个事务中的命令出现了语法错误,大部分客户端驱动会返回错误,2.6.5版本以上的Redis也会在执行EXEC时检查队列中的命令是否存在语法错误,如果存在,则会自动放弃事务并返回错误。
但如果一个事务中的命令有非语法类的错误(比如对String执行HSET操作),无论客户端驱动还是Redis都无法在真正执行这条命令之前发现,所以事务中的所有命令仍然会被依次执行。在这种情况下,会出现一个事务中部分命令成功部分命令失败的情况,然而与RDBMS不同,Redis不提供事务回滚的功能,所以只能通过其他方法进行数据的回滚。

通过事务实现CAS

Redis提供了WATCH命令与事务搭配使用,实现CAS乐观锁的机制。

假设要实现将某个商品的状态改为已售:

  1. if(exec(HGET stock:1001 state) == "in stock")
  2. exec(HSET stock:1001 state "sold");

这一伪代码执行时,无法确保并发安全性,有可能多个客户端都获取到了”in stock”的状态,导致一个库存被售卖多次。

使用WATCH命令和事务可以解决这一问题:

  1. exec(WATCH stock:1001);
  2. if(exec(HGET stock:1001 state) == "in stock") {
  3. exec(MULTI);
  4. exec(HSET stock:1001 state "sold"); exec(EXEC); }

WATCH的机制是:在事务EXEC命令执行时,Redis会检查被WATCH的key,只有被WATCH的key从WATCH起始时至今没有发生过变更,EXEC才会被执行。如果WATCH的key在WATCH命令到EXEC命令之间发生过变化,则EXEC命令会返回失败。

Scripting

通过EVAL与EVALSHA命令,可以让Redis执行LUA脚本。这就类似于RDBMS的存储过程一样,可以把客户端与Redis之间密集的读/写交互放在服务端进行,避免过多的数据交互,提升性能。

Scripting功能是作为事务功能的替代者诞生的,事务提供的所有能力Scripting都可以做到。Redis官方推荐使用LUA Script来代替事务,前者的效率和便利性都超过了事务。
关于Scripting的具体使用,本文不做详细介绍,请参考官方文档 https://redis.io/commands/eval

Redis性能调优

尽管Redis是一个非常快速的内存数据存储媒介,也并不代表Redis不会产生性能问题。
前文中提到过,Redis采用单线程模型,所有的命令都是由一个线程串行执行的,所以当某个命令执行耗时较长时,会拖慢其后的所有命令,这使得Redis对每个任务的执行效率更加敏感。

针对Redis的性能优化,主要从下面几个层面入手:

  • 最初的也是最重要的,确保没有让Redis执行耗时长的命令
  • 使用pipelining将连续执行的命令组合执行
  • 操作系统的Transparent huge pages功能必须关闭:

    1. echo never > /sys/kernel/mm/transparent_hugepage/enabled
  • 如果在虚拟机中运行Redis,可能天然就有虚拟机环境带来的固有延迟。可以通过./redis-cli --intrinsic-latency 100命令查看固有延迟。同时若对Redis的性能有较高要求的话,应尽可能在物理机上直接部署Redis

  • 检查数据持久化策略
  • 考虑引入读写分离机制

长耗时命令

Redis绝大多数读写命令的时间复杂度都在O(1)到O(N)之间,在文本和官方文档中均对每个命令的时间复杂度有说明。

通常来说,O(1)的命令是安全的,O(N)命令在使用时需要注意,如果N的数量级不可预知,则应避免使用。例如对一个field数未知的Hash数据执行HGETALL/HKEYS/HVALS命令,通常来说这些命令执行的很快,但如果这个Hash中的field数量极多,耗时就会成倍增长。
又如使用SUNION对两个Set执行Union操作,或使用SORT对List/Set执行排序操作等时,都应该严加注意。

避免在使用这些O(N)命令时发生问题主要有几个办法:

  • 不要把List当做列表使用,仅当做队列来使用
  • 通过机制严格控制Hash、Set、Sorted Set的大小
  • 可能的话,将排序、并集、交集等操作放在客户端执行
  • 绝对禁止使用KEYS命令
  • 避免一次性遍历集合类型的所有成员,而应使用SCAN类的命令进行分批的,游标式的遍历

Redis提供了SCAN命令,可以对Redis中存储的所有key进行游标式的遍历,避免使用KEYS命令带来的性能问题。同时还有SSCAN/HSCAN/ZSCAN等命令,分别用于对Set/Hash/Sorted Set中的元素进行游标式遍历。SCAN类命令的使用请参考官方文档:https://redis.io/commands/scan

Redis提供了Slow Log功能,可以自动记录耗时较长的命令。相关的配置参数有两个:

  1. slowlog-log-slower-than xxxms #执行时间慢于xxx毫秒的命令计入Slow Log
  2. slowlog-max-len xxx #Slow Log的长度,即最大纪录多少条Slow Log

使用SLOWLOG GET [number]命令,可以输出最近进入Slow Log的number条命令。
使用SLOWLOG RESET命令,可以重置Slow Log

网络引发的延迟

  • 尽可能使用长连接或连接池,避免频繁创建销毁连接
  • 客户端进行的批量数据操作,应使用Pipeline特性在一次交互中完成。具体请参照本文的Pipelining章节

数据持久化引发的延迟

Redis的数据持久化工作本身就会带来延迟,需要根据数据的安全级别和性能要求制定合理的持久化策略:

  • AOF + fsync always的设置虽然能够绝对确保数据安全,但每个操作都会触发一次fsync,会对Redis的性能有比较明显的影响
  • AOF + fsync every second是比较好的折中方案,每秒fsync一次
  • AOF + fsync never会提供AOF持久化方案下的最优性能
  • 使用RDB持久化通常会提供比使用AOF更高的性能,但需要注意RDB的策略配置
  • 每一次RDB快照和AOF Rewrite都需要Redis主进程进行fork操作。fork操作本身可能会产生较高的耗时,与CPU和Redis占用的内存大小有关。根据具体的情况合理配置RDB快照和AOF Rewrite时机,避免过于频繁的fork带来的延迟

Redis在fork子进程时需要将内存分页表拷贝至子进程,以占用了24GB内存的Redis实例为例,共需要拷贝24GB / 4kB * 8 = 48MB的数据。在使用单Xeon 2.27Ghz的物理机上,这一fork操作耗时216ms。

可以通过INFO命令返回的latest_fork_usec字段查看上一次fork操作的耗时(微秒)

Swap引发的延迟

当Linux将Redis所用的内存分页移至swap空间时,将会阻塞Redis进程,导致Redis出现不正常的延迟。Swap通常在物理内存不足或一些进程在进行大量I/O操作时发生,应尽可能避免上述两种情况的出现。

/proc//smaps文件中会保存进程的swap记录,通过查看这个文件,能够判断Redis的延迟是否由Swap产生。如果这个文件中记录了较大的Swap size,则说明延迟很有可能是Swap造成的。

数据淘汰引发的延迟

当同一秒内有大量key过期时,也会引发Redis的延迟。在使用时应尽量将key的失效时间错开。

引入读写分离机制

Redis的主从复制能力可以实现一主多从的多节点架构,在这一架构下,主节点接收所有写请求,并将数据同步给多个从节点。
在这一基础上,我们可以让从节点提供对实时性要求不高的读请求服务,以减小主节点的压力。
尤其是针对一些使用了长耗时命令的统计类任务,完全可以指定在一个或多个从节点上执行,避免这些长耗时命令影响其他请求的响应。

主从复制与集群分片

主从复制

Redis支持一主多从的主从复制架构。一个Master实例负责处理所有的写请求,Master将写操作同步至所有Slave。
借助Redis的主从复制,可以实现读写分离和高可用:

  • 实时性要求不是特别高的读请求,可以在Slave上完成,提升效率。特别是一些周期性执行的统计任务,这些任务可能需要执行一些长耗时的Redis命令,可以专门规划出1个或几个Slave用于服务这些统计任务
  • 借助Redis Sentinel可以实现高可用,当Master crash后,Redis Sentinel能够自动将一个Slave晋升为Master,继续提供服务

启用主从复制非常简单,只需要配置多个Redis实例,在作为Slave的Redis实例中配置:

  1. slaveof 192.168.1.1 6379 #指定MasterIP和端口

当Slave启动后,会从Master进行一次冷启动数据同步,由Master触发BGSAVE生成RDB文件推送给Slave进行导入,导入完成后Master再将增量数据通过Redis Protocol同步给Slave。之后主从之间的数据便一直以Redis Protocol进行同步

主从复制包括全量复制,增量复制两种。一般当slave第一次启动连接master,或者认为是第一次连接,就采用全量复制,全量复制流程如下:
image.png

  1. slave发送sync命令到master。
  2. master接收到SYNC命令后,执行bgsave命令,生成RDB全量文件。
  3. master使用缓冲区,记录RDB快照生成期间的所有写命令。
  4. master执行完bgsave后,向所有slave发送RDB快照文件。
  5. slave收到RDB快照文件后,载入、解析收到的快照。
  6. master使用缓冲区,记录RDB同步期间生成的所有写的命令。
  7. master快照发送完毕后,开始向slave发送缓冲区中的写命令;
  8. salve接受命令请求,并执行来自master缓冲区的写命令

redis2.8版本之后,已经使用psync来替代sync,因为sync命令非常消耗系统资源,psync的效率更高。

slave与master全量同步之后,master上的数据,如果再次发生更新,就会触发增量复制。

当master节点发生数据增减时,就会触发replicationFeedSalves()函数,接下来在 Master节点上调用的每一个命令会使用replicationFeedSlaves()来同步到Slave节点。执行此函数之前呢,master节点会判断用户执行的命令是否有数据更新,如果有数据更新的话,并且slave节点不为空,就会执行此函数。这个函数作用就是:把用户执行的命令发送到所有的slave节点,让slave节点执行。流程如下:
image.png

Sentinel哨兵模式
哨兵模式就三个作用:

  • 发送命令,等待Redis服务器(包括主服务器和从服务器)返回监控其运行状态;
  • 哨兵监测到主节点宕机,会自动将从节点切换成主节点,然后通过发布订阅模式通知其他的从节点,修改配置文件,让它们切换主机;
  • 哨兵之间还会相互监控,从而达到高可用。

哨兵的工作模式如下:

  1. 每个Sentinel以每秒钟一次的频率向它所知的Master,Slave以及其他Sentinel实例发送一个 PING命令。
  2. 如果一个实例(instance)距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值, 则这个实例会被 Sentinel标记为主观下线。
  3. 如果一个Master被标记为主观下线,则正在监视这个Master的所有 Sentinel 要以每秒一次的频率确认Master的确进入了主观下线状态。
  4. 当有足够数量的 Sentinel(大于等于配置文件指定的值)在指定的时间范围内确认Master的确进入了主观下线状态, 则Master会被标记为客观下线。
  5. 在一般情况下, 每个 Sentinel 会以每10秒一次的频率向它已知的所有Master,Slave发送 INFO 命令。
  6. 当Master被 Sentinel 标记为客观下线时,Sentinel 向下线的 Master 的所有 Slave 发送 INFO 命令的频率会从 10 秒一次改为每秒一次
  7. 若没有足够数量的 Sentinel同意Master已经下线, Master的客观下线状态就会被移除;若Master 重新向 Sentinel 的 PING 命令返回有效回复, Master 的主观下线状态就会被移除。

使用Sentinel做自动failover
Redis的主从复制功能本身只是做数据同步,并不提供监控和自动failover能力,要通过主从复制功能来实现Redis的高可用,还需要引入一个组件:Redis Sentinel

Redis Sentinel是Redis官方开发的监控组件,可以监控Redis实例的状态,通过Master节点自动发现Slave节点,并在监测到Master节点失效时选举出一个新的Master,并向所有Redis实例推送新的主从配置。

Redis Sentinel需要至少部署3个实例才能形成选举关系。

关键配置:

  1. #Master实例的IP、端口,以及选举需要的赞成票数
  2. sentinel monitor mymaster 127.0.0.1 6379 2
  3. #多长时间没有响应视为Master失效
  4. sentinel down-after-milliseconds mymaster 60000
  5. #两次failover尝试间的间隔时长
  6. sentinel failover-timeout mymaster 180000
  7. #如果有多个Slave,可以通过此配置指定同时从新Master进行数据同步的Slave数,
  8. #避免所有Slave同时进行数据同步导致查询服务也不可用
  9. sentinel parallel-syncs mymaster 1

另外需要注意的是,Redis Sentinel实现的自动failover不是在同一个IP和端口上完成的,也就是说自动failover产生的新Master提供服务的IP和端口与之前的Master是不一样的,所以要实现HA,还要求客户端必须支持Sentinel,能够与Sentinel交互获得新Master的信息才行。

集群分片

为何要做集群分片:

  • Redis中存储的数据量大,一台主机的物理内存已经无法容纳
  • Redis的写请求并发量大,一个Redis实例以无法承载

当上述两个问题出现时,就必须要对Redis进行分片了。
Redis的分片方案有很多种,例如很多Redis的客户端都自行实现了分片功能,也有向Twemproxy这样的以代理方式实现的Redis分片方案。然而首选的方案还应该是Redis官方在3.0版本中推出的Redis Cluster分片方案。

本文不会对Redis Cluster的具体安装和部署细节进行介绍,重点介绍Redis Cluster带来的好处与弊端。

Redis Cluster的能力

  • 能够自动将数据分散在多个节点上
  • 当访问的key不在当前分片上时,能够自动将请求转发至正确的分片
  • 当集群中部分节点失效时仍能提供服务

其中第三点是基于主从复制来实现的,Redis Cluster的每个数据分片都采用了主从复制的结构,原理和前文所述的主从复制完全一致,唯一的区别是省去了Redis Sentinel这一额外的组件,由Redis Cluster负责进行一个分片内部的节点监控和自动failover。

Redis Cluster分片原理
Redis Cluster中共有16384个hash slot,Redis会计算每个key的CRC16,将结果与16384取模,来决定该key存储在哪一个hash slot中,同时需要指定Redis Cluster中每个数据分片负责的Slot数。Slot的分配在任何时间点都可以进行重新分配。

客户端在对key进行读写操作时,可以连接Cluster中的任意一个分片,如果操作的key不在此分片负责的Slot范围内,Redis Cluster会自动将请求重定向到正确的分片上。

hash tags
在基础的分片原则上,Redis还支持hash tags功能,以hash tags要求的格式明明的key,将会确保进入同一个Slot中。例如:{uiv}user:1000和{uiv}user:1001拥有同样的hash tag {uiv},会保存在同一个Slot中。

使用Redis Cluster时,pipelining、事务和LUA Script功能涉及的key必须在同一个数据分片上,否则将会返回错误。如要在Redis Cluster中使用上述功能,就必须通过hash tags来确保一个pipeline或一个事务中操作的所有key都位于同一个Slot中。

有一些客户端(如Redisson)实现了集群化的pipelining操作,可以自动将一个pipeline里的命令按key所在的分片进行分组,分别发到不同的分片上执行。但是Redis不支持跨分片的事务,事务和LUA Script还是必须遵循所有key在一个分片上的规则要求。

主从复制 vs 集群分片

在设计软件架构时,要如何在主从复制和集群分片两种部署方案中取舍呢?

从各个方面看,Redis Cluster都是优于主从复制的方案

  • Redis Cluster能够解决单节点上数据量过大的问题
  • Redis Cluster能够解决单节点访问压力过大的问题
  • Redis Cluster包含了主从复制的能力

那是不是代表Redis Cluster永远是优于主从复制的选择呢?

并不是。

软件架构永远不是越复杂越好,复杂的架构在带来显著好处的同时,一定也会带来相应的弊端。采用Redis Cluster的弊端包括:

  • 维护难度增加。在使用Redis Cluster时,需要维护的Redis实例数倍增,需要监控的主机数量也相应增加,数据备份/持久化的复杂度也会增加。同时在进行分片的增减操作时,还需要进行reshard操作,远比主从模式下增加一个Slave的复杂度要高。
  • 客户端资源消耗增加。当客户端使用连接池时,需要为每一个数据分片维护一个连接池,客户端同时需要保持的连接数成倍增多,加大了客户端本身和操作系统资源的消耗。
  • 性能优化难度增加。你可能需要在多个分片上查看Slow Log和Swap日志才能定位性能问题。
  • 事务和LUA Script的使用成本增加。在Redis Cluster中使用事务和LUA Script特性有严格的限制条件,事务和Script中操作的key必须位于同一个分片上,这就使得在开发时必须对相应场景下涉及的key进行额外的规划和规范要求。如果应用的场景中大量涉及事务和Script的使用,如何在保证这两个功能的正常运作前提下把数据平均分到多个数据分片中就会成为难点。

所以说,在主从复制和集群分片两个方案中做出选择时,应该从应用软件的功能特性、数据和访问量级、未来发展规划等方面综合考虑,只在确实有必要引入数据分片时再使用Redis Cluster。
下面是一些建议:

  1. 需要在Redis中存储的数据有多大?未来2年内可能发展为多大?这些数据是否都需要长期保存?是否可以使用LRU算法进行非热点数据的淘汰?综合考虑前面几个因素,评估出Redis需要使用的物理内存。
  2. 用于部署Redis的主机物理内存有多大?有多少可以分配给Redis使用?对比(1)中的内存需求评估,是否足够用?
  3. Redis面临的并发写压力会有多大?在不使用pipelining时,Redis的写性能可以超过10万次/秒(更多的benchmark可以参考 https://redis.io/topics/benchmarks
  4. 在使用Redis时,是否会使用到pipelining和事务功能?使用的场景多不多?

综合上面几点考虑,如果单台主机的可用物理内存完全足以支撑对Redis的容量需求,且Redis面临的并发写压力距离Benchmark值还尚有距离,建议采用主从复制的架构,可以省去很多不必要的麻烦。同时,如果应用中大量使用pipelining和事务,也建议尽可能选择主从复制架构,可以减少设计和开发时的复杂度。

Redis Java客户端的选择

Redis的Java客户端很多,官方推荐的有三种:Jedis、Redisson和lettuce。

在这里对Jedis和Redisson进行对比介绍

Jedis:

  • 轻量,简洁,便于集成和改造
  • 支持连接池
  • 支持pipelining、事务、LUA Scripting、Redis Sentinel、Redis Cluster
  • 不支持读写分离,需要自己实现
  • 文档差(真的很差,几乎没有……)

Redisson:

  • 基于Netty实现,采用非阻塞IO,性能高
  • 支持异步请求
  • 支持连接池
  • 支持pipelining、LUA Scripting、Redis Sentinel、Redis Cluster
  • 不支持事务,官方建议以LUA Scripting代替事务
  • 支持在Redis Cluster架构下使用pipelining
  • 支持读写分离,支持读负载均衡,在主从复制和Redis Cluster架构下都可以使用
  • 内建Tomcat Session Manager,为Tomcat 6/7/8提供了会话共享功能
  • 可以与Spring Session集成,实现基于Redis的会话共享
  • 文档较丰富,有中文文档

对于Jedis和Redisson的选择,同样应遵循前述的原理,尽管Jedis比起Redisson有各种各样的不足,但也应该在需要使用Redisson的高级特性时再选用Redisson,避免造成不必要的程序复杂度提升。

Jedis:
github:https://github.com/xetorthio/jedis
文档:https://github.com/xetorthio/jedis/wiki

Redisson:
github:https://github.com/redisson/redisson
文档:https://github.com/redisson/redisson/wiki

数据一致性解决

更新数据库时,可能会发生数据不一致的问题,数据库和缓存的数据不一致。

一般的策略是当更新数据时,先删除缓存数据,然后更新数据库,而不是更新缓存,等要查询的时候才把最新的数据更新到缓存

扩展

Redis 与 Memcached

两者都是非关系型内存键值数据库,主要有以下不同:

数据类型

  • Redis 支持五种不同的数据类型,可以更灵活地解决问题
  • Memcached 仅支持字符串类型

数据持久化

  • Redis 支持两种持久化策略:RDB 快照和 AOF 日志
  • Memcached 不支持持久化。

分布式

  • Redis Cluster 实现了分布式的支持
  • Memcached 不支持分布式,只能通过在客户端使用一致性哈希来实现分布式存储,这种方式在存储和查询时都需要先在客户端计算一次数据所在的节点。

内存管理机制

  • 在 Redis 中,并不是所有数据都一直存储在内存中,可以将一些很久没用的 value 交换到磁盘,而 Memcached 的数据则会一直在内存中。
  • Memcached 将内存分割成特定长度的块来存储数据,以完全解决内存碎片的问题。但是这种方式会使得内存的利用率不高,例如块的大小为 128 bytes,只存储 100 bytes 的数据,那么剩下的 28 bytes 就浪费掉了。

线程模型

  • Redis 使用单线程的多路 IO 复用模型
  • Memcached 是多线程,非阻塞IO复用的网络模型