redis内存k-v,支持多种数据结构,第一个重点在于如何操作更快和适当的节省内存,第二个重点在于分布式管理。本文redis基于3.0。第一部分将介绍所有内存数据结构实现,关注rehash的实现,对编写内存存储提供数据结构参考没什么框架,单线程,无内存池等复杂设计,基本不支持正规的ACID;还会介绍内存溢出淘汰策略,过期键删除,持久化等功能;第二部分将介绍分布式集群,redis自身的主从模式/哨兵模式/集群模式,常用的codis,公司自研的非开源集群。顺便说了些双机房中redis同步方案,redis应用中应优化的点以及常见的redis热key解决方案。
单机redis/内存数据结构
对象类型 | 编码方式 | 选择条件 | 编码详情 |
string | int | long类型整数 | ptr直接指向整数 |
---|---|---|---|
embstr动态字符串 | 长度<=44 | 数组形式组织sds,len/内存预分配/结尾有\0 | |
动态字符串 | 长度>44 | 链表形式组织sds | |
列表 | 压缩列表 | 长度<64&&元素数<512 | 数组形式组织ziplist |
双端链表 | 长度>=64&&元素数>=512 | 双端链表 | |
quicklist | 3.2版本后 | xx | |
哈希 | 压缩列表 | 长度<64&&元素数<512 | |
字典 | 长度>=64&&元素数>=512 | 两个table/若干桶 | |
集合 | 整数集合 | 元素数<512 | |
字典 | 元素数>=512 | ||
有序集合 | 压缩列表 | 长度<64&&元素数<128 | 分支最小元素/分值 |
跳表 | 长度>=64&&元素数>=128 | 字典+跳表 |
sds
保持0仍然可以使用部分C语言字符串的一些函数
Len 获取长度,保证二进制安全;
多出剩余空间,每次检查free预分配内存,杜绝缓冲区溢出,惰性释放,减少修改字符串带来的内存重分配次数
struct sdshdr {
len = 11;
free = 0;
buf = "hello world\0"; // buf 的实际长度为 len + 1
};
分配内存,删除才释放
# 预分配空间足够,无须再进行空间分配
if (sdshdr.free >= required_len):
return sdshdr
# 计算新字符串的总长度
newlen = sdshdr.len + required_len
# 如果新字符串的总长度小于 SDS_MAX_PREALLOC
# 那么为字符串分配 2 倍于所需长度的空间
# 否则就分配所需长度加上 SDS_MAX_PREALLOC (1M)数量的空间
if newlen < SDS_MAX_PREALLOC:
newlen *= 2
else:
newlen += SDS_MAX_PREALLOC
# 分配内存
newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)
ziplist
压缩列表使用特殊的编码来标识长度,再加上连续的内存,非常节约空间
area |<----------------------------------------------- entry ----------------------->|
size 5 byte 2 bit 6 bit 11 byte
+-------------------------------------------+----------+--------+---------------+
component | pre_entry_length | encoding | length | content |
| | | | |
value | 11111110 00000000000000000010011101100110 | 00 | 001011 | hello world |
+-------------------------------------------+----------+--------+---------------+
Pre_entry_length
1 字节:如果前一节点的长度小于 254 字节,便使用一个字节保存它的值。
5 字节:如果前一节点的长度大于等于 254 字节,那么将第 1 个字节的值设为 254 ,然后用接下来的 4 个字节保存实际长度。
encodinng/length/content
以 00 、 01 和 10 开头的字符数组的编码方式如下:
编码 | 编码长度 | content 部分保存的值 |
---|---|---|
00bbbbbb | 1 byte | 长度小于等于 63 字节的字符数组。 |
01bbbbbb xxxxxxxx | 2 byte | 长度小于等于 16383 字节的字符数组。 |
10____ aaaaaaaa bbbbbbbb cccccccc dddddddd | 5 byte | 长度小于等于 4294967295 的字符数组。 |
具体如何省内存:相比如双向,指针加sds的len,free结尾空,2*4+1+2*4(32位指针和Int都是4字节);压缩链表2/6字节。
添加节点在前面,要更新pre_entry_length,next 的 pre_entry_length 只有 1 字节长,但编码 new 的长度需要 5 字节的时候可能连锁更新。next 的 pre_entry_length 有 5 字节长,但编码 new 的长度只需要 1 字节不做处理。
dict
- MurmurHash2 32 bit 算法:这种算法的分布率和速度都非常好
当插入元素要检查是否应该rehash。渐进式rehash,若在rehash中直接操作Ht[1],否则ht[0] - rehash触发条件:
ratio=used/size
自然 rehash : ratio >= 1 ,且变量 dict_can_resize 为真(非持久化中)。
强制 rehash : ratio 大于变量 dict_force_resize_ratio (目前版本中, dict_force_resize_ratio 的值为 5 )
当字典的填充率低于 10% 时, 程序就可以对这个字典进行收缩操作 - rehash过程
ht[1]大小为0的两倍,rehashidx记录ht[0]的rehash索引位置。
渐进式:在 rehash 开始进行之后(d->rehashidx 不为 -1), 每次执行一次添加、查找、删除操作, _dictRehashStep 都会被执行一次。每次执行 _dictRehashStep , ht[0]->table 哈希表第一个不为空的索引上的所有节点就会全部迁移到 ht[1]->table 。当 Redis 的服务器常规任务执行时, dictRehashMilliseconds 会被执行, 在规定的时间内, 尽可能地对数据库字典中那些需要 rehash 的字典进行 rehash , 从而加速数据库字典的 rehash 进程(progress)。
整数集合
这里的encoding是针对整个intset的。当某元素长度超过时要整体升级编码方式。全存Int因此不需要length。只会升级不会降级。升级过程:
扩展内容。从后开始移动,将新值插入
bit 0 15 31 47 63 95 127
value | 1 | 2 | 3 | ? | 3 | ? |
| ^
| |
+-------------+
int16_t -> int32_t
跳表
相比于平衡二叉树,不需要严格的平衡,随机层数.插入和删除不需要调整性能很高查找略逊色
https://www.cl.cam.ac.uk/teac...
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF)) //这里取小于0xffff的数,有0.25的概率level+1,因此level有1/4概率为2, 1/16的概率为3等等
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
ZSKIPLIST_MAXLEVEL=32
ZSKIPLIST_P=1/4
一个节点的平均层数 = 1/(1-p),Redis 每个节点平均指针数为1.33
平均时间复杂度:O(logn)
zscan在rehash的算法
若进行了rehash,先遍历小hash表的v & t0->sizemask索引指向的链表,再遍历大hash表中该索引rehash后的所有索引链表。
因为sizemask=sizehash-1因此低位全是1,索引取决于hashkey的低K位,
同一个节点的hashkey不变,若原来为8位hash,hashkey为…abcd,原索引计算为bcd,
扩展到16位hash,索引变为abcd,若要找出所有原bcd索引的链表,需要在新的hash中找0bcd,1bcd。
因为要循环高位,所以这样从高位到低位反向来,例如:
000 --> 100 --> 010 --> 110 --> 001 --> 101 --> 011 --> 111 --> 000
0000 --> 1000 --> 0100 --> 1100 --> 0010 --> 1010 --> 0110 --> 1110 --> 0001 --> 1001 --> 0101 --> 1101 --> 0011 --> 1011 --> 0111 --> 1111 --> 0000
当rehash时,可能会有重复,但不会有遗漏
do {
/* Emit entries at cursor */
de = t1->table[v & m1];
while (de) {
fn(privdata, de);
de = de->next;
}
/*这里v从0开始,加1只取前m1-m0位,再与后m0位合并*/
v = (((v | m0) + 1) & ~m0) | (v & m0);
/* Continue while bits covered by mask difference is non-zero */
} while (v & (m0 ^ m1)); //这里异或前m1-m0位全是1,直到while中的v为全1后加1变为全0这里为0退出。因此若原来v为110,8到32,rehash的表将遍历00110,01110,10110,11110
然后是下一个v的确认
v |= ~m0; //m0低位全是0,>m0全是1,将超出m0的置1,只保留低m0位
v = rev(v); //二进制翻转
v++; //加1,正常进位
v = rev(v); //二进制翻转,这步之后相当于将v从高位+1向低位进位
过期key删除
- 惰性删除:在每次获取键或setnx时,调用expireIfNeeded ,过期删除
- 定期删除: 遍历每个数据库,随机从db->expires中最多检查n个Key,过期删除。
每次遍历最多16个库,记录断点
对于利用率低于1%数据库跳过检查,等待缩容后处理
运行超过时长退出
每个数据库连续随机抽样过期key个数<n/4,则执行下一个数据库 - 定期删除位置:
redis的crontab执行过程中,删除执行时长,一般不超cpu的1/4时间(时间可以设置)
事件驱动循环中执行,间隔20ms执行,超过10ms退出(时间可以设置) - 持久化中过期键处理:
RDB 已过期键不会保存到新创建的EDB文件中,载入时主服务器模式过滤过期键,从服务模式不过滤;AOF 生成无影响,重写过滤过期键;复制时 主服务器删除过期键后会给从服务器发DEL,从服务器除遇到DEL不会删除过期键
cache淘汰机制
Redis 用来当做LRU cache的几种策略(使用内存已达到maxmemory):
noeviction:无策略,直接返会异常
allkeys-lru:所有key进行LRU,先移除最久使用的(当前时间,减去最近访问的时间)
allkeys-random:随机移除
volatile-random:只随机移除有过期时间的key
volatile-tt: 优先移除最短ttl的有过期时间的key
近似的LRU。采样逐出(默认5个里淘汰一个)。https://redis.io/topics/lru-c...
4.0后引入LFU(least frequently):大概原理是次数达到一个阶段给个计数器初始值,随时间递减。采样取最小淘汰(源码LFULogIncr)
单机redis/持久化
RDB
- 存储:将redis的内存状态保存到磁盘里面,三种方式: save(阻塞);bgsave(子进程负责创建RDB,不阻塞,期间拒绝save,bgsave,延迟bgrewriteaof);自动保存设置saveparams(校验dirty与saveparam.changes,lastsave与saveparam.seconds)
- 载入:启动时自动执行,已开启AOF载入AOF,否则载入RDB
- RDB文件结构:REDIS | db_version | database(SELECTDB | db_number | [EXPIRETIME_MS | ms] TYPE | key | value) | EOF | check_sum,
value根据TYPE和ENCODING结构不一样,比如无压缩字符串5 hello, 压缩后字符串 REDIS_RDB_ENC_LZF 6 21 "?AA"
AOF
- 存储:AOF持久化功能打开时,执行完一个写命令后,会以协议格式将命令追加到aof_buf缓冲区末尾。
- 写入:在每个外层循环,处理过文件事件和时间事件后将缓冲区内容写入AOF文件
- 同步:是否同步由appendfsync(always,everysec,no)决定,
- 载入:创建伪客户端重新执行一遍AOF文件中的命令
- 重写:BGREWRITEAFO命令,整合为不浪费空间的命令。
原理:遍历数据库,读取键值,用一条set命令代替记录当前键值对。当键值超过了redis.h/REDIS_AOF_REWITE_ITEMS_PER_CMD常量的值,此键值对用多条命令来存储。
非阻塞实现:为了避免使用锁保证数据安全性,用子进程进行AOF重写,父进程继续处理命令,用重写缓冲区解决AOF文件与重写时间段后数据库状态不一致问题,在创建子进程后,所有写命令即写入AOF缓冲区又写入重写缓冲区,将重写缓冲区内容也写入AOF文件,缓冲区和信号等通过管道穿传递。
集群
主从模式 复制 SLAVEOF
接收到SLAVEOF命令执行步骤:
设置masterhost,masterport
发送OK给客户端
创建socket connect到主服务器,主服务器accept
发送ping给主服务器,收到PONG继续否则断开重连
主服务器requirepass,从服务器masterauth
发送端口给主服务器 REPLCONF listening-pot <port-number>
同步SYNC/PSYNC
命令传播
1.SYNC
主服务器BGSAVE命令生成一个RDB文件,并使用缓冲区开始记录写命令
BGSAVE结束后后发送RDB文件给从服务器
从服务器载入
主服务器将和缓冲区中写命令发送给从服务器,从服务器执行
2.命令传播
主服务器将所有写命令传播给从服务器
每秒一次频率向主服务器发送REPLCONF ACK <replication_offset>进行心跳检测。检测网络和命令丢失
主服务器配置min-slaves-to-write n, min-slaves-max-lag m当从服务器数量少于3个,或者延迟大于等于10将拒绝执行写命令
根据replication_offset检测是否丢失命令,补发命令
3.断线后重复制的优化 PSYNC
2.8版本以上redis使用PSYNC命令代替SYNC,断线后使用部分重同步,其他使用SYNC
从服务器向主服务器发送命令:首次PSYNC ? -1 ,断线后重复制 PSYNC <runid> <offset>。主服务器返回:+FULLERSYNC <runid> <offset> ,+CONTINUE , -ERR无法识别从服务器重发SYNC命令
4.上面2/3都是2.8以上才支持,需要用到replication_offset,复制积压缓冲区,服务运行ID
主服务器每次向从服务器传播N个字节,将自己的复制偏移量加N。从服务器每次收到N个字节,将自己的复制偏移量加N
主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入复制积压缓冲区,先进先出
从服务器会记录正在复制的主服务器的运行ID,网络断开后,从服务器向主服务器发送这个ID,主服务器根据自己运行ID决定是部分重同步还是完全同步
哨兵
哨兵系统也是一个或多个特殊的redis服务器,监视普通服务器,负责下线主服务器和故障转移
1.启动
(1)初始化服务器
sentinel不适用数据库,不再如RDB/AOF
(2)将普通redis服务器使用的代码替换成sentinel专用代码
使用不同端口,命令集(只有PING,SENTINEL,INFO.SUBSCRIBE,UNSUBSCRIBE,PSUBSCRIBE,PUNSUBSCRIBE)
(3)初始化sentinel状态
(4)根据给定的配置文件,初始化sentinel的监视主服务器列表
(5)创建连向主服务器的网络连接
命令连接,订阅连接(在建立后发送SUBSCRIBE __sentinel__:hello,sentinel需求通过接收其他服务器发来的频道信息发现未知的sentinel)
2.获取主服务器信息
sentinel默认10s一次向主服务器发INFO命令,获取更新sentinelRedisInstance的run_id,role,slaves的等
3.获取从服务器信息
sentinel会对主服务器的从服务器建立命令连接和订阅连接,也是10s/次发送INFO,更新slaves的sentinelRedisInstance
4.向主服务器和从服务器发送信息
sentinel默认2s/次用命令连接向主服务器和从服务器发送 PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
对每个与sentinel连接的服务器,即发送信息到频道又订阅频道接收信息。收到信息后提取参数检查若是自己的丢弃,否则根据信息更新主服务sentinelRedisInstance中的sentinels,创建连接向其他sentinel的命令连接
5.检测主观下线状态
sentinel默认1s/次的频率向所有主/从/sentinel服务器发送PING命令,有效回复为+PONG,-LOADING,-MASTERDOWN。当一个实例在down-after-milliseconds内,连续向sentinel返回无效回复,sentinel修改实例中flags加入|SRI_S_DOWN标识主观下线
6.检查客观下线状态
如果被sentinel判断为主观下线,sentinel当前配置纪元为0,将向其他sentinel发送命令 SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>
返回
<down_state>
<leader_runid> // *代表命令仅用于检测主服务器的下线状态
<leader_epoch> //前一个只为*则为0
当接收到认为下线的sentinel数量超过quorum(sentinel moniter 127.0.0.1 6379 2中2设置)则flags加|SRI_O_DOWN
7.选举领头Sentinel(raft)
也通过SENTINEL is-master-down-by-addr 看来是要分开进行,带runid。
每个发现主服务器进入客观下线的sentinel向其他sentinal发送命令
在一个配置epoch中将先到的设为局部领头,不能再更改。
接收回复检查epoch的值和自己的相同就取出leader_runid,如果发现自己被半数以上选择,则成为领头,epoch+1
如果在规定时间内未选举成功,epoch+1重新选举
8.故障转移
领头进行故障转移
1) 选出新的主服务器
在线的,5s内回复过INFO的,与主服务器断开连接时间足够短,优先级高,复制偏移量大,runid最小
发送SLAVEOF no one
以1s/次(其他是10s/次)的频率向该服务器发送INFO。当role变为master时继续2
2) 向下线的主服务的其他从服务器发送SLAVEOF命令
3) 向旧的主服务器发送SLAVEOF命令
集群
一个集群由多个node组成,通过分片进行数据共享,CLUSTER MEET <ip> <port>将各阶段加入到cluster
1.启动
一个node就是运行在集群模式下的redis服务器,在启动时若cluster-enabled是yes,则开启服务器的集群模式。
节点继续使用单机模式的服务器组件,只是serverCron函数会调用集群模式特有的clusterCron函数,执行集群的常规操作,例如向集群的其他节点发送Gossip消息,检查节点是否断线,或者检查是否需要对下线的节点进行故障转移操作等。节点数据库和单机完全相同除了智能使用0号出具库这和个限制,另外除了将键值对保存在数据库里边之外,节点还会用clusterState中的slots_to_keys跳跃表来保存槽和键,方便对属于某槽所有数据库键进行批量操作
2.客户点向A发送CLASTER MEET <B.ip> <B.port>
A创建B的clusterNode加入到clusterState.nodes中
发送MEET给B
B返回PONG
A发送PING,握手完成
A将B的信息通过Gossip传播给急群众其他节点
3.槽指派,向节点发送CLUSTER ADDSLOTS <slot> [slot ...]
遍历所有输入槽,如果有已经指派的返回错误,如果都没有指派,再遍历一次:
更新当前lusterState.slots[i]设为Myself
更新自己clusterNode 的slots,numslots属性
将自己的slots数组通过消息发送给集群中其他节点,A收到B后会把自己的clusterState.nodes中查找B对应的clusterNode结构,更新其中的slots数组;更新clusterNode中的slots,numslots属性
维护整体slots目的:查某个槽被哪个节点处理
维护单个节点slots目的:将某节点的所有槽指派信息发送给其他。
4.执行命令
在所有的槽都指派完毕之后,集群就会进入上线状态,这是客户端就可以向集群中的节点发送数据命令了。客户端向节点发送与数据库键相关的命令时,如果键所在的槽正好就指派给了当前节点,那么节点就直接执行命令;如果键所在的槽并没有指派给当前节点,那么节点返回一个MOVED错误,指引客户端(redirect)至正确节点,并再次发送之前想要执行的命令。
1)计算键属于哪个槽 CLUSTER KEYSLOT [key]
CRC16(KEY) & 16383
2) 若计算的i不对应Myself 返回MOVED <slot> <ip>:<port>
3) 客户端根据MOVED错误,转向节点重新发送命令
5.重新分片
redis集群的重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,并且相关的槽所属的键值对也会从源节点转移到目标节点。可以online下。
redis的重新分片操作时由redis的集群管理软件redis-trib负责执行的,redis提供了进重新分片所需的所有命令,而redis-trib则通过向源节点和目标节点发送命令来进行重新分片操作。步骤如下:
1)redis-trib对目标节点发送CLUSTER SETLOT < slot > IMPORTING < source_id> 准备好导入
2)redis-trib对源节点发送CLUSTER SETLOT < slot> MIGRATING < target_id > 准备好迁移
3)redis-trib对源节点发送CLUSTER GETKEYSINSLOT < slot > < count > 获得最多count个属于槽slot的键值对的键名
4)对3中每个键名,redis-trib对源节点发送MIGRATE < key_name> 0 < timeout> 迁移
5)重复3和4,知道槽中的键值对迁移到目标节点
6)redis-trib向任意节点发送CLUSTER SETLOT < slot> NODE < target_id>,将槽指派给目标节点,并通过消息告知整个集群,最终所有节点都会知道槽slot已经指派给了目标节点。
6.ASK错误 处理正在迁移中槽错误
接到ASK错误的客户端会根据错误提供的IP地址和端口号,转向至正在导入槽的目标节点,然后向目标节点发送一个ASKING命令,再重新发送原本想要执行的命令。
ASKING命令加client.flags|=REDIS_ASKING。正常客户端发送一个关于槽i的命令,而槽i又没有指派给这个节点的话,会返回MOVED错误,设置了REDIS_ASKING后,则会破例执行
MOVED错误代表槽的负责权已经从一个节点转移到另一个,每次遇到都自动发到MOVED指向的节点。而ASK只是迁移槽中临时的,下次对下次有影响
7.复制与故障转移
1)复制
redis集群中的节点分为主节点和从节点,其中主节点用于处理槽,而从节点则用于复制主节点,并在被复制的主节点下线之后代替下线的主节点继续处理命令请求。
设置从节点:CLUSTER REPLICATE < node_id> 让接收命令的节点成为node_id的从节点
接收到该命令的节点首先会在自己的clusterState.nodes字典里面找到node_id对应的节点clusterNode结构,并将自己的clusterState.myself.slaveof指针指向这个结构;
节点会修改自己clusterState.myself.flags中的属性,关闭原来的REDIS_NODE_MASTER标识,打开REDIS_NODE_SLAVE标识;
调用复制代码,相当于向从节点发送SLAVEOF <master_ip> <master_port>。
2)故障检测
集群中的每个节点都会定期地向集群中的其他节点发送PING消息,如果规定时间内没有返回PONG,发送消息的节点就会把接受消息的节点标记为疑似下线PFAIL。clusterNode的flags标识(REDIS_NODE_PFAIL)
集群中各节点通过互相发送消息的方式交换集群中各个节点的状态信息,当A通过消息得知B认为C进入疑似下线,A在自己clusterState.nodes中找到C对应的clusterNode结构将B的下线报告添加到该clusterNode的fail_reports中
半数以上主节点都报告x意思下线,则标记为FAIL,将主节点x标记为下线的节点向集群广播FAIL消息,所有接受者都将x标记为FAIL
3)故障转移
当一个从节点发现自己复制的主节点进入了下线状态的时候,从节点将开始对下线主节点进行故障转移,步骤如下:
选举新的主节点
新的主节点执行SLAVEOF no one命令,成为新的主节点
新的主节点将下线主节点的槽指派给自己
新的主节点向集群广播PONG消息,表明自己接管了原来下线节点的槽
新的节点开始接收和自己复制处理槽有关的命令请求。
选举新的主节点
同样基于Raft实现
从节点广播CLUSTERMSG_TYPE_FAILOVER_AUTH_REQUEST,未投过票的主节点返回CLUSTERMSG_TYPE_FAILOVER_AUTH_ACK。配置纪元自增,半数以上。
8.消息
消息由消息头(header)和消息正文(data)组成。
cluster.h/clusterMsg结构表示消息头,cluster.h/clusterMsgData联合体指向消息的正文。
节点消息分为5类:
1)MEET
A接到客户端发送的CLUSTER MEET B命令后,会向B发送MEET消息,B加入到A当前所处的集群里
2)PING
每个节点默认1s/次从已知节点随机选5个,对最长时间未发送PING的节点发送PING,或当有节点超过cluster-node-timeout的一半未收到PONG也发送PING,检查节点是否在线
3)PONG
确认MEET,PING;或主动发送让集群中其他节点立即刷新该节点信息,比如故障转移操作成功后
以上三种消息都使用Gossip协议交换各自不同节点的信息,三种消息的正文都是由两个cluster.h/clusterMsgDataGossip结构组成
发送者从自己已知节点列表中随机选择两个节点(主、从),保存在两个clusterMsgDataGossip结构中。接收者发现节点不在已知节点列表则与节点握手,否则更新信息。注意PONG也会带两个回去
4)FAIL
主节点判断FAIL状态,广播
clusterMsgDataFail。(gossip随机会慢)
5)PUBLISH
当节点收到一个PUBLISH,会执行这个命令并向集群中广播一条PUBLISH。即向集群中某个节点发送PUBLISH <channel> <message>将导致集群中所有节点都向channel频道发送message消息。
要让集群所有节点都执行相同命令,可以广播,但还要用PUBLISH发是因为直接广播这种做法,不符合redis集群的“各个节点通过发送和接收消息来进行通信”这一规则。
clusterMsgDataPublish
原生Gossip过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。每次散播消息都选择尚未发送过的节点进行散播(有冗余)
codis
介绍codis的架构组件,可用性,一致性,扩展性
架构组件
Codis 3.x 由以下组件组成:
- Codis Server:基于 redis-3.2.8 分支开发。增加了额外的数据结构,以支持 slot 有关的操作以及数据迁移指令。具体的修改可以参考文档 redis 的修改。
- Codis Proxy:客户端连接的 Redis 代理服务, 实现了 Redis 协议。 除部分命令不支持以外(不支持的命令列表),表现的和原生的 Redis 没有区别(就像 Twemproxy)。
对于同一个业务集群而言,可以同时部署多个 codis-proxy 实例;
不同 codis-proxy 之间由 codis-dashboard 保证状态同步。 - Codis Dashboard:集群管理工具,支持 codis-proxy、codis-server 的添加、删除,以及据迁移等操作。在集群状态发生改变时,codis-dashboard 维护集群下所有 codis-proxy 的状态的一致性。这里和zk交互保证一致,数据迁移,主从增加等都通过这个
对于同一个业务集群而言,同一个时刻 codis-dashboard 只能有 0个或者1个;
所有对集群的修改都必须通过 codis-dashboard 完成。 - Codis Admin:集群管理的命令行工具。
可用于控制 codis-proxy、codis-dashboard 状态以及访问外部存储。 - Codis FE:集群管理界面。
多个集群实例共享可以共享同一个前端展示页面;
通过配置文件管理后端 codis-dashboard 列表,配置文件可自动更新。 - Storage:为集群状态提供外部存储。
提供 Namespace 概念,不同集群的会按照不同 product name 进行组织;
目前仅提供了 Zookeeper、Etcd、Fs 三种实现,但是提供了抽象的 interface 可自行扩展。
扩展性
- slot
整个集群总共 1024 个 slot (槽)
每个 redis group 对应着slot range (如: 0~127), slot range 存储在 zk 中
每个 key 请求时,crc32(key) % 1024 映射到 个 slot_id,proxy 通过 slot_id 找到对应的 redis group 读写数据
集群的扩容缩容,都是通过 slot 迁移来实现,两段式提交的过程。迁移时并不影响线上单 key 读写访问 - 1.数据迁移migrateslot(slotid,from,dest)
一台机器上有很多redis实例(一个实例一个groupid),迁移:sid=>gid,多个迁移会创建任务队列放zk
zookeeper同步配置,放迁移队列,提供可靠的一致性变更,每个codis-proxy中有配置,sid=>gid/gid=>sid的,dash专门开启一个协程从zk的任务队列中获取slot迁移,一个一个进行
迁移有两个阶段,第一阶段状态改为pre_m。若proxy都确认,将状态改m。向所在的redis-server发送迁移命
dash修改到zk,proxy监听回复到zk,dash监听zk进行状态机的变更,同步配置做配置的下发同步(zk 除了存储路由信息,同时还作为一个事件同步的媒介服务,比如变更master 或者数据迁移这样的事情,需要所有的proxy 通过监听特定zk 事件来实现。)
三次配置更改同步:
第一次fillslot将BackendAddr由OriginGroupMaster改写为TargetGroupMaster,这一步操作相当于读写临界区资源BackendAddr,所以必须带写锁,而MigrateFrom只是顺便改了而已
第二次fillslot相当于取消了第一次的写锁,但是如果Promoting在执行的话,不应该取消Promoting设置的锁
第三次fillslot取消了MigrateFrom - 迁移过程中读写请求:
分发请求时存在一个prepare方法,这一步会获取到该key对应的slot是否有MigrateFrom
如果有的话,会使用SLOTSMGRTTAGONE将这个key从MigrateFrom代表的redis强制迁移到Backend代表的redis里去,迁移完成以后再去访问Backend获得这个ke
这样就能解决,如果被迁移的slot中的key,刚好被访问时,产生的一致性问题了 - 迁移与主从切换的冲突:
migrate基本上不依赖lock,当发生数据冲突时,由强制迁移这个key来解决一致性问题,和lock基本上没太大关系,lock主要是针对promote设计的 - 自平衡算法
1.使用自动负载均衡需要满足一个前提:所有codis-server的分组master必须配置maxmemory。
2.各组codis-server分配多少个slot是由其maxmemory决定。比如:A组maxmemory为10G, B组maxmory为1G,进行自动均衡处理后,A组分配的slot会是B组的10倍。
3.自动负载均衡并不会达到绝对意义上的均衡,其只做到maxmemory与分配的slot个数的比例均衡。无法达到操作次数的均衡。
4.自动负载均衡的处理过程中,如果发现存在maxmemory与分配的slot个数比例不均衡时,则会进行发起slot迁移的操作。达到均衡目的的前提下,此过程中会做到尽量减少slot的迁移。
可用性
- 复制
主从切换:哨兵模式 - proxy下线变更等
zk
codis和twemproxy
codis和twemproxy最大的区别有两个:
一个是codis支持动态水平扩展,对client完全透明不影响服务的情况下可以完成增减redis实例的操作;
一个是codis是用go语言写的并支持多线程而twemproxy用C并只用单线程。
后者又意味着:codis在多核机器上的性能会好于twemproxy;codis的最坏响应时间可能会因为GC的STW而变大,不过go1.5发布后会显著降低STW的时间;如果只用一个CPU的话go语言的性能不如C,因此在一些短连接而非长连接的场景中,整个系统的瓶颈可能变成accept新tcp连接的速度,这时codis的性能可能会差于twemproxy。
某自研集群
自研原因:
数据量的限制。1024.
迁移比想象的频繁
zk依赖,zk出问题,路由错误无法发现,redis没有路由信息
思路
- 基于redis集群,基本都靠redis-cluster
- 加一层proxy,客户端与集群连接,单进程单线程(mget岂不是很慢=》用了异步 epoll的模式,发完了不等)
1.建立链接模块。与客户端和redis-clsuster建立链接。
2.命令处理模块。区分单次操作命令和多次操作命令(Mget/Mset)。
3.路由模块。维护redis-cluster的nodes路由信息。(原来client随便打,错了moved这记录了下)
关于双活
方案1:
方案2:
codis收到命令后发送给两个机房的redis
方案3:
redis一些优化和问题
- 白名单动能可以用Bit-array/bitmaps代替set实现
- setex代替set,expire,有两次网络
- 内存提出策略:近似lru,对性能妥协,qps太大时也淘汰不过来,可能会有一些写不进来
- 大key无法分片导致无法水平扩容来解决问题。所以一个key不要存特别大的数据,原则上一个key<100k
- 同一个实例,同一个Key,QPS特别大的时候,仍然扛不住,要避免热key,静态配置类的Key迁出redis放代码里,其他热key要考虑高并发写入的问题。4万的pks针对同一key扛不住,可以转为双缓存或者改一个有常驻内存的语言写。Php的双缓存可以通过fpm的apcu,redis,mysql来做。
- 应用redis时要预测qps,数据容量,内存中的量和磁盘代码可能还不一致。
7.redis影响性能的命令:(执行时间长,传输数据多)
key*,sort(非要单独机器
smembers 控制集合的数量,分子集,srandmember,
save bgsave/afo启动时/master首次收到slave同步请求等时fork进程(fork时虽然数据写时复制,但还是会复制页表,大页可以减少页表,但改就会复制)
appendfsync everysec 子进程持久化和主进程 IO阻塞
bgrewriteaof AOF buffer和文件合并时阻塞的
热key
问题:请求多,部分key集中于同一机器,无法通过增加机器解决,源于redis的从都是备份恢复作用,codis等集群也是
解决方案:
- 1
读:本地缓存(客户端本地:redis/程序/mysql,服务端本地 就是副本扩展多份多机器)
缺点:需要提前获取热点,容量有限,不一致时间长,热点key遗漏。
写:取租约,限流 - 2 读写分离。读复制多份,负载均衡
- 热点key的采样。(未知热key)
线程抢锁开始采样,外层N次,分1024个桶,看每个桶中是否有热点,用标准差。再对桶进行M此采样,选出热点key.客户端从服务器获取这批key进行本地缓存 - 失效击穿
写操作删除本地缓存。根据容量或者过期淘汰,在过期淘汰时为防止击穿,首次发现过期后延长一点过期时间,只有首次的去获取新的key更新。
redis集群的分布式锁redlock
1.同时刻只有一个获取锁,2.多数节点可用则可以获取锁,3.不会死锁,4.锁定期间有效
1/2 :同时多个master上请求锁,超过一半则成功
3:master有时间自动释放,监控每隔时间检查释放等
4:释放锁需要密钥,保证不释放别人的锁
步骤:
1、获取当前时间(单位是毫秒)。
2、轮流用相同的key和随机值在N个节点上请求锁,在这一步里,客户端在每个master上请求锁时,会有一个和总的锁释放时间相比小的多的超时时间。比如如果锁自动释放时间是10秒钟,那每个节点锁请求的超时时间可能是5-50毫秒的范围,这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间,如果一个master节点不可用了,我们应该尽快尝试下一个master节点。
3、客户端计算第二步中获取锁所花的时间,只有当客户端在大多数master节点上成功获取了锁(在这里是3个),而且总共消耗的时间不超过锁释放时间,这个锁就认为是获取成功了。
4、如果锁获取成功了,那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间。
5、如果锁获取失败了,不管是因为获取成功的锁不超过一半(N/2+1)还是因为总消耗时间超过了锁释放时间,客户端都会到每个master节点上释放锁,即便是那些他认为没有获取成功的锁。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。