五种数据结构简介
Redis是使用C编写的,内部实现了一个struct结构体redisObject对象,通过结构体来模仿面向对象编程的“多态”,动态支持不同类型的value。作为一个底层的数据支持,redisObject结构体代码如下定义:
#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */
#define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
typedef struct redisObject {
//对象的数据类型,占4bits,共5种类型
unsigned type:4;
//对象的编码类型,占4bits,共10种类型
unsigned encoding:4;
//least recently used
//实用LRU算法计算相对server.lruclock的LRU时间
unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
//引用计数
int refcount;
//指向底层数据实现的指针
void *ptr;
} robj;
下面介绍type、encoding、ptr3个属性定义枚举。
type:redisObject的类型,字符串、列表、集合、有序集、哈希表
//type的占5种类型:
/* Object types */
#define OBJ_STRING 0 //字符串对象
#define OBJ_LIST 1 //列表对象
#define OBJ_SET 2 //集合对象
#define OBJ_ZSET 3 //有序集合对象
#define OBJ_HASH 4 //哈希对象
encoding:底层实现结构,字符串、整数、跳跃表、压缩列表等
/* Objects encoding. Some kind of objects like Strings and Hashes can be
* internally represented in multiple ways. The 'encoding' field of the object
* is set to one of this fields for this object. */
// encoding 的10种类型
#define OBJ_ENCODING_RAW 0 /* Raw representation */ //原始表示方式,字符串对象是简单动态字符串
#define OBJ_ENCODING_INT 1 /* Encoded as integer */ //long类型的整数
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */ //字典
#define OBJ_ENCODING_ZIPMAP 3 /* Encoded as zipmap */ //不在使用
#define OBJ_ENCODING_LINKEDLIST 4 /* Encoded as regular linked list */ //双端链表,不在使用
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */ //压缩列表
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */ //整数集合
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */ //跳跃表和字典
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */ //embstr编码的简单动态字符串
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */ //由压缩列表组成的双向列表-->快速列表
ptr:实际指向保存值的数据结构
如果一个 redisObject 的 type 属性为 OBJ_LIST,encoding 属性为 REDIS_ENCODING_LINKEDLIST,那么这个对象就是一个 Redis 列表,它的值保存在一个双链表内,而 ptr 指针就指向这个双向链表;如果一个 redisObject 的type属性为OBJ_HASH,encoding 属性REDIS_ENCODING_ZIPMAP,那么这个对象就是一个 Redis 哈希表,它的值保存在一个 zipmap 里,而 ptr 指针就指向这个 zipmap 。
下面这张图片中的OBJ_STRING/OBJ_LIST/OBJ_ZSET/OBJ_HASH/OBJ_SET针对的是redisObject中的type,后面指向的REDIS_ENCODING_INT、REDIS_ENCODING_RAW、REDIS_ENCODING_LINKEDLIST等针对的是encoding字段。
Redis的底层数据结构有以下几种,具体的数据结构原理就不细讲了:
- 简单动态字符串sds(Simple Dynamic String)
- 双向链表(LinkedList)
- 字典(Map)
- 跳跃表(SkipList)
String
字符串对象的底层实现类型如下:
编码—encoding | 对象—ptr |
---|---|
OBJ_ENCODING_RAW | 简单动态字符串实现的字符串对象 |
OBJ_ENCODING_INT | 整数值实现的字符串对象 |
OBJ_ENCODING_EMBSTR | embstr编码的简单动态字符串实现的字符串对象 |
如果一个String类型的value能够保存为整数,则将对应redisObject 对象的encoding修改为REDIS_ENCODING_INT,将对应redisObject对象的ptr值改为对应的数值;如果不能转为整数,保持原有encoding为REDIS_ENCODING_RAW。因此String类型的数据可能使用原始的字符串存储(实际为sds - Simple Dynamic Strings,对应encoding为REDIS_ENCODING_RAW或OBJ_ENCODING_EMBSTR)或者整数存储。
字符串编码存在OBJ_ENCODING_RAW和OBJ_ENCODING_EMBSTR两种,redis会根据value中字符串的大小动态选择。创建一个String类型的redis值,分配空间的代码如下:
RedisObj *o = zmalloc(sizeof(RedisObj)+sizeof(struct sdshdr8)+len+1);
其中:sdshdr8(保存字符串对象的结构)的大小为3个字节,加上1个结束符共4个字节;redisObject的大小为16个字节;一个embstr固定的大小为16+3+1 = 20个字节,因此一个最大的embstr字符串为64-20 = 44字节。创建字符串对象,根据长度使用不同的编码类型--createRawStringObject或createEmbeddedStringObject。当字符串长度大于44字节时,使用createRawStringObject,此时redisobj结构和sdshdr结构(存储具体字符串内容)在内存上是分开的;当字符串长度小于等于44字节时,使用createEmbeddedStringObject,此时redisObj结构和sdshdr结构在内存上是连续的。
List
列表的底层实现有2种:REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_LINKEDLIST,ZIPLIST(压缩列表)相比LINKEDLIST(链接列表)可以节省内存,当创建新的列表时,默认是使用压缩列表作为底层数据结构的。Redis内部会对相关操作做判断,当list的元数小于配置值: hash-max-ziplist-entries 或者elem_value字符串的长度小于 hash-max-ziplist-value, 可以编码成 REDIS_ENCODING_ZIPLIST 类型存储,以节约内存。
压缩列表ziplist结构本身就是一个连续的内存块,由表头、若干个entry节点和压缩列表尾部标识符zlend组成,通过一系列编码规则,提高内存的利用率,使用于存储整数和短字符串。
压缩列表是一系列特殊编码的连续内存块组成的顺序序列数据结构,可以包含任意多个节点(entry),每一个节点可以保存一个字节数组或者一个整数值。
压缩列表数据实现的指针指向的结构如下图所示:
- zlbytes:占4个字节,记录整个压缩列表占用的内存字节数。
- zltail_offset:占4个字节,记录压缩列表尾节点entryN距离压缩列表的起始地址的字节数。
- zllength:占2个字节,记录了压缩列表的节点数量。
- entry[1-N]:长度不定,保存数据。
- zlend:占1个字节,保存一个常数255(0xFF),标记压缩列表的末端。
压缩列表ziplist结构的缺点是:每次插入或删除一个元素时,都需要进行频繁的调用realloc()函数进行内存的扩展或减小,然后进行数据”搬移”,甚至可能引发连锁更新,造成严重效率的损失。
Hash
创建新的Hash类型时,默认也使用ziplist存储value,保存数据过多时,使用hash table。
redisObject对象中存放的是结构体dict,定义如下:
typedefstruct dict {
dictType *type; //指向dictType结构,dictType结构中包含自定义的函数,这些函数使得key和value能够存储任何类型的数据。
void *privdata; //私有数据,保存着dictType结构中函数的参数。
dictht ht[2]; //两张哈希表。用于扩展或收缩
long rehashidx; //rehash的标记,rehashidx==-1,表示没在进行rehash
int iterators; //正在迭代的迭代器数量
} dict;
其中dictht(Redis中哈希表)定义如下:
typedefstruct dictht { //哈希表
dictEntry **table; //数组地址,数组存放着哈希表节点dictEntry的地址。
unsignedlong size; //哈希表table的大小,初始化大小为4
unsignedlong sizemask; //值总是等于(size-1)。
unsignedlong used; //记录哈希表已有的节点(键值对)数量。
} dictht;
其中dictEntry就是存放key和value的结构体。
整体的结构如下:
Set
集合的底层实现也有两种:REDIS_ENCODING_INTSET和REDIS_ENCODING_HT(字典),创建Set类型的key-value时,如果value能够表示为整数,则使用intset类型保存value。否则切换为使用hash table保存各个value(hash table,参考上面Hash的介绍),虽然使用散列表对集合的加入删除元素,判断元素是否存在等操作时间复杂度为O(1),但是当存储的元素是整型且元素数目较少时,如果使用散列表存储,就会比较浪费内存,因此整数集合(intset)类型因为节约内存而存在。
整数集合(intset)结构体定义如下:
typedefstruct intset {
uint32_t encoding; //编码格式,有如下三种格式,初始值默认为INTSET_ENC_INT16
uint32_t length; //集合元素数量
int8_t contents[]; //保存元素的数组,元素类型并不一定是ini8_t类型,柔性数组不占intset结构体大小,并且数组中的元素从小到大排列。
} intset; //整数集合结构
整数集合(intset)类型的编码格式有下面三种:
#define INTSET_ENC_INT16 (sizeof(int16_t)) //16位,2个字节,表示范围-32,768~32,767
#define INTSET_ENC_INT32 (sizeof(int32_t)) //32位,4个字节,表示范围-2,147,483,648~2,147,483,647
#define INTSET_ENC_INT64 (sizeof(int64_t)) //64位,8个字节,表示范围-9,223,372,036,854,775,808~9,223,372,036,854,775,807
intset整数集合之所以有三种表示编码格式的宏定义,是因为根据存储的元素数值大小,能够选取一个最”合适”的类型存储,”合适”可以理解为:既能够表示元素的大小,又可以节省空间。因此,当新添加的元素,例如:65535,超过当前集合编码格式所能表示的范围,就要进行升级操作。
Sorted Set
有序集合的底层编码实现也是2种:REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_SKIPLIST。跳跃表在redis中当数据较多时作为有序集合键的实现方式之一。跳跃表是一个有序链表,其中每个节点包含不定数量的链接,节点中的第i个链接构成的单向链表跳过含有少于i个链接的节点。
跳跃表支持平均O(logN),最坏O(N)复杂度的节点查找,大部分情况下,跳跃表的效率可以和平衡树相媲美。
Redis的持久化
我们知道redis与memcached的一个很大的不同是redis可以将数据持久化到磁盘,能持久化意味着数据的可靠性的提升。
RDB(redis database)是一个磁盘存储的数据库文件,其中保存的是最后一次写入时内存数据的最后状态。由于Redis的数据都存放在内存中,如果没有配置持久化,redis重启后数据就全丢失了,于是需要开启redis的持久化功能,将数据保存到磁盘上,当redis重启后,可以从磁盘中恢复数据。redis提供两种方式进行持久化,一种是RDB持久化(原理是将Reids在内存中的数据库记录定时dump到磁盘上的RDB持久化,这称为“半持久化模式”),另外一种是AOF(append only file)持久化(原理是将Reids的操作日志以追加的方式写入文件,这称为“全持久化模式”)。
RDB的持久化方式通过配置的定时执行间隔定时将内存中的数据写入到一个新的临时RDB文件中,然后用这个临时文件替换上次持久化的RDB文件,如此不断的定时更替。
当redis server重启时,会检查当前配置的持久化方式,如果是AOF(Append Of File)则以AOF数据作为恢复数据,因为AOF备份的准确性往往比RDB更高。如果是只开启了RDB模式的话则会加载最新的RDB文件内容到内存中。
另外redis也提供了手动调用的命令来实施RDB备份,包括阻塞的持久化和非阻塞的持久化。
RDB持久化
RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
RDB持久化的时间间隔可以配置,和该配置项一起配合使用的还有另一个指标“变更次数”,每次时间间隔时必须同时符合“间隔时间”和“变更次数”两个条件才会进行RDB持久化,否则当次的持久化过程会推迟到下一个时间间隔再判断是否符合条件。
AOF持久化
当Redis开启AOF持久化时,每次接收到操作指令后,先将操作命令和数据以格式化的方式追加到操作日志文件的尾部,追加成功后才进行内存数据库的数据变更。这样操作日志文件就保存了所有的历史操作过程。该过程与MySQL的bin.log、zookeeper的txn-log十分相似。
AOF保存的是每次操作的操作序列,相比较而言RDB保存的是数据快照,因此AOF的操作日志文件内容往往比RDB文件大。
需要注意的是,因为linux对文件的写操作采取了“延迟写入”手段,因此redis提供了always、everysec、no三种选择来决定直接调用操作系统文件写入的刷盘动作。
AOF先记录后变更的特性决定了数据的可靠性更高,因此当AOF和RDB持久化都配置时,Redis服务在重启后会优先选择AOF数据作为数据恢复标准。
执行AOF数据恢复时,Redis读取AOF文件中的“操作+数据”集,通过逐条重放的方式恢复内存数据库。
AOF文件会不断增大,它的大小直接影响“故障恢复”的时间,而且AOF文件中历史操作是可以丢弃的。AOF rewrite操作就是“压缩”AOF文件的过程,当然redis并没有采用“基于原aof文件”来重写的方式,而是采取了类似snapshot的方式:基于copy-on-write,全量遍历内存中数据,然后逐个序列到aof文件中。因此AOF rewrite能够正确反应当前内存数据的状态,这正是我们所需要的。rewrite过程中,对于新的变更操作将仍然被写入到原AOF文件中,同时这些新的变更操作也会被redis收集起来(buffer,copy-on-write方式下,最极端的可能是所有的key都在此期间被修改,将会耗费2倍内存),当内存数据被全部写入到新的aof文件之后,收集的新的变更操作也将会一并追加到新的aof文件中,此后将会重命名新的aof文件为appendonly.aof,此后所有的操作都将被写入新的aof文件。如果在rewrite过程中,出现故障,将不会影响原AOF文件的正常工作,只有当rewrite完成之后才会切换文件,因为rewrite过程是比较可靠的。
Redis事务
Redis事务通常会使用MULTI,EXEC,WATCH等命令来完成,redis实现事务的机制与常见的关系型数据库有很大的却别,比如redis的事务不支持回滚,事务执行时会阻塞其它客户端的请求执行等。
事务实现相关的指令
MULTI
用于标记事务块的开始。Redis会将后续的命令逐个放入队列中,每一个指令的返回结果都是“QUEUED”。只有先执行MULTI指令后才能使用EXEC命令原子化地执行这个命令序列。总是返回OK。
EXEC
在一个事务中执行所有先前放入队列的命令,然后恢复正常的连接状态。EXEC指令的返回值是队列中多条指令的有序结果。
当在事务中使用了WATCH命令监控的KEY时,只有当受监控的键没有被修改时,EXEC命令才会执行事务中的队列命令集合。
DISCARD
清除所有先前在一个事务中放入队列的命令,然后恢复正常的连接状态。
如果使用了WATCH命令,那么DISCARD命令就会将当前连接监控的所有键取消监控。
WATCH
watch 用于在进行事务操作的最后一步也就是在执行exec 之前对某个key进行监视,如果这个被监视的key被改动,那么事务就被取消,否则事务正常执行。一般在MULTI 命令前就用watch命令对某个key进行监控。如果当前连接监控的key值被其它连接的客户端修改,那么当前连接的EXEC命令将执行失败。
WATCH命令的作用只是当被监控的键值被修改后阻止事务的执行,而不能保证其他客户端不修改这一键值。
UNWATCH
清除所有先前为一个事务监控的键。执行EXEC命令后会取消对所有键的监控,如果不想执行事务中的命令也可以使用UNWATCH命令来取消监控。UNWATCH命令,清除所有受监控的键。在运行UNWATCH命令之后,Redis连接便可以再次自由地用于运行新事务。
redis事务从开始到结束通常会通过三个阶段:
1)事务开始
2)命令入队
3)事务执行
标记事务的开始,MULTI命令可以将执行该命令的客户端从非事务状态切换成事务状态,这一切换是通过在客户端状态的flags属性中打开REDIS_MULTI标识完成, 在打开事务标识的客户端里,这些命令,都会被暂存到一个命令队列里,不会因为用户的输入而立即执行。客户端打开了事务标识后,只有命令: EXEC, DISCARD, WATCH,MULTI命令会被立即执行,其它命令服务器不会立即执行,而是将这些命令放入到一个事务队列里面,然后向客户端返回一个QUEUED回复 。redis客户端有自己的事务状态,这个状态保存在客户端状态mstate属性中。
事务的ACID性质详解
在redis中事务总是具有原子性(Atomicity),一致性(Consistency)和隔离性(Isolation),并且当redis运行在某种特定的持久化模式下,事务也具有持久性(Durability)。
原子性
事务具有原子性指的是事务中的多个操作当作一个整体来执行,服务器要么就执行事务中的所有操作,要么就一个操作也不执行。但是对于redis的事务功能来说,事务队列中的命令要么就全部执行,要么就一个都不执行,因此redis的事务是具有原子性的(有条件的原子性)。我们通常会知道两种关于redis事务原子性的说法:一种是要么事务都执行,要么都不执行;另外一种说法是redis事务,当事务中的命令执行失败后面的命令还会执行,错误之前的命令不会回滚。其实这个两个说法都是正确的,redis分语法错误和运行错误。
- 语法错误:如果redis出现了语法错误,Redis 2.6.5之前的版本会忽略错误的命令,执行其他正确的命令,2.6.5之后的版本会忽略这个事务中的所有命令,都不执行。
- 运行错误:运行错误表示命令在执行过程中出现错误,比如用GET命令获取一个散列表类型的键值。这种错误在命令执行之前Redis是无法发现的,所以在事务里这样的命令会被Redis接受并执行。如果事务里有一条命令执行错误,其他命令依旧会执行(包括出错之后的命令)。
只有当被调用的Redis命令有语法错误时,这条命令才会执行失败(在将这个命令放入事务队列期间,Redis能够发现此类问题),或者对某个键执行不符合其数据类型的操作:实际上,这就意味着只有程序错误才会导致Redis命令执行失败,这种错误很有可能在程序开发期间发现,一般很少在生产环境发现。
Redis已经在系统内部进行功能简化,这样可以确保更快的运行速度,因为Redis不需要事务回滚的能力。
一致性
事务具有一致性指的是如果在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据库也应该仍然一致的。 “一致”指的是数据符合数据库本身的定义和要求,没有包含非法或者无效的错误数据。redis通过谨慎的错误检测和简单的设计来保证事务一致性。如果遇到运行错误,redis的原子性也不能保证,所以一致性也是有条件的一致性。
隔离性
事务的隔离性指的是即使有多个事务并发在执行,各个事务之间也不会互相影响,并且在并发状态下执行的事务和串行执行的事务产生的结果完全相同。 因为redis使用单线程的方式来执行事务(以及事务队列中的命令),并且服务器保证,在执行事务期间不会对事物进行中断。因此redis的事务总是以串行的方式运行的,并且事务也总是具有隔离性的 。
持久性
事务的持久性指的是当一个事务执行完毕时,执行这个事务所得的结果已经被保持到永久存储介质里面。 因为redis事务不过是简单的用队列包裹起来一组redis命令,redis并没有为事务提供任何额外的持久化功能,所以redis事务的持久性由redis使用的模式决定 :
- 当服务器在无持久化的内存模式下运行时,事务不具有持久性,一旦服务器停机,包括事务数据在内的所有服务器数据都将丢失 ;
- 当服务器在RDB持久化模式下运作的时候,服务器只会在特定的保存条件满足的时候才会执行BGSAVE命令,对数据库进行保存操作,并且异步执行的BGSAVE 不能保证事务数据被第一时间保存到硬盘里面,因此RDB持久化模式下的事务也不具有持久性 ;
- 当服务器运行在AOF持久化模式下,并且appedfsync的选项的值为always时,程序总会在执行命令之后调用同步函数,将命令数据真正的保存到硬盘里面,因此这种配置下的事务是具有持久性的;
- 当服务器运行在AOF持久化模式下,并且appedfsync的选项的值为everysec时,程序会每秒同步一次命令数据到磁盘因为停机可能会恰好发生在等待同步的那一秒内,这种可能造成事务数据丢失,所以这种配置下的事务不具有持久性。
过期数据清除
数据过期时间
通过EXPIRE key seconds命令来设置数据的过期时间。返回1表明设置成功,返回0表明key不存在或者不能成功设置过期时间。key的过期信息以绝对Unix时间戳的形式存储(Redis2.6之后以毫秒级别的精度存储)。这意味着,即使Redis实例没有运行也不会对key的过期时间造成影响。
key被DEL命令删除或者被SET、GETSET命令重置后与之关联的过期时间会被清除。
更新了存储在key中的值而没有用全新的值替换key原有值的所有操作都不会影响在该key上设置的过期时间。例如使用INCR命令增加key的值或者通过LPUSH命令在list中增加一个新的元素或者使用HSET命令更新hash字段的值都不会清除原有的过期时间设置。
若key被RENAME命令重写,比如本存在名为mykey_a和mykey_b的key一个RENAME mykey_b mykey_a命令将mykey_b重命名为本已存在的mykey_a。那么无论mykey_a原来的设置如何都将继承mykey_b的所有特性,包括过期时间设置。
EXPIRE key seconds应用于一个已经设置了过期时间的key上时原有的过期时间将被更新为新的过期时间。
过期数据删除策略--被动方式结合主动方式
当clients试图访问设置了过期时间且已过期的key时,这个时候将key删除再返回空,为主动过期方式。但仅是这样是不够的,因为可能存在一些key永远不会被再次访问到,这些设置了过期时间的key也是需要在过期后被删除的。因此,Redis会周期性的随机测试一批设置了过期时间的key并进行处理。测试到的已过期的key将被删除,这种为被动过期方式。典型的方式为,Redis每秒做10次如下的步骤:
1)随机测试100个设置了过期时间的key
2)删除所有发现的已过期的key
3)若删除的key超过25个则重复步骤1
这是一个基于概率的简单算法,基本的假设是抽出的样本能够代表整个key空间,redis持续清理过期的数据直至将要过期的key的百分比降到了25%以下。这也意味着在任何给定的时刻已经过期但仍占据着内存空间的key的量最多为每秒的写操作量除以4。
redis集群方案
Redis官方集群方案Redis Cluster(P2P模式)
redis 3.0版本开始提供的集群服务,服务端实现的集群。Redis Cluster将所有Key映射到16384个Slot中,集群中每个Redis实例负责一部分,实例之间双向通信。业务程序通过集成的Redis Cluster客户端进行操作。客户端可以向任一实例发出请求,如果所需数据不在该实例中,则该实例引导客户端自动去对应实例读写数据。
redis启动之后,用户必须开启集群模式,通过cluster-enabled yes 设置。通过执行cluster meet 命令来完成连接各个redis单例服务,redis 节点必须进行槽(slot)指派,这样就建立一个redis 集群了。没有槽指派,集群是不能正常运用起来.
redis 集群是通过分片方式来存储键值的,集群默认将整个redis 数据库分成16384个槽(slot),每个节点必须做槽指派。否则集群处于fail 状态。通过shell命令来指派槽,必须把16384槽都分配到不同节点。
此种方式集群在添加和删除节点时,需通过手动脚本命令进行添加和删除,槽必须需要重新分配。这种集群不能自动发现节点,节点的健康状况,缺乏管理页面监控整个集群的状况。
RedisSharding集群
redis 3.0之前版本的集群方式,是客户端实现集群的方案。建立由N个节点组成一个集群,各redis节点相互独立,不会进行相互通信。客户端预先设置的路由规则,直接对多个Redis实例进行分布式访问。
采用一致性hash算法(将key和节点name同时hashing)将redis 数据散列对应的节点,这样客户端就知道从哪个Redis节点获取数据。当增加或减少节点时,不会产生由于重新匹配造成的rehashing。
客户端实现的集群缺点:
- 各个节点相互独立
- 一个节点挂的,整个集群不可用,因此一般redis节点都主从备份,一但某个节点挂了,备份节点成为master。
- 增加节点时,尽管采用一致性哈希发送,还是会有key匹配不到而丢失,导致缓存被击穿
- 增加节点时,客户端需重新调整路由规则,有多少个客户端业务接入,就有多少个客户端得重新调整。
利用代理中间件实现大规模Redis集群
通过中间代理层实现的集群方案以codis最为经典,codis的结构图如下:
这里以codis为例分析,codis-proxy 是Redis客户端连接的代理服务,客户端通过连接codis-proxy,codis-proxy指定连接后面具体的redis实例。Redis客户端通过zk上的注册信息来获得当前可用的proxy列表,从而保证代理的高可用性。
我们为什么选用codis方案作为redis的集群方案,原因如下:
- 整个多台codis-server 就是一个大的存储系统, 实现负责均衡
- 由于dashhoard功能,可通过web界面来管理,观察Codis集群的状态,做到可视化操作,添加/删除组、数据分片、添加/删除redis实例等操作。
- 支持热扩容。即:在不停止服务的情况下,实现集群设备的增减。
- 数据在迁移过程中,不需要停机等待迁移完成,数据平滑的迁移到新的节点,客户端可以正常通过Proxy访问节点数据,用户正常访问,无感知。
- 高可性:通过codis-ha会自动观察发现某组master出现异常,就会将改组中节点的salve为master,实现codis-server的主从切换。
redis 典型使用
典型使用场景简介
场景一:显示最新的列表; 使用功能:Redis中的列表
在Web应用中,“列出最新的回复”之类的查询非常普遍,这通常会带来可扩展性问题。类似的问题就可以用Redis来解决。比如说,我们的一个Web应用想要列出用户贴出的最新20条评论。在最新的评论边上我们有一个“显示全部”的链接,点击后就可以获得更多的评论。
我们假设数据库中的每条评论都有一个唯一的递增的ID字段。我们可以使用分页来制作主页和评论页,使用Redis的模板:
1)每次新评论发表时,我们会将它的ID添加到一个Redis列表:
LPUSH latest.comments <ID>
2)我们将列表裁剪为指定长度,因此Redis只需要保存最新的5000条评论:
LTRIM latest.comments 05000
3)每次我们需要获取最新评论的项目范围时,我们调用一个函数来完成(使用伪代码):
FUNCTION get_latest_comments(start,num_items):
id_list = redis.lrange("latest.comments",start,start+num_items-1)
IF id_list.length < num_items
id_list = SQL_DB("SELECT ... ORDER BY time LIMIT ...")
END
RETURN id_list
END
我们做了限制不能超过5000个ID,因此我们的获取ID函数会一直询问Redis。只有在start/count参数超出了这个范围的时候,才需要去访问数据库。
我们的系统不会像传统方式那样“刷新”缓存,Redis实例中的信息永远是一致的。SQL数据库(或是硬盘上的其他类型数据库)只是在用户需要获取“很远”的数据时才会被触发,而主页或第一个评论页是不会麻烦到硬盘上的数据库了。
场景二:删除与过滤; 使用功能: Redis中的集合
比如邮箱的垃圾邮件功能,包含特定词或者来自特定发送方。
有些时候你想要给不同的列表附加上不同的过滤器。如果过滤器的数量有限,你可以简单的为每个不同的过滤器使用不同的Redis列表。
场景三:根据某个属性进行排名之类; 使用功能:Redis的有序集合
另一个很普遍的需求是各种数据库的数据并非存储在内存中,因此在大数据量场景下按得分排序,数据库的性能不够理想。
典型的比如那些在线游戏的排行榜,根据得分你通常想要:
- 列出前100名高分选手
- 列出某用户当前的全球排名
如果用数据库的的order by排序,这种相应时间非常长,无法支持大并发请求。但是这些操作对于Redis来说小菜一碟,即使你有几百万个用户,每分钟都会有几百万个新的得分。
向有序集合添加一个或多个成员,或者更新已存在成员的分数:
ZADD key score1 member1 [score2 member2]
得到前100名高分用户很简单:
ZREVRANGE key 0 99
用户的全球排名也相似,只需要:
ZRANK key
场景四:过期项目处理 ; 使用功能:Redis的有序集合和过期时间
另一种常用的项目排序是按照时间排序。并且只需要保留一定时间内的数据。
这时我们可以使用current_time(unix时间)作为得分,用Redis的有序集合来存储。并同时通过expire设置time_to_live。
场景五:计数; 使用功能:Redis的原子操作
Redis是一个很好的计数器,这要感谢INCRBY和其他相似命令。可以用于分布式场景下的全局计数器。我相信你曾许多次想要给数据库加上新的计数器,用来获取统计或显示新信息,但是最后却由于写入敏感而不得不放弃它们。现在使用Redis就不需要再担心了。有了原子递增(atomic increment),你可以放心的加上各种计数,用GETSET重置,或者是让它们过期。
**场景六:特定时间内的特定项目; 使用功能:redis的有序集合 **
另一项对于其他数据库很难,但Redis做起来却轻而易举的事就是统计在某段特点时间里有多少特定用户访问了某个特定资源。比如我想要知道某些特定的注册用户或IP地址,他们到底有多少访问了某篇文章。
每次获得一次新的页面浏览时只需要这样做:
SADD page:day1:<page_id>:<user_id>
当然你可能想用unix时间替换day1,比如time()-(time()%3600*24)等等。
想知道特定用户的数量吗?只需要使用
SCARD page:day1:<page_id>
需要测试某个特定用户是否访问了这个页面
SISMEMBER page:day1:<page_id>
场景七: Pub/Sub; 使用功能:通过watch命令
Redis的Pub/Sub非常非常简单,运行稳定并且快速。支持模式匹配,能够实时订阅与取消频道。你应该已经注意到像list push和list pop这样的Redis命令能够很方便的执行队列操作了,但能做的可不止这些:比如Redis还有list pop的变体命令,能够在列表为空时阻塞队列。
场景八:分布式同步、分布式锁; 使用功能:锁(访问同一个key实现)
从redis获取值N,对数值N进行边界检查,自加1,然后N写回redis中。 这种应用场景很常见,像秒杀,全局递增ID、IP访问限制等。以IP访问限制来说,恶意攻击者可能发起无限次访问,并发量比较大,分布式环境下对N的边界检查就不可靠,因为从redis读的N可能已经是脏数据。传统的加锁的做法(如java的synchronized和Lock)也没用,因为这是分布式环境,这种场景就需要分布式锁。
分布式锁可以基于很多种方式实现,不管哪种方式,他的基本原理是不变的:用一个状态值表示锁,对锁的占用和释放通过状态值来标识。
Redis为单进程单线程模式,采用队列模式将并发访问变成串行访问,且多客户端对Redis的连接并不存在竞争关系。redis的SETNX命令可以方便的实现分布式锁,设置成功,返回 1 ,否则返回 0 。
上面的锁定逻辑有一个问题:如果一个持有锁的客户端失败或崩溃了不能释放锁,该怎么解决?我们可以通过锁的键对应的时间戳来判断这种情况是否发生了,如果当前的时间已经大于锁对应的值,说明该锁已失效,可以被重新使用。
发生这种情况时,不能简单的通过DEL来删除锁,然后再SETNX一次,当多个客户端检测到锁超时后都会尝试去释放它,这里就可能出现一个竞态条件。 为了让分布式锁的算法更稳键些,持有锁的客户端在解锁之前应该再检查一次自己的锁是否已经超时,再去做DEL操作(不是DEL,而是getset命令),这个时候可能已经被其他线程先set值了,通过比较值钱get的值和getset返回的值是否相等,可以判别当前线程是否获得锁。
更多关于分布式锁的实现,请参考Java分布式锁三种实现方案
如何解决缓存击穿
缓存穿透是指查询一个不存在的数据,导致这个不存在的数据每次请求都要到存储层去查询。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
缓存击穿问题一般出现在某个高并发访问的key突然到了过期时间。缓存击穿和缓存雪崩的区别在于前者针对某一key缓存,后者则是很多key。
有人会说不设置过期时间,就不会出现热点key过期问题,也就是“物理”不过期,这样就不存在缓存击穿的问题。从功能上看,如果缓存数据不过期,那就成静态的数据了,理论上也就不需要redis这种缓存。
缓存使用可以从实时性和是否热点两个维度来选择解决缓存击穿方案。
实时性
大家都知道,缓存是从数据中同步过来的,所以它是有延迟的。业务对延迟的容忍度,就是实时性要求。实时性是业务要求,是一个无法妥协的变量。有的延迟可以在秒级别,有的能到分钟级别,有的就是零容忍。不同的实时性要求,就可以采取不同的缓存同步策略。
热点
热点值指的的一个资源(注意不是页面)被同时访问的用户数,这个值比较高时才是热点。热点是技术要求,因为缓存击穿问题基本都是热点引起的,所以在设计缓存方案的时候必须要考虑热点。举例:商品详情页面,假设这个页面的并发量是1w,但其中最大的商品的并发量却可能很低,假设只有50并发。我们认为这个页面不存在热点。这里的热点,指的是资源热点,或者说数据热点。
处理方案(绿色方框)
1)懒加载
先从缓存中取,如果没有则从数据库中取,再放入缓存。
特点:维护成本低、实时性差,命中率低(遇到热点,可能出现数据库击穿的问题)
2)推送
通过独立的任务,周期性的将数据刷入缓存。这里除了任务之外,也可能是一个消息触发。
特点:维护成本适中,实时性适中(周期性任务),命中率100%
推送的方案通常可以结合任务中间件或消息中间件(公司可以考虑我的另一篇文章DRC实战),他们具有更大的灵活性。干预度强,也可以实现降级。
3)懒加载:二级缓存
送数据库获取数据后,放入一个短期缓存和一个长期缓存。在短期缓存过期后,通过加锁控制去数据库加载数据的线程数。没有获得锁的,直接从二级缓存获取数据。
特点:维护成本适中,实时性适中,命中率100%,该方案可以解决动态热点。是推送方案的补充
4)双写
一边写入数据库,一边写入缓存
特点:维护成本最高(侵入代码),实时性高,命中率100%
使用优先级:1)>2)>3)>4)。响应的维护成本越低越优先