Redis是基于内存数据库,操作效率高,提供丰富的数据结构(Redis底层对数据结构还做了优化),可用作数据库,缓存,消息中间件等。如今广泛用于互联网大厂,面试必考点之一,本文从数据结构,到集群,到常见问题逐步深入了解Redis,看完再也不怕面试官提问!
redis的单线程指的是数据处理使用的单线程,实际上它主要包含
- IO线程:处理网络消息收发
- 主线程:处理数据读写操作,包括事务、Lua脚本等
- 持久化线程:执行RDB或AOF时,使用持久化线程处理,避免主线程的阻塞
- 过期键清理线程:用于定期清理过期键
至于redis为什么使用单线程处理数据,是因为redis基于内存操作,并且有高效的数据类型,它的性能瓶颈并不在CPU计算,主要在于网络IO,而网络IO在后来的版本中也被独立出来了IO线程,因此它能快速处理数据,单线程反而避免了多线程所带来的并发和资源争抢的问题
Redis底层存储基于全局Hash表,存储结构和Java的HashMap类似(数组+链表方式)
Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash
即拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries
查看存储编码类型:object encoding key
源码位置:t_string.c
string是最常用的类型,它的底层存储结构是SDS
redis的string分三种情况对对象编码,目的是为了节省内存空间:
robj *tryObjectEncodingEx(robj *o, int try_trim)
SET key value
MSET key value [key value ...]
SETNX key value #常用作分布式锁
GET key
MGET key [key ...]
DEL key [key ...]
EXPIRE key seconds
INCR key
DECR key
INCRBY key increment
DECRBY key increment
源码位置:t_list.c
redis的list首先会按紧凑列表存储(listPack),当紧凑列表的长度达到list_max_listpack_size之后,会转换为双向链表
// 1.LPUSH/RPUSH/LPUSHX/RPUSHX这些命令的统一入口
void pushGenericCommand(client *c, int where, int xx)
// 2.追加元素,并尝试转换紧凑列表
void listTypeTryConversionAppend(robj *o, robj **argv, int start, int end, beforeConvertCB fn, void *data)
// 3.尝试转换紧凑列表
static void listTypeTryConversionRaw(robj *o, list_conv_type lct, robj **argv, int start, int end, beforeConvertCB fn, void *data)
// 4.尝试转换紧凑列表
// 若紧凑列表的长度达到list_max_listpack_size之后,则转换
static void listTypeTryConvertQuicklist(robj *o, int shrinking, beforeConvertCB fn, void *data)
当redis进行list元素移除时
// 1.移除list元素的统一入口
void listElementsRemoved(client *c, robj *key, int where, robj *o, long count, int signal, int *deleted)
// 2.尝试转换
void listTypeTryConversion(robj *o, list_conv_type lct, beforeConvertCB fn, void *data)
// 3.尝试转换
static void listTypeTryConversionRaw(robj *o, list_conv_type lct, robj **argv, int start, int end, beforeConvertCB fn, void *data)
// 4.尝试转换双向链表
// 若双向链表中只剩一个节点,且是压缩节点,则对双向链表转换为紧凑列表
static void listTypeTryConvertQuicklist(robj *o, int shrinking, beforeConvertCB fn, void *data)
以下参数可在redis.conf配置
list_max_listpack_size:默认-2
LPUSH key value [value ...]
RPUSH key value [value ...]
LPOP key
RPOP key
LRANGE key start stop
BLPOP key [key ...] timeout #从key列表头弹出一个元素,若没有元素,则阻塞等待timeout秒,0则一直阻塞等待
BRPOP key [key ...] timeout #从key列表尾弹出一个元素,若没有元素,则阻塞等待timeout秒,0则一直阻塞等待
根据list的特性,可以组成实现以下常用的数据结构
redis实现数据结构的意义在于分布式环境的实现
源码位置:t_hash.c
redis的hash首先会按紧凑列表存储(listPack),当紧凑列表的长度达到hash_max_listpack_entries或添加的元素大小超过hash_max_listpack_value之后,会转换为Hash表
// 1.添加hash元素
void hsetCommand(client *c)
void hsetnxCommand(client *c)
// 2.尝试转换Hash表
// 若紧凑列表的长度达到hash_max_listpack_entries
// 或添加的元素大小超过hash_max_listpack_value
// 则进行转换
void hashTypeTryConversion(robj *o, robj **argv, int start, int end)
// 3.尝试转换Hash表
void hashTypeConvert(robj *o, int enc)
// 4.转换Hash表
void hashTypeConvertListpack(robj *o, int enc)
以下参数可在redis.conf配置
hash_max_listpack_value:默认64
hash_max_listpack_entries:默认512
HSET key field value
HSETNX key field value
HMSET key field value [field value ...]
HGET key field
HMGET key field [field ...]
HDEL key field [field ...]
HLEN key
HGETALL key
HINCRBY key field increment
源码位置:t_set.c
// 1.添加set元素
void saddCommand(client *c)
// 2.1.创建set表
// 若存储对象是整形数字且集合小于set_max_listpack_entries,则存储为OBJ_ENCODING_INTSET
// 若集合长度小于set_max_listpack_entries时,存储为紧凑列表
// 否则存储为Hash表
robj *setTypeCreate(sds value, size_t size_hint)
// 2.2 尝试转换set表
// 如果编码是OBJ_ENCODING_LISTPACK(紧凑列表),且集合长度大于set_max_listpack_entries
// 或编码是OBJ_ENCODING_INTSET(整形集合),且集合长度大于set_max_intset_entries
// 则进行转换为Hash表
void setTypeMaybeConvert(robj *set, size_t size_hint)
// 2.3 添加元素
int setTypeAdd(robj *subject, sds value)
int setTypeAddAux(robj *set, char *str, size_t len, int64_t llval, int str_is_sds)
// 2.4 若整形数组添加元素,长度超过set_max_intset_entries,则转换为Hash表
static void maybeConvertIntset(robj *subject)
以下参数可在redis.conf配置
set_max_intset_entries:默认512
set_max_listpack_entries:默认128
SADD key member [member ...]
SREM key member [member ...]
SMEMBERS key
SCARD key
SISMEMBERS key member
SRANDMEMBER key [count]
SPOP key [count]
SRANDOMEMBER key [count]
SINTER key [key ...] #交集运算
SINTERSTORE destination key [key ...] #将交集结果存入新集合destination
SUNION key [key ...] #并集运算
SUNIONSTORE destination key [key ...] #将并集结果存入新集合destination
SDIFF key [key ...] #差集运算
SDIFFSTORE destination key [key ...] #将差集结果存入新集合destination
源码位置:t_zset.c
根据情况可能创建紧凑列表或跳表
// 1.添加元素
void zaddCommand(client *c)
void zaddGenericCommand(client *c, int flags)
// 2.1 创建元素
// 若集合长度<=zset_max_listpack_entries 并且值的长度<=zset_max_listpack_value,则创建紧凑列表
// 否则创建跳表节点
robj *zsetTypeCreate(size_t size_hint, size_t val_len_hint)
// 2.2 添加元素
// 若集合是紧凑列表,且集合元素超过zset_max_listpack_entries
// 或当前添加的元素长度超过zset_max_listpack_value
// 则将紧凑列表转换为跳表
int zsetAdd(robj *zobj, double score, sds ele, int in_flags, int *out_flags, double *newscore)
以下参数可在redis.conf配置
zset_max_listpack_entries:默认128
zset_max_listpack_value:默认64
跳表仅在以下情况转换回压缩列表
void georadiusGeneric(client *c, int srcKeyIndex, int flags)
void zunionInterDiffGenericCommand(client *c, robj *dstkey, int numkeysIndex, int op, int cardinality_only)
ZADD key score member [[score member]...]
ZREM key member [member ...]
ZSCORE key member
ZINCRBY key increment member
ZCARD key
ZRANGE key start stop [WITHSCORES]
ZREVRANGE key start stop [WITHSCORES]
ZUNIONSTORE destkey numkeys key [key ...] # 并集计算
ZINTERSTORE destkey numkeys key [key ...] # 交集计算
源码位置:server.h
{
unsigned type:4;//类型 五种对象类型
unsigned encoding:4;//编码
void *ptr;//指向底层实现数据结构的指针
int refcount;//引用计数
unsigned lru:24;//记录最后一次被命令程序访问的时间
}robj;
源码位置:sds.h
typedef char *sds;
struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串长度小于 1<<5 32字节
unsigned char flags; /* 3 lsb of type, and 5 msb of string length intembstr*/
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 对应的字符串长度小于 1<<8 256
uint8_t len; /* used */ //目前字符创的长度 用1字节存储
uint8_t alloc; //已经分配的总长度 用1字节存储
unsigned char flags; //flag用3bit来标明类型,类型后续解释,其余5bit目前没有使用 embstr raw
char buf[]; //柔性数组,以'\0'结尾
};
struct __attribute__ ((__packed__)) sdshdr16 { // 对应的字符串长度小于 1<<16
uint16_t len; /*已使用长度,用2字节存储*/
uint16_t alloc; /* 总长度,用2字节存储*/
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 { // 对应的字符串长度小于 1<<32
uint32_t len; /*已使用长度,用4字节存储*/
uint32_t alloc; /* 总长度,用4字节存储*/
unsigned char flags;/* 低3位存储类型, 高5位预留 */
char buf[];/*柔性数组,存放实际内容*/
};
struct __attribute__ ((__packed__)) sdshdr64 { // 对应的字符串长度小于 1<<64
uint64_t len; /*已使用长度,用8字节存储*/
uint64_t alloc; /* 总长度,用8字节存储*/
unsigned char flags; /* 低3位存储类型, 高5位预留 */
char buf[];/*柔性数组,存放实际内容*/
};
字符串类型的内部编码有3种
embstr和raw进行区分的长度,是44;是因为redisObject的长度是16字节,sds的长度是4+字符串长度;因此当字符串长度是44时,embstr的长度正好是16+4+44 =64,jemalloc正好可以分配64字节的内存单元。
ziplist 被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。
ziplist 是一个特殊双向链表,不像普通的链表使用前后指针关联在一起,它是存储在连续内存上的。
/* 创建一个空的 ziplist. */
unsigned char *ziplistNew(void) {
unsigned int bytes = ZIPLIST_HEADER_SIZE+ZIPLIST_END_SIZE;
unsigned char *zl = zmalloc(bytes);
ZIPLIST_BYTES(zl) = intrev32ifbe(bytes);
ZIPLIST_TAIL_OFFSET(zl) = intrev32ifbe(ZIPLIST_HEADER_SIZE);
ZIPLIST_LENGTH(zl) = 0;
zl[bytes-1] = ZIP_END;
return zl;
}
ziplist 在更新或者新增时候,如空间不够则需要对整个列表进行重新分配。当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
ziplist 节点的 prevlen 属性会根据前一个节点的长度进行不同的空间大小分配:
假设有这样的一个 ziplist,每个节点都是等于 253 字节的。新增了一个大于等于 254 字节的新节点,由于之前的节点 prevlen 长度是 1 个字节。
为了要记录新增节点的长度所以需要对节点 1 进行扩展,由于节点 1 本身就是 253 字节,再加上扩展为 5 字节的 pervlen 则长度超过了 254 字节,这时候下一个节点又要进行扩展了
Redis7.0之后采用listPack全面替代zipList
在 Redis5.0 出现了 listpack,目的是替代压缩列表,其最大特点是 listpack 中每个节点不再包含前一个节点的长度,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。
unsigned char *lpNew(size_t capacity) {
unsigned char *lp = lp_malloc(capacity > LP_HDR_SIZE+1 ? capacity : LP_HDR_SIZE+1);
if (lp == NULL) return NULL;
lpSetTotalBytes(lp,LP_HDR_SIZE+1);
lpSetNumElements(lp,0);
lp[LP_HDR_SIZE] = LP_EOF;
return lp;
}
数组:查询快,插入删除慢
链表:查询慢,插入删除快
跳表:跳表是基于链表的一个优化,在链表的插入删除快的特性之上,也增加了它的查询效率。它是将有序链表改造为支持折半查找算法,它的插入、删除、查询都很快
跳表缺陷:需要额外空间来建立索引层,以空间换时间,因此zset一开始是以紧凑列表存储,后续才会转换为跳表
由于Redis 主要运行在单个线程中,它采用的是一种近似的 LRU 算法,而不是传统的完全 LRU 算法(没有把所有key组织为链表)。这种实现方式在保证性能的同时,仍然能够有效地识别并淘汰最近最少使用的键。当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 在访问 key 时,对 logc进行变化:
redis.conf 提供了两个配置项,用于调整 LFU 算法从而控制 logc 的增长和衰减:
redis的key过期删除策略采用惰性删除+定期删除实现:
Redis 的惰性删除策略由 db.c 文件中的 expireIfNeeded 函数实现,代码如下:
int expireIfNeeded(redisDb *db, robj *key) {
// 判断 key 是否过期
if (!keyIsExpired(db,key)) return 0;
....
/* 删除过期键 */
....
// 如果 server.lazyfree_lazy_expire 为 1 表示异步删除,反之同步删除;
return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :
dbSyncDelete(db,key);
}
在 Redis 中,默认每秒进行 10 次过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10;定期删除的实现在 expire.c 文件下的 activeExpireCycle 函数中,其中随机抽查的数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义的,它是写死在代码中的,数值是 20;也就是说,数据库每轮抽查时,会随机选择 20 个 key 判断是否过期。
redis提供pipeline,可以让客户端一次发送一连串的命令给服务器执行,然后再返回执行结果
/**
* 一次io获取个值
*
* @param redisKeyEnum
* @param ids
* @param clz
* @param <T>
* @param <E>
* @return
*/
public <T, E extends T> List<T> multiGet(RedisKeyEnum redisKeyEnum, List<String> ids, Class<E> clz) {
ShardRedisConnectionFactory factory = getShardRedisConnectionFactory(redisKeyEnum);
ShardedJedis shardedJedis = factory.getConnection();
return execute(factory, shardedJedis, new Supplier<List<T>>() {
@Override
public List<T> get() {
// 1.获取管道
ShardedJedisPipeline pipeline = shardedJedis.pipelined();
List<T> list = new ArrayList<>();
List<Response<String>> respList = new ArrayList<>();
for (String id : ids) {
String key = getKey(redisKeyEnum, id);
// 2.通过管道执行命令
Response<String> resp = pipeline.get(key);
respList.add(resp);
}
// 3.统一提交命令
pipeline.sync();
for (Response<String> resp : respList) {
// 4.遍历获取全部的命令执行返回结果
String result = resp.get();
if (result == null) {
continue;
}
if (clz.equals(String.class)) {
list.add((E) result);
} else {
list.add(JsonUtil.json2Obj(result, clz));
}
}
return list;
}
});
}
Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
事务的命令:
redis事务在编译错误可以回滚,而运行时错误不能回滚,简单说,
redis事务不支持回滚
redis提供了两种持久化的方式,分别是RDB(Redis DataBase)和AOF(Append Only File)。
其实RDB和AOF两种方式也可以同时使用,在这种情况下,如果redis重启的话,则会优先采用AOF方式来进行数据恢复,这是因为AOF方式的数据恢复完整度更高。
如果你没有数据持久化的需求,也完全可以关闭RDB和AOF方式,这样的话,redis将变成一个纯内存数据库
AOF日志是一种追加式持久化方式,它记录了每个写操作命令,以追加的方式将命令写入AOF文件。通过重新执行AOF文件中的命令,可以重建出数据在内存中的状态。AOF日志提供了更精确的持久化,适用于需要更高数据安全性和实时性的场景。
优点:
缺点:
根据不同的需求,可以选择RDB快照、AOF日志或两者结合使用。你可以根据数据的重要性、恢复速度要求以及磁盘空间限制来选择合适的持久化方式。有时候,也可以通过同时使用两种方式来提供更高的数据保护级别。
RDB快照是一种全量持久化方式,它会周期性地将内存中的数据以二进制格式保存到磁盘上的RDB文件。RDB文件是一个经过压缩的二进制文件,包含了数据库在某个时间点的数据快照。RDB快照有助于实现紧凑的数据存储,适合用于备份和恢复。
优点:
缺点:
Redis提供了基于“发布/订阅”模式的消息机制。此种模式下,消息发布者和订阅者不进行直接通信,发布者客户端向指定的频道(channel) 发布消息,订阅该频道的每个客户端都可以收到该消息。结构如下:
该消息通信模式可用于模块间的解耦
# 订阅消息
subscribe channel [channel ...]
# 发布消息
publish channel "hello"
# 按模式订阅频道
psubscribe pattern [pattern ...]
# 退订频道
unsubscribe pattern [pattern ...]
# 按模式退订频道
punsubscribe pattern [pattern ...]
redis集群主要有三种模式:主从复制,哨兵模式和Cluster
主从复制模式中包含一个主数据库实例(master)与一个或多个从数据库实例(slave)
replicaof 127.0.0.1 6379 # master的ip,port
masterauth 123456 # master的密码
replica-serve-stale-data no # 如果slave无法与master同步,设置成slave不可读,方便监控脚本发现问题
优点:
缺点:
主从切换技术的方法是:当主服务器宕机后,需要手动把一台从服务器切换为主服务器,这就需要人工干预,费事费力,还会造成一段时间内服务不可用。这不是一种推荐的方式,更多时候,我们优先考虑哨兵模式。
哨兵模式是一种特殊的模式,首先Redis提供了哨兵的命令,哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个Redis实例。
这里的哨兵有两个作用
然而一个哨兵进程对Redis服务器进行监控,可能会出现问题,为此,我们可以使用多个哨兵进行监控。各个哨兵之间还会进行监控,这样就形成了多哨兵模式。
# 使得Redis服务器可以跨网络访问
bind 0.0.0.0
# 设置密码
requirepass "123456"
# 指定主服务器,注意:有关slaveof的配置只是配置从服务器,主服务器不需要配置
slaveof 192.168.11.128 6379
# 主服务器密码,注意:有关slaveof的配置只是配置从服务器,主服务器不需要配置
masterauth 123456
# 禁止保护模式
protected-mode no
# 配置监听的主服务器,这里sentinel monitor代表监控,mymaster代表服务器的名称,可以自定义,192.168.11.128代表监控的主服务器,6379代表端口,2代表只有两个或两个以上的哨兵认为主服务器不可用的时候,才会进行failover操作。
sentinel monitor mymaster 192.168.11.128 6379 2
# sentinel author-pass定义服务的密码,mymaster是服务名称,123456是Redis服务器密码
# sentinel auth-pass <master-name> <password>
sentinel auth-pass mymaster 123456
# 启动Redis服务器进程
./redis-server ../redis.conf
# 启动哨兵进程
./redis-sentinel ../sentinel.conf
哨兵模式解决了主从复制不能自动故障转移,达不到高可用的问题,但还是存在难以在线扩容,Redis容量受限于单机配置的问题。
Cluster模式实现了Redis的分布式存储,即每台节点存储不同的内容,来解决在线扩容的问题
Cluster特点
Cluster模式集群节点最小配置6个节点(3主3从,因为需要半数以上),其中主节点提供读写操作,从节点作为备用节点,不提供请求,只作为故障转移使用。
redis.conf配置:
port 7100 # 本示例6个节点端口分别为7100,7200,7300,7400,7500,7600
daemonize yes # r后台运行
pidfile /var/run/redis_7100.pid # pidfile文件对应7100,7200,7300,7400,7500,7600
cluster-enabled yes # 开启集群模式
masterauth passw0rd # 如果设置了密码,需要指定master密码
cluster-config-file nodes_7100.conf # 集群的配置文件,同样对应7100,7200等六个节点
cluster-node-timeout 15000 # 请求超时 默认15秒,可自行设置
启动redis:
[root@dev-server-1 cluster]# redis-server redis_7100.conf
[root@dev-server-1 cluster]# redis-server redis_7200.conf
组成集群:
redis-cli --cluster create --cluster-replicas 1 127.0.0.1:7100 127.0.0.1:7200 127.0.0.1:7300 127.0.0.1:7400 127.0.0.1:7500 127.0.0.1:7600 -a passw0rd
--cluster-replicas:表示副本数量,也就是从服务器数量,因为我们一共6个服务器,这里设置1个副本,那么Redis会收到消息,一个主服务器有一个副本从服务器,那么会计算得出:三主三从。
当使用redis作为数据库的缓存层时,会经常遇见这几种问题,以下是这些问题的描述以及对应的解决方案
概念:请求过来之后,访问不存在的数据,redis中查询不到,则穿透到数据库进行查询
现象:大量穿透访问造成redis命中率下降,数据库压力飙升
解决方案:
概念:请求访问的key对应的数据存在,但key在redis中已过期,则访问击穿到数据库
现象:若大批请求中访问的key均过期,那么redis正常运行,但数据库的瞬时并发压力会飙升
解决方案:
概念:同一时间大批量key同时过期,造成瞬时对这些key的请求全部击穿到数据库;或redis服务不可用(宕机)
缓存雪崩与缓存击穿的区别在于:缓存击穿是单个热点数据过期,而缓存雪崩是大批量热点数据过期
现象:大量热点数据的查询请求会增加数据库瞬时压力
解决方案:
概念:redis中存在占用内存空间较多的key,其中包含多种情况,如string类型的value值过大,hash类型的所有成员总值过大,zset的成员数量过大等。大key的具体值的界定,要根据实际业务情况判断。
现象:大key对业务会产生多方面的影响:
原因:
排查:
redis-cli -h 127.0.0.1 -p 6379 —bigkeys
例如:输出占用内存大于1kb,排名前3的keys。
rdb —commond memory —bytes 1024 —largest 3 dump.rbd
解决方案:
概念:redis中某个key的访问次数比较多且明显多于其他key,则这个key被定义为热key
现象:
**原因:**某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品
排查:
hotkeys
参数,该参数能够返回所有 key 的被访问次数(使用前提:redis淘汰策略设置为lfu)# redis-cli -p 6379 --hotkeys
MONITOR
命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。该命令对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR(生产环境中建议谨慎使用该命令)解决方案:
欢迎关注(w)Henry游戏开发(x) ,分享开发技术!