1 Redis常见的数据类型
Redis常见的5种数据类型包括string,list,hash,set,zset,每一种数据类型的编码可以有多种,例如list类型的底层数据结构可以是双向链表,也可以是跳跃表。下面先介绍redis底层的6种数据结构。
数据结构
简单动态字符串SDS
SDS和C字符串的区别:
- 以常数复杂度获取字符串的长度,通过len记录了字符串的长度。
- 不会出现缓冲区溢出
- C字符串拼接,长度不够会产生缓冲区溢出;
- SDS字符串拼接,先检查SDS空间是否足够,不够先扩容。
- 减少字符串修改时带来的内存重新分配次数
- 空间预分配:修改sds时会对buf进行扩容,当buf<1M时翻倍扩容,即给free分配的空间大小和len一致。当buf>1M时,给free分配1M。
- 惰性空间释放:sds缩短时,释放的空间先不释放,由free记录。
- 二进制安全
- 可以保持文本、图片、音频等数据。sds通过len记录了字符串的长度,不像C字符串通过''\0'判断是否到字符串末位。
- 兼容部分C字符串的函数
链表
双端无环链表:节点通过prev和next指针实现,表头节点的prev,表尾节点的next都指向NULL
通过head和tail指针可以快速获取表头与表尾,通过len可以快速获取链表长度。
字典
字典数据结构用于实现Redis的哈希键、数据库等,图中字典有两个哈希表,一个平时使用,一个仅在rehash时使用
- 计算键值对放到哈希数组的哪个位置,Redis使用MurmurHash2算法计算键的哈希值: hash=dict->type->hashFunction(key); index=hash & dict->ht[x].sizemask;
- 键冲突:采用链地址法解决。
- 渐进式rehash过程:
- 给ht[1]哈希表分配空间
- 扩展操作,空间大小为第一个大于等于ht[0].used*2的2^n;
- 收缩操作,空间大小为第一个大于等于ht[0].used的2^n。
- rehashidx设置为0,表示rehash开始。将ht[0]在rehashidx索引上的键值对rehash到ht[1],rehash完成rehashidx值增一,ht[0]的键值对全部rehash至ht[1],rehashidx设置为-1,表示rehash操作已完成。
- 释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表。
- 给ht[1]哈希表分配空间
跳跃表
Redis只有两个地方用到了跳跃表结构,一是用于实现有序集合,二是在集群节点中用作内部数据结构。
level:层数最大的那个节点的层数,节点的层数是1-32的随机数
length:跳跃表节点数量(不包括表头节点);
层:节点中用L1,L2等字样表示,每个层带有前进指针、跨度两个属性;
后退指针:BW,反向遍历时使用;
分值:跳跃表的节点按分值从小到大排列;分值相同时,节点按成员对象的大小进行排序;
成员对象:通过o1,o2字样表示,成员对象必需唯一。
整数集合
整数集合是集合键的底层实现之一,集合元素有序且不重复,可以保存int16,int32,int64的整数值,具体的类型由encoding决定。
升级:当前所有元素为int16,添加新元素为int32,此时需要先升级,扩展数组空间,每个元素空间为32位。
降级:不支持降级。
压缩列表
压缩列表(ziplist)是Redis为了节约内存而开发的,其结构如下:
zlbytes:整个压缩列表占用的内存字节数;
zltail:表尾节点距离压缩列表的起始地址有多少字节;
zllen:节点数量;
entryX:节点,可以保存字节数组或者整数;
zlend:特殊值0xFF,标记压缩列表的末端。
压缩列表的节点结构如下:
- previous_entry_length:前一个节点的长度,添加、删除节点可能产生连锁更新问题。
- 前一个节点长度小于254字节,previous_entry_length长度为1个字节;
- 前一个节点长度小大于等于254字节,previous_entry_length长度为5个字节。
- encoding:记录content保持的数据类型及长度,数组、整数值的类型和长度由编码出去最高两位的其他位记录。 | 编码 | 编码长度| content保持的值| | :----- | --------: | --------: | | 00 | 1字节 | 长度小于等于63字节的字节数组| | 01 | 2字节 | 长度小于等于16383字节的字节数组| | 10 | 5字节 | 长度小于等于4294967295字节的字节数组| | 11 | 1字节 | 整数 |
- content:保存节点的值。
数据类型
Redis常见的5种数据对象包括字符串、列表、哈希、集合、有序集合对象,对象的结构为:
typedef struct redisObject{
unsigned type:4; //类型
unsigned encoding:4; //编码
void *ptr; //指向底层实现数据结构的指针
...
}
常见的5种数据对象的类型和编码如下:
| 数据类型 | type | encoding | 编码条件 |
|---|---|---|---|
| 字符串 | REDIS_STRING | REDIS_ENCODING_INT | 整数 |
| 字符串 | REDIS_STRING | REDIS_ENCODING_EMBSTR | 字符串长度小于等于39字节,浮点数 |
| 字符串 | REDIS_STRING | REDIS_ENCODING_RAW | 字符串长度大于39字节,浮点数 |
| 列表 | REDIS_LIST | REDIS_ENCODING_ZIPLIST | 字符串长度<64字节,且数量<512个 |
| 列表 | REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 不满足ZIPLIST编码条件时 |
| 哈希 | REDIS_HASH | REDIS_ENCODING_ZIPLIST | 键和值的长度都<64字节,且键值对数量<512个 |
| 哈希 | REDIS_HASH | REDIS_ENCODING_HT | 不满足ZIPLIST编码条件时 |
| 集合 | REDIS_SET | REDIS_ENCODING_INTSET | 元素都是整数,且数量<512个 |
| 集合 | REDIS_SET | REDIS_ENCODING_HT | 不满足INTSET编码条件时 |
| 有序集合 | REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 字符串长度<64字节,且数量<128个 |
| 有序集合 | REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 不满足ZIPLIST编码条件时 |
| 注意:部分编码条件的上限值可以通过配置文件修改。 |
2 Redis过期键删除策略
- 惰性删除:获取某个key时,检查key是否过期,如果过期了就删除;
- 定期删除:每隔一段时间,检查数据库,删除过期键;每秒检查10次,每次检查如果超过25%的键过期,则触发下一次检查
- 内存淘汰机制
3 数据持久化
数据库存储内容
Redis是一个键值对数据库服务器,所有的键值对存储在dict字典结构中。下图展示了一个带有过期字典的数据库例子。
RDB持久化
RDB持久化将redis在内存中的数据库状态保持到磁盘,生成一个压缩的二进制文件,通过RDB文件,可以还原数据库状态。
生成RDB文件
- SAVE:阻塞Redis服务器进程,直到RDB文件创建完成。
- BGSAVE:派生子进程,由子进程负责创建RDB文件。 BGSAVE执行期间,拒绝执行SAVE、BGSAVE命令,BGREWRITEAOF延迟到BGSAVE执行完之后。 BGREWRITEAOF执行期间,拒绝执行BGSAVE命令。
- 自动间隔性保存 save 900 1 save 300 10 save 60 10000 服务器900秒内,对数据库进行了至少一次修改,服务器自动执行一次BGSAVE命令。服务器通过dirty记录修改次数,通过lastsave记录上一次执行SAVE或BGSAVE的命令时间,由这两个参数实现自动间隔性保存。
载入RDB文件
服务器启动时自动执行,没有专门的命令。 如果开启了AOF持久化功能,优先使用AOF文件还原数据库,AOF持久化关闭,使用RDB文件还原数据库。
RDB文件结构
- RDB文件结构如下:
RDB文件最开头是REDIS,长度为5字节,保存着"REDIS"五个字符,用于快速检查载入的文件是否是RDB文件。 db_version长度为4字节,记录RDB文件的版本号。 database包含零个或任意多个数据库,以及各个数据库中的键值对。 EOF长度为1字节,标志RDB文件正文内容结束。 check_sum是一个8字节的无符号整数,通过对前面4部分内容计算得出,用于检查RDB文件是否损坏。
- database部分数据库结构如下:
- pairs保存了数据库所有的键值对和过期时间,结构如下:
EXPIRETIME_MS为1字节,表示接下来读入的将是一个毫秒为单位的过期时间。 ms为8字节,记录毫秒为单位的UNIX时间戳。 TYPE为1字节,记录了value的类型。 key是字符串对象。 如果TYPE为REDIS_RDB_TYPE_STRING,那么value保存的是一个字符串对象,字符串对象的编码可以是REDIS_ENCODING_INT或者REDIS_ENCODING_RAW。如果字符串的长度大于20字节,将压缩后再保存。
- 压缩后的字符串结构如下:
REDIS_RDB_ENC_LZF标志着字符串被LZF算法压缩过。 compressed_len记录字符串被压缩后的长度。 origin_len记录原长度。 compressed_string记录被压缩后的字符串。 压缩后的字符结构示例如下:
AOF持久化
AOF持久化通过保存REDIS服务器执行的命令来记录数据库的状态,文件内容如下:
生成AOF文件
服务器执行完一个写命令,会以协议格式将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾。服务器每结束一个事件循环之前,都会考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF文件。具体是否需要由服务器配置的appendfsync的值决定。
- always 将aof_buf缓冲区的所有内容写入并同步到AOF文件
- everysec ,默认配置,将aof_buf缓冲区的所有内容写入到AOF文件,如果距离上次同步超过一秒钟,那么在此对AOF文件进行同步。(写入:保存到内存缓冲区,同步:写入磁盘)。
- no 将aof_buf缓冲区的所有内容写入到AOF文件,何时同步由操作系统决定。
载入AOF文件
AOF重写
AOF文件重写不需要对现有的AOF文件进行操作,这个功能是通过读取服务器当前的数据库状态来实现的。从数据库读取键当前的值,代替之前记录这个键值对的多条命令。
AOF重写由子线程执行,但是在子进程进行AOF重写期间,服务器进程处新的命令,可能会对现有数据库状态进行修改,导致当前数据库状态和重写后的AOF文件所保存的数据库状态不一致。
为了解决这一问题,Redis服务器设置了一个AOF重写缓冲区,这个缓冲区在服务器创建子进程之后开始使用,当Redis服务器执行完一个写命令后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区。
AOF重写过程如下:整个过程中,只有T8和T9执行时会阻塞服务器进程。
4 Redis使用常见问题
redis的QPS为10万-100万量级,数据库的QPS为1000量级
缓存穿透
- 产生原因:大量的请求访问不存在的key,redis没有缓存,如果百万级请求都落到数据库,可能导致数据库宕机。
- 解决方式:
- 缓存空值,根据不存在的key查数据库,将查到的空值缓存。如果是恶意攻击,每次请求的key都不一样,这种情况无法解决。
- 布隆过滤器,用来判断key是否存在于某个集合中,如果不存在就直接返回,存在查缓存,查 数据库。
缓存击穿
- 产生原因:高并发下,大量请求同时查询某个key,这个key正好失效了,比如热点数据失效
- 解决方式:查数据库时加互斥锁。
缓存雪崩
- 产生原因:某一时刻发生大规模的缓存失效,比如redis宕机了,请求都落到数据库,可能导致数据库宕机。
- 解决方式:
- redis集群,保证缓存服务的高可用。
- ehcache本地缓存+Hystrix限流&降级,降级类似于在页面提示用户"服务器崩溃请刷新重试"。
- redis持久化,重启服务,并通过持久化文件恢复缓存数据。
5 redis常用命令
-
连接redis 进入到redis组件的bin目录,执行./redis-cli -p 7019 -a Qz3Qp9Fq1F
-
手动执行持久化,生成rdb文件
127.0.0.1:7019> bgsave
-
关闭持久化,redislinux64的conf/redis.conf文件
- 取消rdb持久化
# save 900 1
# save 300 10
# save 60 10000 - 取消aof持久化
appendonly no
- 取消混合持久化
aof-use-rdb-preamble no
- 取消rdb持久化
-
选择数据库:select 7
-
查询key:get xxx:login:gb35114:isAuth
-
查看所有key:keys *
-
搜索key:keys abc*
-
删除key:del xxx
-
redis监控管理:info命令
# Clients
connected clients:130
# Memory
used memory:10670184
used memory human:10.18M
used memory rss:11345920 -
查看连接的客户端:./redis-cli -p 7019 -a Qz3Qp9Fq1F -c client list
6 项目中redis配置
-
rdb持久化关闭
# save 900 1
# save 300 10
# save 60 10000 -
内存为8G
maxmemory 8589934592
-
内存容量超过maxmemory后的处理策略,Redis的内存淘汰策略是指在Redis用于缓存的内存不足时,如何处理新写入且需要申请额外空间的数据。
maxmemory-policy volatile-lru
- noeviction:当内存不足容纳新写入数据时,新写入操作就会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除 某个key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。
Redis的内存淘汰策略的选取并不会影响过期的key的处理,内存淘汰策略用于处理内存不足的需要申请额外空间的数据,过期策略用于处理过期的缓存数据。
- noeviction:当内存不足容纳新写入数据时,新写入操作就会报错。
-
惰性删除配置
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
slave-lazy-flush yes- lazyfree-lazy-eviction,针对redis内存使用达到maxmeory,并设置有淘汰策略,在被动淘汰键时,是否采用lazy free机制。因为此场景开启lazy free, 可能使用淘汰键的内存释放不及时,导致redis内存超用,超过maxmemory的限制。
- lazyfree-lazy-expire,针对设置有TTL的键,达到过期后,被redis清理删除时是否采用lazy free机制。此场景建议开启,因TTL本身是自适应调整的速度。
- lazyfree-lazy-server-del,针对有些指令在处理已存在的键时,会带有一个隐式的DEL键的操作。如rename命令,当目标键已存在,redis会先删除目标键,如果这些目标键是一个big key,那就会引入阻塞删除的性能问题。此参数设置就是解决这类问题,建议可开启。
- replica-lazy-flush,针对slave进行全量数据同步,slave在加载master的RDB文件前,会运行flushall来清理自己的数据场景,参数设置决定是否采用异常flush机制。如果内存变动不大,建议可开启。可减少全量同步耗时,从而减少主库因输出缓冲区爆涨引起的内存使用增长。
-
AOF持久化
appendonly yes
appendfsync no
no-appendfsync-on-rewrite yes- appendfsync,AOF同步频率,no表示将aof_buf缓冲区的所有内容写入到AOF文件,何时同步由操作系统决定。
- no-appendfsync-on-rewrite,同时执行bgrewriteaof操作和主进程写aof文件的操作时,两者都会操作磁盘,而bgrewriteaof往往会涉及大量磁盘操作,这样就会造成主进程在写aof文件的时候出现阻塞的情形。
- no-appendfsync-on-rewrite设置为no,是最安全的方式,不会丢失数据,但是要忍受阻塞的问题。
- no-appendfsync-on-rewrite设置为yes,设置为yes,相当于将appendfsync设置为no,说明并没有执行磁盘操作,只是写入了缓冲区,因此这样并不会造成阻塞,但是如果这个时候redis挂掉,就会丢失数据。在linux的操作系统的默认设置下,最多会丢失30s的数据。
7 Redisson分布式锁
分布式锁主要有以下几个特点:
- 独占性:同一时刻只有一个线程能够持有锁。
- 可重入:同一个线程能够重复获取已获得的锁。
- 超时释放:在获得锁之后限制锁的有效时间,避免资源无法释放而造成死锁。
- 高可用:有良好的获取锁与释放锁的功能,避免分布式锁失效。
项目上使用
private void executeMenuSync() {
try {
RLock lock = redissonClient.getLock(LockKey.MENU_SYNC);
// 获取同步锁,并持有 90 秒,锁会自动释放不用手动释放
if (lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS)) {
try {
LOGGER.info("acquire the synchronization lock and start to perform menu synchronization");
menuSyncService.syncMenus();
LOGGER.info("menu synchronization end");
} finally {
MENU_SYNC_STATE = Signal.UN_READY;
lock.unlock();
}
} else {
LOGGER.info("the synchronization lock is not acquired so the menu synchronization method is not called this time");
}
} catch (Exception e) {
LOGGER.error(e, XauthErrorCode.SYSTEM.SYSTEM_ERROR, "menu synchronization failed");
}
}
获取分布式锁tryLock方法源码
tryLock方法主要可以分为四步:
- tryAcquire尝试获取锁,如果获取到返回true。
- 获取不到锁说明锁被占用了,订阅解锁消息通知。
- 收到解锁消息通知,再次尝试获取锁,如果获取不到重复步骤三,直到超过waitTime获取锁失败。
- 不论是否获取锁成功,取消解锁消息订阅。
参考:www.cnblogs.com/CF1314/p/17…
// 在waitTime时间范围内尝试获取锁,如果获取到锁,则设置锁过期时间leaseTime
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
// 第一步:尝试获取锁
Long ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
// ttl为空说明获取到了锁
if (ttl == null) {
return true;
} else {
// 判断尝试获取锁是否超过waitTime
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
// 尝试获取锁超过waitTime,获取锁失败
this.acquireFailed(waitTime, unit, threadId);
return false;
} else {
// 第二步:订阅释放锁消息通知
current = System.currentTimeMillis();
CompletableFuture subscribeFuture = this.subscribe(threadId);
try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException var21) {
if (!subscribeFuture.completeExceptionally(new RedisTimeoutException("Unable to acquire subscription lock after " + time + "ms. Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
subscribeFuture.whenComplete((res, ex) -> {
if (ex == null) {
this.unsubscribe(res, threadId);
}
});
}
this.acquireFailed(waitTime, unit, threadId);
return false;
} catch (ExecutionException var22) {
this.acquireFailed(waitTime, unit, threadId);
return false;
}
try {
// 如果在剩余等待时间内收到订阅通知,那么会继续计算剩余等待时间(排除掉订阅等待的时间)
time -= System.currentTimeMillis() - current;
if (time <= 0L) {
// 无剩余时间返回false
this.acquireFailed(waitTime, unit, threadId);
boolean var24 = false;
return var24;
} else {
boolean var16;
do {
long currentTime = System.currentTimeMillis();
// 如果剩余等待时间依然有剩余,就可以再次尝试获取锁
ttl = this.tryAcquire(waitTime, leaseTime, unit, threadId);
if (ttl == null) {
// 如果加锁成功返回true
var16 = true;
return var16;
}
// 否则不断计算剩余等待时间
time -= System.currentTimeMillis() - currentTime;
if (time <= 0L) {
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
currentTime = System.currentTimeMillis();
if (ttl >= 0L && ttl < time) {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture)).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
} while(time > 0L);
this.acquireFailed(waitTime, unit, threadId);
var16 = false;
return var16;
}
} finally {
this.unsubscribe((RedissonLockEntry)this.commandExecutor.getNow(subscribeFuture), threadId);
}
}
}
}
tryAcquire方法点进去,一直到tryAcquireAsync0->tryAcquireAsync->tryLockInnerAsync方法,这里面是尝试获取分布式锁redis lua脚本。变量KEYS[1]是锁key,ARGV[1]是锁过期时间,ARGV[2]是当前线程id。 lua脚本能够保证操作的原子性,这里判断锁是否存在或者是当前线程,锁的次数加 1 并重置有效期。反之无法加锁则返回锁的剩余等待时间。
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return this.commandExecutor.syncedEval(this.getRawName(), LongCodec.INSTANCE, command,
// 判断锁是否存在,或者锁已经存在,判断threadId是否是自己
"if ((redis.call('exists', KEYS[1]) == 0) or (redis.call('hexists', KEYS[1], ARGV[2]) == 1))
// 锁次数加1
then redis.call('hincrby', KEYS[1], ARGV[2], 1);
// 设置有效期
redis.call('pexpire', KEYS[1], ARGV[1]);
// 返回结果
return nil;
end;
// 没获取到锁,返回锁的剩余等待时间
return redis.call('pttl', KEYS[1]);",
Collections.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});
}
释放锁
同样使用到了 lua 脚本,如果是自己的线程,重入次数 - 1,当可重入次数为 0 删除锁,否则重置有效期。
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 判断锁是否是自己持有
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0)
// 不是自己持有,直接返回
then return nil;
end;
// 是自己持有的锁,重入次数-1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
// 可重入次数是否为0
if (counter > 0)
// 大于0,不能释放锁,设置有效期
then redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
// 等于0,删除锁
redis.call('del', KEYS[1]);
redis.call(ARGV[4], KEYS[2], ARGV[1]);
return 1;
end;
return nil;",
Arrays.asList(this.getRawName(), this.getChannelName()), new Object[]{LockPubSub.UNLOCK_MESSAGE, this.internalLockLeaseTime, this.getLockName(threadId), this.getSubscribeService().getPublishCommand()});
}
8 lua脚本
优点
- 简单易学:Lua语言采用简单直观的语法和易于理解的编程模型,使得初学者可以快速上手。
- 轻量高效:Lua语言的解释器非常轻量级,运行速度快,并且占用系统资源少。
- 高度可扩展:Lua语言具有嵌入式特性,可以与其他编程语言(如C、C++)进行无缝集成,使得开发者可以轻松扩展语言功能。
- 可移植性强:Lua语言在多个平台上都具有良好的兼容性,可以在各种操作系统和嵌入式设备上运行。
缺点
- 缺乏标准库:虽然 Lua 有许多强大的第三方库,但它自带的标准库不够完整,比如不支持正则表达式、XML 解析和数据库操作等。这对于一些需要大量处理字符串或数据操作的应用可能会带来不便。
- 较小的生态圈和比较小众的用户群体:相对于其他主流编程语言,Lua 的用户群体比较小众,因此在开发过程中可能会缺少相关的支持和资料。此外,开发者需要自行探索适合自己的解决方案。
- 面向对象支持较弱:尽管Lua支持面向对象编程,但是它没有内置面向对象语法,例如类和继承,这些需要通过元表或第三方库来实现。对于需要大量面向对象编程的项目,这会增加开发复杂度和代码量。
9 其他面试题
- 热点数据集中失效问题
- 设置不同的过期时间,在设置缓存过期时间的时候,将过期时间错开,比如在一个基础的时间上加上或者减去一个范围内的随机值。
- 数据库访问时加互斥锁
- 百万用户访问的热点key问题如何解决?
- 负载均衡多个读节点,备份热点Key
- Redis中上亿个key,如何把固定前缀的key查出来?
- scan命令