一、数据类型
1.1、字符串(String)
1.1.1、底层结构
底层结构是简单动态字符串SDS。
struct sdshdr {
// 记录 buf 数组中已使用字节的数量,等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存实际的字符串数据(包括结尾的 '\0',但对用户透明)
char buf[];
};
1.1.2、空间逻辑
空间预分配
如果长度扩容后的空间小于1M,则长度扩容一倍;大于1M,则长度扩容新增1M。
空间懒释放
如果长度缩短,redis不会立即释放空间,只是修改len和free。
最大空间
512M
1.1.3、使用场景
缓存数据库数据(频繁的新增或者查询),分布式锁使用,存储json数据。
1.2、哈希(Hash)
1.2.1、底层结构
哈希的底层实现依赖于两种不同的数据结构:ziplist(压缩列表) 和 hashtable(哈希表),具体使用哪种取决于哈希中元素的数量和元素值的大小。
ziplist(压缩列表):
当哈希中的元素数量较少且每个元素的键和值都比较小时,Redis 使用 ziplist 来存储哈希。
hashtable(哈希表):
当哈希中的元素超过一定阈值或者单个元素过大时,Redis 将自动转换为 hashtable 结构来存储哈希。
1.2.2、使用场景
存储对象(修改对象属性比较多),商品信息,计数器集合(多个维度的计数)等
1.3、列表(List)
1.3.1、底层结构
列表的底层实现统一采用 quicklist
结构,这是一个精心设计的双向链表,其节点是 ziplist
(压缩列表)。这种设计完美融合了 ziplist
的高内存利用率和链表的高效插入/删除特性。
quicklist
是一个双向链表 (head
和tail
指针),链表的每个节点 (quicklistNode
) 包含一个ziplist
。ziplist
本身是一块连续内存,用于紧凑存储多个列表元素。ziplist
的优势:内存效率高, 连续存储,无指针开销。存储小整数和短字符串时压缩率极高。quicklist
的优势 (整体结构):平衡内存与性能:ziplist
解决了链表指针的内存开销问题。quicklist
通过将大列表分割成多个ziplist
节点,避免了单个超大ziplist
修改时(尤其是在中间插入/删除)昂贵的重分配和连锁更新开销;高效的两端操作:LPUSH
/RPUSH
/LPOP
/RPOP
操作通常只涉及头/尾节点的ziplist
,时间复杂度接近 O(1)。如果头/尾节点的ziplist
未满,操作非常快且无内存分配;如果满了,只需在链表头/尾新增一个quicklistNode
(也是 O(1));
1.3.2、使用场景
消息队列(需要消息持久化,历史消息回溯,可靠的消费确认),栈,文章评论列表。
适用于简单的时间或者插入顺序。
1.4、集合(Set)
1.4.1、底层结构
Redis 的集合(Set)是一个无序、元素唯一的数据结构,它提供了高效的成员添加、删除、检查存在性以及集合运算(交集、并集、差集)的能力。
Redis 集合底层使用两种主要编码:intset
(整数集合),hashtable
(哈希表)
1.4.2、使用场景
去重存储(网站的访问用户),关系运算(互关好友)。
适用于利用唯一,无需这个特性。
1.5、有序集合(Sorted Set)
1.5.1、底层结构
Redis 的有序集合(Sorted Set,简称 ZSet)是其最强大的数据结构之一,它结合了集合(Set)的唯一性和按分数(Score)排序的特性。每个元素关联一个浮点数类型的分数(Score),集合内的元素按分数升序排序(分数小的在前),分数相同的元素按字典序(lexicographical order) 排序。其底层实现巧妙平衡了查询、插入性能和内存占用。
底层结构特点:
ziplist
(压缩列表):
有序集合保存的元素数量 <=
zset-max-ziplist-entries
(默认128
)。有序集合中所有元素的值(成员)的字符串长度 <=
zset-max-ziplist-value
(默认64
字节)。
skiplist
(跳跃表) + dict
(字典):
- 适用条件: 当不满足
ziplist
的任一条件时使用。 dict
(字典 / 哈希表) :Key
: 存储集合的成员(member)。Value
: 存储该成员对应的分数(score)。作用: 提供 O(1) 时间复杂度 的成员到分数的查找 (ZSCORE
命令的核心)。zskiplist
(跳跃表):一个多层的、有序的链表结构。每个节点包含:1.成员(member),2.分数(score) - 节点按分数升序排列,分数相同的按成员字典序排列。3.后退指针(backward pointer) - 指向前一个节点,用于从后向前遍历。4.层级(level)数组 - 每个层级包含:前进指针(forward pointer) - 指向该层级的下一个节点。跨度(span) - 记录该层级前进指针跳过了多少个节点(用于计算排名ZRANK
)。
1.5.2、使用场景
排行榜系统,带权重的队列/延迟队列
二、集群模式
2.1、主从复制模式
2.1.1、特点
包含一个主节点(Master)和一个或者多个从节点(Slave)。主节点负责所有的写操作和读操作,从节点复制主节点的数据并负责读操作。当主节点发生异常,可以将一个从节点升级为主节点,但是必须手动操作。
2.1.2、优缺点
优点:
简单易用,适合读多写少的场景。
缺点:
不具备故障自动转移到能力,没办法容错和恢复。
主节点故障时,如果没有把数据复制到从节点,也会导致数据的不一致。
2.2、哨兵模式
2.2.1、特点
为了解决主从复制模式无法自动容错和恢复,引入哨兵模式。
在主从模式的基础上,新增哨兵节点。哨兵节点是特殊的redis节点,主要监控主节点和从节点的状态。当主节点发生故障的时候,哨兵节点会从正常的从节点选择一个节点升级为主节点,并通知其他从节点。
通常会部署多个哨兵节点。
2.2.2、优缺点
优点:
减少人为的干预,确保服务的持续可用
缺点:
额外的配置和哨兵实例的管理。
2.3、分布式集群模式
2.3.1、特点
它将数据自动分片到多个节点上,每个节点负责一部分数据。
每个分片都有一个主节点和多个从节点,主节点负责写操作,从节点复制数据和处理读操作。
当某个主节点出现故障,Cluster会尝试将该节点标记为不可用,并从可用的从节点中提升一个节点为主节点。
2.3.2、优缺点
优点:
适用于大规模的应用场景,提供了更好的横向扩展和容错能力。
数据分片,每个节点都单独的对外服务,不存在单点故障问题。
缺点:
实现复杂。
如果某个槽位对应的节点不可用,则该槽位上的所有键都无法访问。
三、缓存设计优化
3.1、缓存穿透
3.1.1、问题描述
查询一个不存在的数据,导致每次都查询到数据库,引起数据库崩溃。
3.1.2、解决方案
- 缓存空对象数据。
- 通过布隆过滤器检查请求参数。
- 非法的参数校验。
3.2、缓存击穿
3.2.1、问题描述
查询过期的热点数据,导致大量的请求同时查询数据库,引起数据库崩溃。
3.2.2、解决方案
- 缓存时间设置永久,不建议。
- 如果更新不频繁,可以在未查询到缓存之后,设置分布式锁,设置缓存。
- 如果更新频繁,可以使用定时任务主动构建缓存或者延长缓存时间。
3.3、缓存血崩
3.3.1、问题描述
同一时间大量的缓存数据失效或过期,导致请求查询数据库,引起数据库崩溃。
3.3.2、解决方案
- 差异化随机过期时间
- 高可用缓存架构,redis集群和多级缓存结构(本地缓存)
- 熔断降级与限流
3.4、缓存不一致
3.3.1、问题描述
由于用户在修改数据库后,缓存却因为各种原因没有及时更新,后续用户在查询时直接查询redis缓存,那么用户得到的是旧数据,而此时数据库中的数据却是新数据,这就是双写不一致的情况。
3.3.2、解决方案
- 延迟缓存双删(对缓存进行两次删除,分别是在写入数据库之前和写入数据库完成之后对缓存中的key进行删除)
- 异步更新缓存,基于订阅binlog,一旦mysql产生更新操作可以把binlog相关的消息推送至redis,redis根据binlog的记录对redis进行更新,可以使用阿里的canal,对mysql的binlog进行订阅
3.5、删除策略
惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
3.6、内存淘汰策略
名称 | 含义 |
---|---|
noeviction | 超过内存上限则报错,默认策略 |
volatile-lru | 在过期的key中,移除最近最少使用的,优先清理那些不再频繁使用的临时数据 |
volatile-random | 在过期的key中,随机移除某个key,不需要特别关注访问频率时 |
volatile-ttl | 在过期的key中,移除过期最早的,应用程序依赖于 TTL 来自动清理旧数据 |
allkeys-lru | 在全部的key中,移除最近最少使用的key,适合通用型缓存应用场景 |
allkeys-random | 在全部的key中,随机移除某个key,不需要特别关注访问频率时 |
volatile-lfu | 在过期的key中,移除使用频率最少的key,优先清理那些不再频繁使用的临时数据 |
allkeys-lfu | 在全部的key中,移除使用频率最少的key,适合通用型缓存应用场景 |
四、分布式锁
4.1、实现
4.1.1、基于Redisson实现
Redisson是一个基于redis的客户端,它提供了分布式锁功能。为了避免锁超时,引入了看门狗的机制,它可以帮助我们延迟锁的有效期。默认情况下看门狗锁超时时间是30秒,
- 自动续租:当一个Redisson客户端实例获取到一个分布式锁时,如果没有指定锁的超时时间,Watchdog会基于Netty的时间轮启动一个后台任务,定期向Redis发送命令,重新设置锁的过期时间,通常是锁的租约时间的1/3。这确保了即使客户端处理时间较长,所持有的锁也不会过期。
- 续期时长:默认情况下,每10s钟做一次续期,续期时长是30s。
- 停止续期:当锁被释放或者客户端实例被关闭时,Watchdog会自动停止对应锁的续租任务。
public class RedissonLock extends RedissonExpirable implements RLock {
// 加锁
private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {
return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
}
RFuture<Boolean> ttlRemainingFuture = tryLockInnerAsync(waitTime,
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// lock acquired
if (ttlRemaining) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
// lua脚本加锁
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 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(getName()), internalLockLeaseTime, getLockName(threadId));
}
// 线程判断
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
renewExpiration();
}
}
// 延迟锁时间
private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getName() + " expiration", e);
return;
}
if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
}
一旦客户端挂了但是锁还没释放怎么办?
如果,应用集群中的一台机器,拿到了分布式锁,但是在执行的过程中,他挂了,还没来得及把锁释放,那么会有问题么?
因为我们知道,锁的续期是Redisson实现的,而Redisson的后台任务是基于JVM运行的,也就是说,如果这台机器挂了,那么Redisson的后台任务也就没办法继续执行了。
那么他也就不会会再继续续期了,那么到了期限之后,锁就会自动解除了。这样就可以避免因为一个实例宕机导致分布式锁的不可用。
Redisson解锁失败,watchdog会不会一直续期下去?
不会的,因为在解锁过程中,不管是解锁失败了,还是解锁时抛了异常,都还是会把本地的续期任务停止,避免下次续期。
4.1.2、基于RedLock
RedLock是Redis的作者提出的一个多节点分布式锁算法,旨在解决使用单节点Redis分布式锁可能存在的单点故障问题.
在进行加锁操作时,RedLock会向每个Redis节点发送相同的命令请求,每个节点都会去竞争锁,如果至少在大多数节点上成功获取了锁,那么就认为加锁成功。反之,如果大多数节点上没有成功获取锁,则加锁失败。这样就可以避免因为某个Redis节点故障导致加锁失败的情况发生。
在redis集群中有3个节点的情况下:
1、客户端想要获取锁时,会生成一个全局唯一的ID(官方文档建议使用系统时间来生成这个ID)
2、客户端尝试使用这个ID获取所有redis节点的同意,这一步通过使用SETNX命令实现。
3、如果有2个以上的节点同意,那么锁就被成功设置了。
4、获取锁之后,用户可以执行想要的操作。
5、最后,不想用这把锁的时候,再尝试依次解锁,无论锁是否成功获取。
需要注意的是,RedLock并不能完全解决分布式锁的问题。例如,在脑裂的情况下,RedLock可能会产生两个客户端同时持有锁的情况。
废弃使用的原因
导致 Redisson 废弃 RedLock ,可能有以下几个原因:
- 缺乏官方认证:尽管 RedLock 算法由 Redis 的创始人提出的,他后来指出,这种算法并没有经过彻底的检验,并且他不推荐在需要严格一致性的分布式系统中使用它。
- 安全性和可靠性的担忧:在某些情况下,RedLock 算法可能无法保证互斥性,特别是在网络分区和节点故障的情况下。这种不确定性可能导致算法在分布式环境中的锁定行为不是完全可靠的。
- 维护和操作复杂性:实现和维护 RedLock 需要对多个 Redis 实例进行操作,这不仅增加了部署的复杂性,也增加了出错的可能性。
- Martin Kleppmann 的批评:分布式系统领域的知名研究者和作者Martin Kleppmann ,在他的分析中指出,RedLock 算法存在几个关键问题,可能导致它在某些故障模式下不能正确地提供锁服务。
当然,以上只是一些猜测,主要其实就是官方不认可,并且作者表示也不承担责任,所以在 Redisson 这种框架中就不再支持他了。
4.1.3、基于ZooKeeper
在使用 ZooKeeper 实现分布式锁时,通常会创建一个特定路径下的临时顺序节点来表示锁的存在。每个客户端尝试获取锁时都会在这个路径下创建一个新的顺序节点,并检查自己是否是当前最小的节点(即第一个节点)。如果不是,则监听前一个节点的变化,等待它被删除(意味着锁释放),然后再次尝试获取锁。
实现步骤
创建锁节点:所有参与竞争锁的客户端会在ZooKeeper的一个指定目录下创建临时顺序节点。
确定锁的所有者:创建节点后,客户端会列出该目录下的所有子节点,并根据名称排序。如果某个客户端创建的节点是最小的那个,那么它就获得了锁;否则,它需要找到比自己小的那个节点,并对其设置一个watcher(监听器)。
等待锁释放:没有获得锁的客户端将阻塞并监听前面的节点。一旦前面的节点被删除(即锁被释放),监听它的客户端就会收到通知,此时该客户端可以再次检查自己是否成为了最小的节点,如果是,则获得锁。
释放锁:当持有锁的客户端完成任务后,它会删除自己的节点,这将触发对下一个等待客户端的通知,从而允许它们尝试获取锁。
4.2、场景
4.2.1、避免资源竞争
场景:多个节点同时操作共享资源(如数据库、文件、服务)。
案例:
库存扣减:1000个请求同时抢购100件商品,用商品ID作为锁Key,确保超卖。
账户余额修改:防止并发转账导致余额计算错误。
4.2.2、幂等性保障
场景:网络重试或消息重复消费时,避免重复执行业务逻辑。
案例:
支付回调:用订单ID作为锁Key,防止支付平台重复回调导致多次记账。
MQ消息消费:消息ID加锁,确保消息只被处理一次。
4.2.3、分布式任务调度
场景:确保集群中只有一个节点执行定时任务。
案例:
数据统计任务:多个节点竞争锁,获取锁的节点执行每日报表生成。
缓存预热:仅一个节点加载热点数据到缓存。
4.2.4、临界资源互斥访问
场景:控制对物理设备或共享服务的访问。
案例:
打印机任务队列:防止多台服务器同时发送打印指令。
第三方API调用:限制对短信网关的并发请求(如1秒1次)。