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(生产环境中建议谨慎使用该命令)
解决方案:
更多技术干货,欢迎关注我!