这篇文章是复习所用,质量一般
NoSQL
NoSQL是指“不仅仅是SQL”的一种数据库管理系统,它与传统的关系型数据库管理系统(RDBMS)模型有所不同。传统的RDBMS系统(如MySQL、PostgreSQL和Oracle)依赖于结构化查询语言(SQL)来定义和操作数据,而NoSQL数据库提供了更灵活的数据模型,通常为了可扩展性、性能和开发的便利性而放弃了SQL数据库的某些特性。
一些常见类型的NoSQL数据库包括:
-
文档型数据库:这些数据库将半结构化数据以文档的形式存储,通常使用JSON或BSON(二进制JSON)格式。例如MongoDB、Couchbase和CouchDB。
-
键值存储:这些数据库将数据存储为键值对,其中每个键是唯一的并映射到一个值。例如Redis、Amazon DynamoDB和Riak。
-
列族存储:这些数据库将数据组织成列而不是行,适用于需要存储大量具有不同属性的数据的应用程序。例如Apache Cassandra和HBase。
-
图数据库:这些数据库专为管理高度相互关联的数据而设计,如社交网络或推荐引擎。例如Neo4j、Amazon Neptune和Apache TinkerPop。
NoSQL数据库的应用场景
- 大数据应用程序:NoSQL数据库通常能够更好地处理大规模的数据,特别是分布式环境下的数据存储和处理。
- 实时数据分析:对于需要快速地存储和分析大量实时数据的场景,NoSQL数据库往往能够提供更好的性能和可扩展性。
- 非结构化数据:当数据具有不固定的结构或者需要频繁地修改数据模型时,NoSQL数据库的灵活性可以提供更好的支持。
- 高并发的Web应用:NoSQL数据库通常能够更好地应对高并发访问和快速的数据写入操作,适合用于支持大规模Web应用程序。
- 复杂的数据关系:对于需要处理复杂的数据关系,如社交网络的图数据结构,NoSQL的图数据库是一个很好的选择。
SQL数据库的应用场景
- 事务处理:对于需要强一致性和支持复杂事务的应用,SQL数据库通常提供更可靠的支持。
- 复杂查询:SQL数据库以结构化查询语言(SQL)为基础,能够执行复杂的查询操作,包括连接、聚合等操作。
- 数据完整性和约束:SQL数据库通常支持各种数据完整性和约束条件,如主键、外键、唯一约束等,确保数据的一致性和正确性。
- 标准化数据模型:对于需要固定且严格定义的数据模型,如金融应用或企业级应用,SQL数据库提供了更合适的解决方案。
- 小规模应用:对于数据量相对较小且数据模型相对简单的应用,SQL数据库可能更加适用,并且更易于管理和维护。
Redis
Redis是一个开源的内存数据存储系统,也可以用作缓存和消息队列代理。它支持多种数据结构,包括字符串、哈希、列表、集合、有序集合等,并提供了丰富的操作命令来对这些数据结构进行处理。
特点和优势:
- 内存存储:Redis主要将数据存储在内存中,因此具有快速的读写速度,低延迟,速度快(基于内存、IO多路复用、良好的编码)
- 持久化:Redis支持多种持久化方式,包括快照(snapshot)和追加式文件(append-only file),可以将数据持久化到磁盘上,以防止数据丢失。
- 数据结构丰富:Redis支持多种数据结构,如字符串、哈希、列表、集合、有序集合等,能够满足不同场景下的需求。
- 原子性:单线程,每个命令具备原子性。
- 集群和复制:Redis支持主从复制和分片(sharding),可以构建高可用性和高扩展性的集群。
- 支持事务:Redis本身不支持事务,但是可以将多个操作封装成一个事务进行执行,保证事务的原子性。
- 发布/订阅:Redis提供了发布/订阅(Pub/Sub)功能,可以实现消息的发布和订阅,用于构建消息队列等应用。
应用场景:
- 缓存:由于其快速的读写速度和丰富的数据结构,Redis常被用作缓存服务器,加速应用程序的访问速度。
- 会话存储:可以将用户会话数据存储在Redis中,以提高Web应用程序的性能和可伸缩性。
- 计数器:Redis的原子操作特性使其很适合用作计数器,如网站的访问量计数器。
- 消息队列:通过Redis的发布/订阅功能,可以实现消息队列系统,用于解耦和异步处理任务。
- 实时排行榜:利用Redis的有序集合数据结构,可以轻松实现实时排行榜功能。
- 分布式锁:通过Redis的原子操作和分布式特性,可以实现分布式锁,用于控制并发访问。
为什么使用 redis?(一)性能我们在碰到需要执行耗时特别久,且结果不频繁变动的 SQL ,就特别适合将运行结果放入缓存。这样, 后面的请求就去缓存中读取,使得请求能够迅速响应 。(二)并发在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用 redis 做一个缓冲操作,让请求先访问到redis ,而不是直接访问数据库
Redis安装完成后就自带了命令行客户端:redis-cli,使用方式如下:
redis-cli [options] [commonds]其中常见的options有:
-h 127.0.0.1
:指定要连接的redis节点的IP地址,默认是127.0.0.1
-p 6379
:指定要连接的redis节点的端口,默认是6379
-a 123321
:指定redis的访问密码其中的commonds就是Redis的操作命令,例如:
ping
:与redis服务端做心跳测试,服务端正常会返回pong
不指定commond时,会进入
redis-cli
的交互控制台:
Redis的基本数据类型
hash这个是类似 HashMap 的一种结构,这个一般就是可以将结构化的数据,比如一个对象(前提是 这个对象没 嵌套其他的对象 )给缓存在 redis 里,然后每次读写缓存的时候,可以就操作 hash 里的 某个字段 。
在Redis中,有两个重要的概念:键(key)和哈希键(hash key),它们之间有一些区别。
键(Key):
- 键是 Redis 中最基本的数据结构,用于唯一标识存储在 Redis 中的数据。
- Redis中的键是一个二进制安全的字符串,可以包含任何字节序列,最大长度为512MB。
- 键可以对应多种数据类型,如字符串、列表、哈希表、集合、有序集合等。
- 键必须是唯一的,如果尝试使用相同的键存储新的值,则会覆盖原有的值。
哈希键(Hash Key):
- 哈希键是指 Redis 中的哈希表数据结构中的字段(field),用于唯一标识哈希表中的单个键值对。
- 在 Redis 中,哈希键可以被看作是哈希表中的子键。
- 哈希表的每个键都是一个键值对集合,其中每个键值对都有一个唯一的哈希键。
- 每个哈希键都与一个值(value)相关联,可以是字符串、整数等数据类型。
list 是有序列表,这个可以玩儿出很多花样比如可以通过 list 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的东西。比如可以通过 lrange 命令,读取某个闭区间内的元素,可以基于 list 实现分页查询,这个是很棒的一个 功能,基于 redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西,性能高,就一页 一页走。
Redis的Java客户端
SpringDataRedis
SpringDataRedis的序列化方式
RedisTemplate可以接收任意0bject作为值写入Redis,只不过写入前会把0bject序列化为字节形式,默认是采用JDK序列化
尽管JSON的序列化方式可以满足我们的需求,但依然存在一些问题
为了在反序列化时知道对象的类型,JSON序列化器会将类的class类型写入json结果中,存入Redis,会带来额外的内存开销。
为了节省内存空间,我们并不会使用JSON序列化器来处理value,而是统一使用String序列化器,要求只能存储String类型的key和value。当需要存储]ava对象时,手动完成对象的序列化和反序列化。
写入redis时,将对象使用ObjectMapper转换成json,mapper.writeValueAsString(对象)
读取redis数据时,使用mapper.readValue(对象字节码文件)或者JSONUtil.toJsonStr()
Java序列化
是Java编程语言中的一项特性,它允许将对象转换为字节流,以便可以在网络上传输或持久化到文件系统中。序列化的主要目的是在不同的Java虚拟机(JVM)之间或在不同的操作系统之间传输Java对象,并在需要时重新构造这些对象。
原理和用法:
-
实现Serializable接口:要使Java对象可序列化,需要让该对象的类实现
java.io.Serializable
接口。这是一个标记接口,不包含任何方法,其存在只是为了告诉Java虚拟机这个类是可序列化的。 -
序列化对象:一旦一个类实现了
Serializable
接口,就可以使用ObjectOutputStream
类将其对象序列化为字节流。通过调用ObjectOutputStream
的writeObject()
方法,可以将对象写入输出流。 -
反序列化对象:通过
ObjectInputStream
类,可以将字节流反序列化为对象。通过调用ObjectInputStream
的readObject()
方法,可以从输入流中读取对象。
- 序列化和反序列化过程中,被序列化的类的所有成员变量都应该是可序列化的,否则会抛出
java.io.NotSerializableException
异常。 - 序列化并不保存静态变量的状态,因为它们属于类而不是实例。
- 序列化不保存方法体,仅保存类的状态。
- 序列化在跨平台和跨语言的通信中也可以有用,但需要注意不同平台和语言的兼容性问题。
缓存更新
缓存的通用模型有三种:
-
Cache Aside
:有缓存调用者自己维护数据库与缓存的一致性。即:-
查询时:命中则直接返回,未命中则查询数据库并写入缓存
-
更新时:更新数据库并删除缓存,查询时自然会更新缓存
-
-
Read/Write Through
:数据库自己维护一份缓存,底层实现对调用者透明。底层实现:-
查询时:命中则直接返回,未命中则查询数据库并写入缓存
-
更新时:判断缓存是否存在,不存在直接更新数据库。存在则更新缓存,同步更新数据库
-
-
Write Behind Cahing
:读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库
主动更新
旁路缓存Cache Aside
Cache Aside
的写操作是要在更新数据库的同时删除缓存,那为什么不选择更新数据库的同时更新缓存,而是删除呢?
原因很简单,假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新。采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。
先删除缓存,再操作数据库,由于更新数据库的操作本身比较耗时,在期间有线程来查询数据库并更新缓存的概率非常高。因此不推荐这种方案。
先操作数据库,再删除缓存,可以发现,异常状态发生的概率极为苛刻,线程1必须是查询数据库已经完成,但是缓存尚未写入之前。线程2要完成更新数据库同时删除缓存的两个操作。要知道线程1执行写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低。
综上,添加缓存的目的是为了提高系统性能,而你要付出的代价就是缓存与数据库的强一致性。如果你要求数据库与缓存的强一致,那就需要加锁避免并行读写。但这就降低了性能,与缓存的目标背道而驰。
因此不管任何缓存同步方案最终的目的都是尽可能保证最终一致性,降低发生不一致的概率。我们采用先更新数据库再删除缓存的方案,已经将这种概率降到足够低,目的已经达到了。
同时我们还要给缓存加上过期时间,一旦发生缓存不一致,当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。
Write Through(写穿缓存)
- 每次写操作时,先更新缓存,再同步更新数据库。
- 读操作则只从缓存中读取数据。
- 写操作的性能较差,因为每次写操作都需要同步更新缓存和数据库
Read Through(读穿缓存)
- 读取操作由缓存层处理,缓存未命中时,缓存层从数据库中加载数据,并更新缓存。
- 对于写操作,通常结合 Write Through 或 Write Behind 进行数据更新。
- 对缓存系统要求较高,需要缓存系统支持自动从数据库加载数据。
Write Behind(也称为 写回缓存
)-实习遇到
- 当有写操作时,数据会首先写入缓存,同时标记为“脏数据”(Dirty)。缓存的更新立即返回给调用方,不直接更新数据库。
- 数据在缓存中存储一段时间后,或者当缓存中的脏数据达到一定阈值时,后台异步任务或定时任务会将这些脏数据批量地写入数据库。
- 存在缓存和数据库数据不一致的风险,特别是在缓存尚未写回数据库时出现故障的情况。
缓存穿透
day10-Redis面试篇 - 飞书云文档 (feishu.cn)
缓存空值
布隆过滤器
缓存雪崩
常见的解决方案有:
-
给不同的Key的TTL添加随机值,这样KEY的过期时间不同,不会大量KEY同时过期
-
利用Redis集群提高服务的可用性,避免缓存服务宕机
-
给缓存业务添加降级限流策略
-
给业务添加多级缓存,比如先查询本地缓存,本地缓存未命中再查询Redis,Redis未命中再查询数据库。即便Redis宕机,也还有本地缓存可以抗压力
缓存击穿
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
由于我们采用的是Cache Aside
模式,当缓存失效时需要下次查询时才会更新缓存。当某个key缓存失效时,如果这个key是热点key,并发访问量比较高。就会在一瞬间涌入大量请求,都发现缓存未命中,于是都会去查询数据库,尝试重建缓存。可能一瞬间就把数据库压垮了。
如上图所示:
-
线程1发现缓存未命中,准备查询数据库,重建缓存,但是因为数据比较复杂,导致查询数据库耗时较久
-
在这个过程中,一下次来了3个新的线程,就都会发现缓存未命中,都去查询数据库
-
数据库压力激增
面试题:如何保证缓存的双写一致性?
答:缓存的双写一致性很难保证强一致,只能尽可能降低不一致的概率,确保最终一致。我们项目中采用的是
Cache Aside
模式。简单来说,就是在更新数据库之后删除缓存;在查询时先查询缓存,如果未命中则查询数据库并写入缓存。同时我们会给缓存设置过期时间作为兜底方案,如果真的出现了不一致的情况,也可以通过缓存过期来保证最终一致。追问:为什么不采用延迟双删机制?
答:延迟双删的第一次删除并没有实际意义,第二次采用延迟删除主要是解决数据库主从同步的延迟问题,我认为这是数据库主从的一致性问题,与缓存同步无关。既然主节点数据已经更新,Redis的缓存理应更新。而且延迟双删会增加缓存业务复杂度,也没能完全避免缓存一致性问题,投入回报比太低。
面试题:如何解决缓存穿透问题?
答:缓存穿透也可以说是穿透攻击,具体来说是因为请求访问到了数据库不存在的值,这样缓存无法命中,必然访问数据库。如果高并发的访问这样的接口,会给数据库带来巨大压力。
我们项目中都是基于布隆过滤器来解决缓存穿透问题的,当缓存未命中时基于布隆过滤器判断数据是否存在。如果不存在则不去访问数据库。
当然,也可以使用缓存空值的方式解决,不过这种方案比较浪费内存。
面试题:如何解决缓存雪崩问题?
答:缓存雪崩的常见原因有两个,第一是因为大量key同时过期。针对问这个题我们可以可以给缓存key设置不同的TTL值,避免key同时过期。
第二个原因是Redis宕机导致缓存不可用。针对这个问题我们可以利用集群提高Redis的可用性。也可以添加多级缓存,当Redis宕机时还有本地缓存可用。
-1 永久有效 -2 key 不存在
根据Redis 4.0.0,HMSET被视为已弃用。请在新代码中使用HSET。
面试题:如何解决缓存击穿问题?
答:缓存击穿往往是由热点Key引起的,当热点Key过期时,大量请求涌入同时查询,发现缓存未命中都会去访问数据库,导致数据库压力激增。解决这个问题的主要思路就是避免多线程并发去重建缓存,因此方案有两种。
第一种是基于互斥锁,当发现缓存未命中时需要先获取互斥锁,再重建缓存,缓存重建完成释放锁。这样就可以保证缓存重建同一时刻只会有一个线程执行。不过这种做法会导致缓存重建时性能下降严重。
第二种是基于逻辑过期,也就是不给热点Key设置过期时间,而是给数据添加一个过期时间的字段。这样热点Key就不会过期,缓存中永远有数据。
查询到数据时基于其中的过期时间判断key是否过期,如果过期开启独立新线程异步的重建缓存,而查询请求先返回旧数据即可。当然,这个过程也要加互斥锁,但由于重建缓存是异步的,而且获取锁失败也无需等待,而是返回旧数据,这样性能几乎不受影响。
需要注意的是,无论是采用哪种方式,在获取互斥锁后一定要再次判断缓存是否命中,做dubbo check. 因为当你获取锁成功时,可能是在你之前有其它线程已经重建缓存了。
黑马点评业务逻辑
session登录,将用户信息放在threadlocal中
设置null值解决缓存穿透
给不同的Key的TTL添加随机值,预防缓存雪崩
给热点Key设置过期时间,而是给数据添加一个过期时间的字段。这样热点Key就不会过期,缓存中永远有数据,从而解决缓存击穿
解决一人一单问题,从而使用了分布式锁
在集群模式下,每个线程都有自己锁,导致锁都可以有个线程获取。
![](https://i-blog.csdnimg.cn/blog_migrate/c095c9260a256c5af73870fae8fdab9b.png)
超卖问题
产生超卖问题的原因
- 并发请求:多个用户同时下单,导致多个请求同时读取到相同的库存量,并且各自进行扣减,导致实际库存扣减量超过了实际库存。
- 数据库事务隔离级别不当:如果数据库事务隔离级别过低(如Read Committed),可能导致读取到不一致的数据,进而导致超卖。
- 缓存不一致:如果库存信息被缓存,并且缓存没有及时同步更新,会导致库存数据的不一致,从而可能导致超卖。
解决超卖问题的方法
-
分布式锁:使用Redis的分布式锁来控制对库存的并发访问。在每次扣减库存时,先获取锁,确保在同一时间只有一个请求能够修改库存数据。
-
数据库悲观锁:在扣减库存时使用数据库的悲观锁(如
SELECT ... FOR UPDATE
),锁定库存记录,防止其他事务在当前事务完成之前修改库存数据。 -
数据库乐观锁:通过版本号或时间戳来控制并发操作,每次更新库存时检查版本号是否与读取时的一致,如果不一致则重试。
-
队列削峰:使用消息队列(如Kafka)将用户的购买请求按顺序排队处理,从而控制并发操作的数量,避免超卖。
-
库存预扣减:在用户下单时,先将库存预扣减(即在数据库或缓存中扣减库存),然后在实际扣款时确认库存。若库存不足,则直接返回失败给用户。
-
高并发环境下的库存扣减流程:
- 用户请求购买时,先通过Redis分布式锁获取锁。
- 检查当前库存是否足够,如果足够,则扣减库存,并生成订单。
- 将扣减的库存变化记录通过消息队列异步写入数据库,以确保数据的一致性。
- 释放Redis锁。
分布式锁误删问题
Redis分布式锁的误删问题
通常是指锁被错误释放,从而导致多个进程或线程同时持有同一个锁,进而引发数据竞争或资源争用的现象。误删问题一般发生在以下几种情况下:
1. 锁的持有者误释放锁
如果一个进程或线程在持有锁期间因为异常或编程错误,误删了不属于它的锁,就会导致其它进程或线程以为锁已经释放,从而误认为自己可以获取锁。
2. 锁的自动过期问题
Redis的分布式锁通常会设置一个自动过期时间,防止锁因为持有者没有主动释放而永远不被释放。然而,如果一个操作执行时间超过了锁的过期时间,锁会自动过期并被Redis删除,此时其它进程可能会获取到这个锁,而原来的锁持有者仍然认为自己持有锁并继续操作。
3. 锁竞争问题
当多个进程或线程竞争同一把锁时,如果锁被不小心释放,多个进程或线程可能会同时持有该锁,导致资源争用和数据不一致。
解决误删问题的方法
-
使用唯一标识:在获取锁时,为锁分配一个唯一标识(如UUID),在释放锁时,先检查该锁的唯一标识是否匹配,只有匹配时才释放锁。这种方式确保了只有锁的持有者才可以释放锁。
-
Redisson库:Redisson库封装了分布式锁的实现,包含了一些防止误删的机制,比如自动续期。使用Redisson可以减少因为锁过期导致的误删问题。
-
合理设置锁的过期时间:根据实际操作时间来合理设置锁的过期时间,确保操作能在锁过期前完成,同时避免锁设置的过期时间过长。
-
Lua脚本:在释放锁时使用Lua脚本,通过原子性操作确保锁的释放和唯一标识的检查在同一操作中完成,避免竞争条件。
在判断锁的持有者和释放锁之间,如果程序发生了阻塞(例如遇到FULL GC),就有可能出现锁过期而被其他线程或进程获取的情况。这时,原本的持有者会继续尝试释放锁,可能导致锁被错误地释放,造成并发问题。
保证判断锁和释放锁的原子性
为了解决这个问题,我们可以使用Redis的Lua脚本来保证锁操作的原子性。Lua脚本在Redis中是单线程执行的,能够确保在脚本执行期间,不会有其他Redis命令插入,从而保证整个操作的原子性。
单线程模型
Redis本身是单线程处理所有命令的,即每次只能执行一个命令。这意味着在Lua脚本执行期间,不会有其他命令插入或执行。
原子性保证
当一个Lua脚本在Redis中执行时,它会被当作一个独立的、不可分割的操作。这意味着整个脚本中的所有Redis命令都会一次性执行完毕,中途不会被其他命令打断。
脚本执行上下文
Lua脚本在Redis中执行时,Redis会为其创建一个独立的执行上下文。这个上下文中,所有对Redis数据的操作都是同步进行的,没有上下文切换和并发的问题。
redisson-待优化
使用setnx不可重入
异步秒杀思路
关注推送
持久化机制
RDB
一篇文章彻底理解Redis持久化:RDB和AOF_rdb和aof存得是什么-CSDN博客
AOF
主从同步
主从同步(Replication)是一种实现数据复制的机制,允许一个 Redis 实例(主节点)将其数据复制到一个或多个 Redis 实例(从节点)。通过主从同步,Redis 实现了高可用性和读写分离等功能。
读性能提升:主从架构允许将读请求分散到多个从节点上,从而减轻主节点的负担。这种读写分离可以显著提高系统的读性能,适合读操作频繁的应用场景。
高可用性:在主从架构中,如果主节点发生故障,可以通过从节点来快速恢复数据和服务。这种故障转移机制提高了系统的高可用性,减少了服务中断的时间。
负载均衡:通过添加多个从节点,可以在多个节点之间平衡读请求的负载,避免单节点成为性能瓶颈。
横向扩展:主从架构支持水平扩展,通过添加更多的从节点,可以轻松应对不断增加的读请求量,而不需要对主节点进行大规模升级。
假设有A、B两个Redis实例,如何让B作为A的slave节点?
1.在 Redis 配置文件中,从节点配置主节点的 IP 地址和端口。在B节点执行命令:slaveofA的IP A的port
2.从节点启动时,通过 slaveof
配置向主节点发送 PSYNC
命令,开始同步数据。
3.如果从节点是第一次连接主节点,或从节点的数据已经过期,需要进行完整同步
-
主节点创建 RDB 快照: 主节点接收到
PSYNC
命令后,会执行BGSAVE
命令创建一个 RDB 快照,同时开始记录从创建 RDB 快照开始的新写操作到一个增量复制缓冲区。 -
传输 RDB 文件: 主节点将 RDB 文件发送给从节点,从节点加载该 RDB 文件,恢复数据。
-
传输增量复制数据: 主节点在 RDB 文件传输完成后,将增量复制缓冲区中的写操作发送给从节点,从节点依次执行这些写操作,完成同步。
4.在初次同步完成后,主节点会继续向从节点发送所有新的写操作,以保持数据的一致性。这些写操作通过主从复制协议传输,从节点接收到后直接执行。
5.如果主从节点之间的网络连接中断,从节点会尝试重新连接主节点并进行重新同步。根据断开时长和增量缓冲区的数据量,重同步可能是完整同步,也可能是部分(增量)同步。
-
部分(增量)同步: 如果从节点的复制偏移量和复制积压缓冲区匹配,主节点只需将缺失的写操作发送给从节点。
-
完整同步: 如果复制偏移量不匹配或积压缓冲区数据不足,必须重新进行完整同步。
6.在高可用性配置中,Redis 主节点故障后,可以通过 Sentinel 或其他方式自动切换主从角色。新的主节点选举完成后,其他从节点会自动切换到新的主节点进行同步。
SYNC 命令
SYNC 是 Redis 早期版本中用于主从同步的命令。当从节点向主节点发送 SYNC 命令时,主节点会执行以下步骤:
- 生成 RDB 快照:主节点会阻塞客户端请求,生成当前数据库的 RDB 快照文件。
- 发送 RDB 文件:主节点将生成的 RDB 文件发送给从节点。
- 复制期间的写操作:在生成 RDB 快照的同时,主节点会记录期间的所有写操作。
- 发送写操作:在 RDB 文件传输完成后,主节点会将记录的写操作发送给从节点。
由于 SYNC 过程中主节点会阻塞所有客户端请求,导致性能下降,而且每次从节点重新连接时都会触发完整同步,这在大型数据集或高写入频率下会产生较大的开销。
PSYNC 命令
PSYNC 是 Redis 2.8 版本引入的改进版同步命令,提供了部分同步的功能,可以在从节点断开连接后重新连接时减少数据传输量。PSYNC 的工作流程如下:
-
初次同步(完整同步):
- 当从节点第一次连接主节点时,从节点会发送 PSYNC 命令,但由于没有偏移量和复制 ID,主节点会触发完整同步,类似于 SYNC 的流程。
- 主节点生成 RDB 快照并发送给从节点,同时记录期间的写操作。
- 传输 RDB 文件后,主节点将记录的写操作发送给从节点。
-
部分(增量)同步:
- 如果从节点在与主节点的连接过程中断开,再次连接时会发送 PSYNC 命令,包含从节点的复制偏移量和主节点的复制 ID。
- 主节点检查从节点的偏移量和自身的复制积压缓冲区(replication backlog)。
- 如果偏移量匹配且积压缓冲区中的数据足够,则主节点只需将缺失的写操作发送给从节点,完成部分同步。
- 如果偏移量不匹配或积压缓冲区数据不足,则需要重新进行完整同步。
Redis 2.8以前采用的复制都为全量复制,使用SYNC命令全量同步复制,SYNC存在很大的缺陷就是:不管slave是第一次启动,还是连接断开后的重连,主从同步都是全量数据复制,严重消耗master的资源以及大量的网络连接资源。Redis在2.8及以上版本使用PSYNC命令完成主从数据同步,PSYNC同步过程分为全量复制和部分复制,完善了SYNC存在的缺陷。
Reids主从同步复制数据主要有2种场景:
1.从服务器从来第一次和当前主服务器连接,即初次复制,不管是SYNC 还是 PSYNC第一次都是全量同步复制数据。
2.从服务器断线后重新和之前连接的主服务器恢复连接,即断线后重复制,SYNC使用的是全量复制,PSYNC使用的是增量复制。
SYNC命令进行主从同步主要有一下几个问题:
1)master服务器执行BGSAVE命令生成RDB文件,这个生成过程会大量消耗主服务器资源(CPU、内存和磁盘I/O资源)。
2)master需要将生成的RBD文件发送给slave,这个发送操作会消耗主从服务器大量的网络资源(带宽与流量)。
3)接收到RDB文件后,slave需要载入RDB文件,载入期间slave会因为阻塞而导致没办法处理命令请求(master不会阻塞)。
4)最大的问题是重连接后回全量同步数据。如上面例子,slave在断开后,再进行重新连时,slave丢掉以前的数据,行全量同步master数据,要是断开到重连期间执行的写命令很少,这种操作就没有必要了。
————————————————版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/Seky_fei/article/details/106877329
哨兵机制
Redis 的哨兵机制(Sentinel)是一个高可用性解决方案,用于监控 Redis 主从复制架构中的主节点和从节点,并在主节点出现故障时自动进行故障转移(failover),以确保系统的高可用性。
工作流程
1. 节点监控
哨兵节点会定期向主节点和从节点发送 PING 命令。如果一个节点在指定时间内未能响应 PING 命令,则哨兵会认为该节点已下线(S_DOWN,Subjectively Down),即主观下线。
2. 主观下线和客观下线
如果一个节点被多数哨兵节点(quorum)认为是主观下线,则该节点会被标记为客观下线(O_DOWN,Objectively Down)。通常,quorum 值在哨兵配置文件中指定,如至少两个哨兵节点同意主节点已下线。
3. 哨兵选举
当主节点被标记为客观下线时,哨兵节点之间会进行协商选举,选出一个领导哨兵(leader sentinel),该领导哨兵负责进行故障转移操作。
4. 故障转移
领导哨兵会选择一个最合适的从节点作为新的主节点,并发送 SLAVEOF NO ONE 命令将其提升为主节点。然后,其他从节点会被重新配置为新的主节点的从节点。
5. 通知客户端
故障转移完成后,哨兵会将新的主节点信息通知给所有相关客户端,客户端会更新其连接配置,指向新的主节点继续工作。
RedisTemplate
主节点配置 (redis-master.conf)
port 6379
bind 0.0.0.0
protected-mode no
从节点配置 (redis-slave.conf)
port 6380
bind 0.0.0.0
protected-mode no
slaveof 127.0.0.1 6379
哨兵配置 (sentinel.conf)
port 26379
sentinel monitor mymaster 127.0.0.1 6379 2
sentinel down-after-milliseconds mymaster 30000
sentinel failover-timeout mymaster 180000
sentinel parallel-syncs mymaster 1
通过以上配置,RedisTemplate
可以自动感知哨兵的故障转移,始终连接到正确的主节点,保证 Redis 的高可用性和数据的可靠性。
数据结构
RedisObject
RedisObject
是 Redis 内部用于表示数据对象的结构。它是 Redis 数据存储中的一个核心组件,用于抽象和管理各种数据类型,如字符串、列表、集合、散列和有序集合。
RedisObject
是 Redis 内部的一个关键结构,用于抽象和管理各种数据类型。通过统一的数据表示和多种编码方式,RedisObject
提供了高效的数据存储和访问,同时通过引用计数和 LRU 时间实现了有效的内存管理。它在 Redis 的各种操作中起到了核心作用,是 Redis 实现高性能和灵活性的基础。
1. 字符串(String)
- Redis 最基本的数据类型,一个键对应一个字符串值。
- 可以存储任意类型的数据,如文本、数字、二进制数据等。
- RAW:普通动态字符串(通常是
sds
)。- 用于存储普通字符串,适合较大的字符串数据。
- EMBSTR:嵌入式字符串。
- 用于存储较小的字符串(<= 44 字节)。这种编码方式将
redisObject
和实际的字符串数据分配在同一块内存中,提高了内存分配和释放的效率。
- 用于存储较小的字符串(<= 44 字节)。这种编码方式将
- INT:整数。
- 用于存储可以表示为 64 位有符号整数的字符串。直接将整数值保存在
redisObject
中,避免了字符串转换的开销。
- 用于存储可以表示为 64 位有符号整数的字符串。直接将整数值保存在
2. 列表(List)
- 一个键对应一个链表,可以存储多个有序的字符串。
- 适用于需要顺序访问的数据,如消息队列等。
- ZIPLIST:压缩列表。
- 用于存储较短的列表(元素数量或总字节数较少)。压缩列表是一个连续的内存块,通过紧凑的方式存储多个元素,适合内存使用较少的情况。
- LINKEDLIST:链表。
- 用于存储较长的列表。链表编码方式在内存分配和操作上更灵活,但内存开销相对较大(已在 Redis 3.2 中被
quicklist
取代)。
- 用于存储较长的列表。链表编码方式在内存分配和操作上更灵活,但内存开销相对较大(已在 Redis 3.2 中被
- QUICKLIST:快速列表。
- Redis 3.2 引入的新编码方式,将压缩列表和链表结合起来。每个快速列表节点包含一个或多个压缩列表,实现了高效的存储和访问。
- Redis 3.2 引入的新编码方式,将压缩列表和链表结合起来。每个快速列表节点包含一个或多个压缩列表,实现了高效的存储和访问。
3. 集合(Set)
- 一个键对应一个无序集合,集合中的元素都是唯一的。
- 适用于需要去重的数据集合,如标签集合等。
- INTSET:整数集合。
- 用于存储所有元素为整数且数量较少的集合。整数集合是一种紧凑的存储方式,内存使用效率高。
- HT:哈希表。
- 用于存储较大的集合或包含非整数元素的集合。哈希表提供了快速的查找、插入和删除操作,但内存使用较多。
4. 有序集合(Sorted Set)
- 类似于集合,但每个元素会关联一个分数,Redis 会按分数自动排序。
- 适用于排行榜等场景。
- ZIPLIST:压缩列表。
- 用于存储较小的有序集合。元素和分值紧密排列在一起,适合元素数量较少的场景。
- SKIPLIST:跳表。
- 用于存储较大的有序集合。跳表通过多级索引实现快速的范围查询和排序操作,适合需要频繁进行范围查询的场景。
- HT:哈希表。
- 用于存储较大的哈希。哈希表提供了快速的字段查找和操作,但内存开销相对较大。
- 利用跳表支持范围查询和有序查询,利用hash支持精确查询。
-
5. 哈希(Hash)
- 一个键对应一个链表,可以存储多个有序的字符串。
- 适用于需要顺序访问的数据,如消息队列等。
- ZIPLIST:压缩列表。
- 用于存储字段数量较少且字段名和值较短的哈希。压缩列表通过紧凑的方式存储键值对,减少内存使用。
- HT:哈希表。
- 用于存储较大的哈希。哈希表提供了快速的字段查找和操作,但内存开销相对较大。
编码方式选择的依据
Redis 会根据数据的大小和具体的使用情况在不同编码方式之间进行自动切换。例如:
- 字符串小于等于 44 字节时使用
EMBSTR
编码,大于 44 字节时使用RAW
编码。 - 列表元素较少时使用
ZIPLIST
编码,超过一定数量时切换为QUICKLIST
编码。 - 集合元素全为整数且数量较少时使用
INTSET
编码,其他情况使用HT
编码。 - 哈希字段较少时使用
ZIPLIST
编码,字段较多时使用HT
编码。 - 有序集合元素较少时使用
ZIPLIST
编码,元素较多时使用SKIPLIST
编码。
SDS
Redis中的字符串键和字符串值的数据结构是SDS,SDS(Simple Dynamic String)是 Redis 内部实现的一种动态字符串数据结构,用于替代 C 语言的原生字符串(null-terminated string)。与 C 语言的字符串相比,SDS 提供了更高效的内存管理和更丰富的操作功能。
SDS 的优势
- 动态分配内存:SDS 可以根据需要动态扩展或收缩字符串的内存,避免了手动管理内存的麻烦。
- 常数时间获取长度:SDS 在结构体中保存了字符串的长度,可以在常数时间内获取字符串的长度,而不需要遍历字符串。
- 二进制安全:SDS 可以包含任意二进制数据,包括嵌入的空字符(
\0
),这使得 SDS 可以处理不仅仅是文本字符串的数据。 - 防止缓冲区溢出:SDS 的 API 会自动处理内存分配和扩展,防止缓冲区溢出的问题。
- 减少内存碎片:SDS 会预留一些未使用的空间,以减少频繁的内存分配和释放,从而减少内存碎片。
编码方式 | 适用场景 | 优点 | 缺点 |
---|---|---|---|
int | 可以表示为 64 位有符号整数的字符串 | 内存高效,性能高效 | 仅适用于整数 |
embstr | 较短的字符串(<= 44 字节) | 内存高效,性能高效 | 仅适用于短字符串 |
raw | 较长的字符串(> 44 字节) | 灵活性高 | 内存开销大,可能导致内存碎片 |
embstr 和 raw 编码区别(最主要的就是embstr创建字符串redisObject对象的时候直接分配字符串内存空间了)
embstr 编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次。
释放 embstr 编码的字符串对象只需要调用一次内存释放函数, 而释放 raw 编码的字符串对象需要调用两次内存释放函数。
因为 embstr 编码的字符串对象的所有数据都保存在一块连续的内存里面, 所以这种编码的字符串对象比起 raw 编码的字符串对象能够更好地利用缓存带来的优势。
intset
intset
在Redis中主要用来优化集合类型(Set)的小整数集合,当集合中的所有元素都是整数且数量较少时,Redis会使用intset
来存储这个集合。
intset
是一个紧凑的整数集合,使用数组来存储整数,并且保证存储的整数是唯一的。
intset
的特点
- 紧凑存储:
intset
使用一个连续的内存区域来存储整数,这样可以节省内存。 - 自动升级:当需要插入的整数超出了当前的编码范围时,
intset
会自动升级其编码方式。例如,当插入一个int64_t
类型的整数到一个int16_t
编码的intset
中时,intset
会将所有现有的整数转换为int64_t
类型。 - 有序存储:
intset
中的整数按照升序排列,这样可以加速查找操作(二分查找)。
Dict(HT)
在Redis中,dict
(字典)是一种核心数据结构,广泛用于实现各种数据类型和内部存储。Redis的字典实现基于哈希表,具有高效的插入、删除和查找操作。
dict
数据结构由以下几个主要部分组成:
- dictEntry:表示字典中的一个键值对。
- dictht:表示哈希表,包含了指向哈希表数组的指针及其大小和已使用的节点数量。
- dict:表示字典,包含两个哈希表,一个用于正常操作,另一个用于在rehash过程中使用。
在Redis的dict
数据结构中,掩码(mask)用于快速计算键在哈希表中的索引位置。具体地说,掩码是通过哈希表的大小减去1得到的。这个设计的核心思想是利用位运算来快速计算索引。
掩码计算的原因
假设哈希表的大小为size
,且size
是2的幂(例如,16, 32, 64等)。掩码计算为size - 1
,这是因为2的幂减去1的结果在二进制表示中所有位都是1。例如:
- 如果
size
是16(即2^4
),那么size - 1
是15,二进制表示为0000 1111
。 - 如果
size
是32(即2^5
),那么size - 1
是31,二进制表示为0001 1111
。
位运算的优势
使用size - 1
作为掩码并进行位运算有几个重要的优势:
- 快速计算:位运算(按位与)比取模运算更快。计算索引时,将哈希值与掩码进行按位与操作可以快速得到索引位置。
- 哈希均匀性:当哈希表的大小是2的幂时,掩码可以确保哈希值的低位有效位参与索引计算,这有助于分散哈希值,避免哈希冲突。
Rehash 过程
为了动态调整哈希表的大小,Redis使用了rehash机制。rehash是逐步进行的,以避免对性能的巨大影响。具体过程如下:
- 创建新哈希表:创建一个新的哈希表,大小通常是原来哈希表的两倍或更小。
- 渐进式rehash:将旧哈希表中的键值对逐步迁移到新哈希表中,每次rehash操作只迁移一部分键值对。这通常在增删改查操作时顺带进行。
- 切换哈希表:当旧哈希表中的所有键值对都迁移到新哈希表后,释放旧哈希表。
ziplist Redis 7.0被listpack
取代
ziplist
(压缩列表)是Redis中一种紧凑的数据结构,用于存储小量的线性数据。它是一种以内存效率为核心设计的连续内存块,用于优化存储空间。ziplist
主要用于实现Redis中的hash
、list
和sorted set
等数据类型的底层存储结构。
优点
- 节省内存:由于使用紧凑的格式存储数据,
ziplist
非常节省内存。 - 适用于小数据量:对于少量元素,
ziplist
提供了高效的存储和访问性能。
缺点
- 适用范围有限:
ziplist
适合存储小量数据,数据量增大或元素变长时,访问和修改性能会显著下降。 - 操作复杂:插入、删除操作可能涉及大量的内存拷贝,效率较低。
大端序和小端序
在大端序中,数据的高位字节存储在内存的低地址处,低位字节存储在内存的高地址处。这种方式更符合人类阅读数字的习惯。
例如,对于一个16位(2字节)的整数0x1234
,内存存储顺序如下:
内存地址: 0x00 0x01
存储数据: 0x12 0x34
在小端序中,数据的低位字节存储在内存的低地址处,高位字节存储在内存的高地址处。
例如,对于同样的16位(2字节)的整数0x1234
,内存存储顺序如下:
内存地址: 0x00 0x01
存储数据: 0x34 0x12
理解高位和低位对理解大小端至关重要:高位字节(Most Significant Byte, MSB):在一个多字节数据中,表示数值最大的字节。例如,在0x1234
中,0x12
是高位字节。
- 大端序:大端序主要用于网络协议(也称为网络字节序),因为它更直观。
- 大端序被称为网络字节序(Network Byte Order)。许多网络协议(如TCP/IP)规定数据在网络上传输时采用大端序。这是因为大端序的高位字节在前,更直观,符合人类阅读数字的习惯。IP地址和端口号在网络报文中通常使用大端序表示。比如,一个IP地址
192.168.1.1
在网络上传输时是按照0xC0 0xA8 0x01 0x01
的顺序传输。 - 小端序:小端序在许多处理器(如x86架构)中被广泛使用,因为它在某些操作上更高效。
- 在小端序中,最低有效字节在前,这使得在读取多字节数据时,可以直接读取低位字节而无需知道数据的总长度。例如,在处理可变长度的数据结构或协议时,小端序可以更高效地访问低位数据。Intel和AMD的x86及x86-64处理器使用小端序,适用于大多数桌面和服务器环境。
在网络通信中,数据传输使用大端序,但在主机内部处理数据时,可能使用小端序。为了兼容,这些系统需要在发送和接收数据时进行字节序转换。
listpack
listpack
是 Redis 7.0 引入的一种新的压缩列表结构,它设计更加简洁高效,用于替代 ziplist
。listpack
主要用于实现 Redis 的 hash
、list
和 sorted set
等数据类型的底层存储。
主要结构:| total_bytes | num_elements | entry1 | entry2 | entry3 | end |
- total_bytes:存储整个
listpack
的总字节数。 - num_elements:存储
listpack
中元素的数量。当Entry个数大于等于65535时,Num Elem被设置为65535,此时如果需要获取元素个数,需要遍历整个listpack。 - entry:每个元素的结构,由长度前缀和实际数据组成。
- end:特殊值
0xFF
,标识listpack
的结束。
在 listpack 中,因为每个列表项只记录自己的长度,而不会像 ziplist 中的列表项那样,会记录前一项的长度。所以,当在 listpack 中新增或修改元素时,实际上只会涉及每个列表项自己的操作,而不会影响后续列表项的长度变化,这就避免了连锁更新。
深入分析redis之listpack,取代ziplist?-CSDN博客
Quicklist
quicklist
是 Redis 为了解决 ziplist
和 linkedlist
各自的缺点而引入的一种新的数据结构。它结合了 ziplist
的内存效率和 linkedlist
的操作效率,广泛用于 Redis 的列表(list)类型中。
quicklist
是一个双向链表,每个链表节点保存一个 ziplist
。这种结构将 ziplist
的内存紧凑性和 linkedlist
的快速插入、删除操作结合起来。
- 内存效率高:每个节点使用
ziplist
存储数据,减少内存碎片,提高存储效率。 - 操作效率高:双向链表结构使得在列表的头部和尾部进行插入、删除操作非常高效。
- 灵活性好:可以根据实际需求动态调整
ziplist
的大小,以平衡内存使用和操作性能。
skiplist跳表
跳表(Skip List)是一种用于快速查找的数据结构。它通过在链表的基础上增加多级索引,使得元素查找、插入和删除操作的时间复杂度达到O(logn)。跳表在Redis中主要用于实现有序集合(sorted set)。
跳表的操作
查找操作
从最高层开始,沿着指针查找,若当前层找不到目标值,则下降到下一层继续查找,直到找到目标值或到达最低层。
插入操作
- 查找插入位置:从最高层开始查找要插入的位置。
- 插入节点:在找到的位置插入新节点,并随机决定新节点的层级。
- 更新指针:调整相应层级的指针指向新节点。
删除操作
- 查找删除位置:从最高层开始查找要删除的节点。
- 删除节点:在找到的位置删除节点,调整相应层级的指针。
跳表的时间复杂度
- 查找:平均时间复杂度为 O(logn)O(\log n)O(logn)。
- 插入:平均时间复杂度为 O(logn)O(\log n)O(logn)。
- 删除:平均时间复杂度为 O(logn)O(\log n)O(logn)。
一文彻底搞懂跳表的各种时间复杂度、适用场景以及实现原理_跳表时间复杂度-CSDN博客
为什么Redis选择使用跳表而不是红黑树来实现有序集合?
Redis 中的有序集合(zset) 支持的操作:插入一个元素
删除一个元素
查找一个元素
有序输出所有元素
按照范围区间查找元素(比如查找值在 [100, 356] 之间的数据)
其中,前四个操作红黑树也可以完成,且时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。按照区间查找数据时,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了,非常高效。
原文链接:https://blog.csdn.net/qq_34412579/article/details/101731935
看到这里不得不想到一个八股,MySQL的InnoDB引擎选择使用B+树作为其主要的索引结构,而不是跳表
为什么Innodb选择B+ tree而不是跳表,红黑树
- B+ tree是多叉树结构,每个结点都是一个16k的数据页,能存放较多的索引信息,所以扇出很高。三层左右就可以存储2kw左右的数据。也就是说查询一次数据,如果这些数据页都在磁盘里,那么最多需要查询三次磁盘IO。
- 跳表是链表结构,一个结点存放一条数据,如果底层需要存储2kw数据,且每次查询都能达到二分效果,2kw大概需要2的24次方左右,也就是说跳表高度大概在24层左右。最坏情况下,这24层数据会分散在不同的数据页里,也就是说查询一次数据需要24次磁盘IO。
- 红黑树是一种二叉树,每个节点只能存储一个关键字,树的高度较高,访问的磁盘 I/O 次数更多,不适合处理大量数据的磁盘存取。红黑树虽然也支持顺序遍历,但每次范围查询需要递归遍历左右子树,性能不如 B+ 树直接遍历叶子节点的链表高效。红黑树的插入和删除操作虽然也是 O(log n),但会频繁旋转节点,涉及大量指针调整,无法有效利用磁盘的顺序读写特性。红黑树由于没有顺序链表的支持,无法高效地完成这些批量操作。红黑树的每个节点不仅存储键,还存储实际数据,会增加树的大小和复杂性。
因此,存放同样量级的数据,B+ tree的高度会比跳表的要少,对于数据库系统而言,意味着一次查询需要的磁盘IO次数更少,因此查询效率更高。
对于写操作而言,B+树需要拆分合并数据页,跳表则是独立插入,并且根据随机函数确定层数,没有旋转和维持平衡带来的开销,因此跳表的写入性能会比B+ tree树要好。
为什么Redis有序集合底层选择跳表而非B+ tree
redis是基于内存的数据库,因此不需要考虑磁盘IO,所以索引层数在redis看了就不再是跳表的劣势了
- B+树在数据写入时,存在拆分和合并数据页的开销,目的是为了保持树的平衡。
- 跳表在数据写入时,只需要通过随机函数生成当前节点的层数即可,然后更新每一层索引,往其中加入一个节点,相比于B+ tree而言,少了旋转平衡带来的开销。
因此,redis最终选择的是跳表,而不是B+ tree。
由于跳表的查询复杂度在O(logn),因此redis中zset数据类型底层结合使用skiplist和hash,用空间换时间,利用跳表支持范围查询和有序查询,利用hash支持精确查询。
在Redis中,有序集合的底层实现如下:
- 跳表:存储元素和分值,支持有序操作和范围查询。
- 哈希表:以元素为键,分值为值,支持快速精确查找。
当执行插入操作时,元素会被同时插入到跳表和哈希表中。当执行删除操作时,元素会从跳表和哈希表中同时删除。当执行查找操作时,会根据操作类型选择使用跳表或哈希表。
思考思路:实现复杂度,内存效率,范围查找效率,维护成本,实际应用的考虑
红黑树(Red-Black Tree)
优点
- 平衡性好:红黑树是一种自平衡二叉查找树,保证了树的高度近似平衡,最坏情况下的时间复杂度为 O(logn)O(\log n)O(logn)。
- 插入、删除、查找效率高:红黑树的插入、删除、查找操作均为 O(logn)O(\log n)O(logn) 时间复杂度。
- 内存使用:由于是二叉树,红黑树的每个节点只需要两个指针,内存开销较小。
缺点
- 实现复杂:红黑树的插入和删除操作需要进行复杂的旋转和颜色调整,代码实现较为复杂。
- 范围查询效率一般:红黑树进行范围查询时需要在树上进行多次遍历,效率不如B+树和跳表。
B+树
优点
- 适合磁盘存储:B+树的设计非常适合磁盘存储系统,每个节点包含多个键值对,可以减少磁盘I/O操作。
- 高效的范围查询:B+树的叶子节点通过链表连接,范围查询和顺序遍历效率高。
- 高扇出:由于每个节点可以存储多个元素,B+树的高度通常较低,进一步减少了查找路径。
- 增删改查O(logₙ N),其中 n 是树的阶(每个节点最多的子节点数),N 是树中的元素总数。
- B+树的高度大约为
O(logₙ N)
,其中 n 是 B+树的阶数(即每个节点的最大子节点数),N 是元素总数。
缺点
- 实现复杂:B+树的插入和删除操作需要进行节点分裂和合并,以及复杂的平衡调整,代码实现复杂。
- 内存消耗大:每个节点需要存储多个指针和键值对,内存开销较大。
跳表(Skip List)
优点
- 实现简单:跳表的代码实现相对简单,插入和删除操作只需调整少量指针,不需要复杂的平衡操作。
- 范围查询效率高:跳表通过多级索引快速定位起点,然后在底层链表中顺序遍历,范围查询效率高。
- 动态调整方便:插入和删除操作只涉及局部指针调整,维护成本低。
- 跳表的高度一般为
O(log N)
,其中 N 是元素的总数。
缺点
- 内存开销较大:跳表需要额外的索引层,每个节点存储多个指针,内存开销相对较大。
- 随机化性能不稳定:跳表的性能依赖于随机数的质量,虽然平均情况下性能良好,但最坏情况下性能可能较差。
总结表格
数据结构 | 优点 | 缺点 |
---|---|---|
红黑树 | 平衡性好,插入、删除、查找效率高,内存使用较少 | 实现复杂,范围查询效率一般 |
B+树 | 适合磁盘存储,高效的范围查询,高扇出 | 实现复杂,内存消耗大 |
跳表 | 实现简单,范围查询效率高,动态调整方便 | 内存开销较大,随机化性能不稳定 |
红黑树适合需要高效插入、删除和查找的内存数据结构;B+树适合需要高效范围查询和顺序访问的磁盘存储系统;跳表适合需要简单实现和高效范围查询的内存数据结构。
linux五种IO模型
内核空间 (Kernel Space)
- 访问权限:内核空间是由操作系统内核使用的内存区域,只有操作系统内核和具有特权的系统进程可以访问。普通用户程序无法直接访问内核空间。
- 功能:
- 管理硬件资源:内核负责管理计算机的硬件资源,包括CPU、内存、硬盘、网络设备等。
- 系统调用:用户空间的程序通过系统调用接口与内核通信,请求内核执行特权操作,如文件操作、网络通信、进程管理等。
- 内存管理:内核负责内存的分配和回收,以及内存保护,防止进程之间相互干扰。
- 进程调度:内核负责管理进程的生命周期,包括创建、调度、终止等。
- 安全性:内核空间具有更高的安全性,因为普通用户程序无法直接访问,从而防止恶意程序对系统核心部分进行破坏。
用户空间 (User Space)
- 访问权限:用户空间是由用户应用程序使用的内存区域,普通应用程序在这个空间内运行。用户空间的程序不能直接访问硬件资源,必须通过系统调用请求内核服务。
- 功能:
- 应用程序运行:用户空间是所有用户级应用程序运行的地方,如文本编辑器、浏览器、游戏等。
- 用户交互:大多数用户交互操作都发生在用户空间,比如读取用户输入、显示输出等。
- 灵活性:用户空间程序可以被轻松地加载、执行、终止和调试,而不需要影响整个系统的稳定性。
内核空间与用户空间的交互
- 系统调用:用户空间的应用程序通过系统调用接口与内核空间进行交互,请求内核执行某些操作。
- 上下文切换:当系统调用发生时,CPU会从用户模式切换到内核模式,执行内核代码,完成后再切换回用户模式。
- 保护机制:为了保护内核空间的安全性,CPU在用户模式下运行时,限制了对某些指令和内存地址的访问,只有在内核模式下才能执行这些操作。
在Linux操作系统中,IO模型用于处理输入/输出操作,尤其是网络编程中的数据传输。Linux提供了五种主要的IO模型:
1. 阻塞IO(Blocking IO)
描述:
- 阻塞IO是最简单、最常见的IO模型。当应用程序发起一个IO操作时,它会被阻塞,直到操作完成。
- 例如,在网络编程中,调用
recv
函数读取数据时,程序会阻塞,直到有数据可读。
优点:
- 编程简单直观。
- 无需额外的同步机制。
缺点:
- 效率低,因为进程会一直等待,无法处理其他任务。
2. 非阻塞IO(Non-blocking IO)
描述:
- 非阻塞IO模式下,应用程序发起一个IO操作后,如果操作无法立即完成(例如,没有数据可读),函数会立即返回一个错误而不是阻塞。
- 应用程序需要不断地轮询(polling)该IO操作,直到操作完成。
优点:
- 程序不会被阻塞,可以继续处理其他任务。
缺点:
- 轮询会导致CPU资源浪费,效率低。
3. IO多路复用(IO Multiplexing)
描述:
- IO多路复用使用
select
、poll
或epoll
系统调用来监视多个文件描述符,任何一个文件描述符就绪时,通知应用程序进行相应的IO操作。 - 这种模型中,程序会在
select
或poll
上阻塞,但可以同时等待多个IO操作。
优点:
- 可以处理多个IO操作,适用于需要同时处理多个连接的场景,如服务器端编程。
缺点:
- 在大量文件描述符时,
select
和poll
的效率较低,epoll
在这方面有较好的表现。
4. 信号驱动IO(Signal-driven IO)
描述:
- 信号驱动IO使用信号(signal)机制,当IO操作就绪时,内核会发送一个信号通知应用程序。
- 应用程序可以设置一个信号处理函数,在信号处理函数中执行IO操作。
优点:
- 可以在不阻塞程序的情况下完成IO操作。
缺点:
- 编程复杂,需要处理信号机制带来的异步问题。
5. 异步IO(Asynchronous IO)
描述:
- 异步IO模型下,应用程序发起IO操作后,立即返回,内核在操作完成后通知应用程序。
- 应用程序可以通过回调函数、信号或其他机制来处理完成的IO操作。
优点:
- 最高效的IO模型,完全非阻塞,适用于需要高并发和高性能的场景。
缺点:
- 编程复杂,涉及异步处理和回调机制。
Redis 的核心操作,如处理命令和访问数据结构,都是在单线程中完成的。这种设计简化了代码实现和维护,避免了多线程并发带来的复杂性(例如死锁和竞争条件),并且充分利用了现代CPU的缓存性能。
从 Redis 6.0 开始,为了提高网络I/O性能,尤其是在处理大批量客户端连接时,Redis 引入了多线程I/O处理。在这种模式下,Redis 仍然使用单线程处理命令,但可以使用多个线程并行处理网络请求的读写操作。
具体使用场景包括
- 读取客户端的命令请求:多个线程可以并发读取客户端发送的命令请求。
- 发送响应到客户端:多个线程可以并发处理将命令结果发送给客户端的操作。
多线程带来的好处
- 提高并发处理能力:多线程的I/O处理减少了网络瓶颈,尤其是在处理大量小请求或网络延迟高的场景中。
- 减轻主线程压力:将部分I/O操作分担到其他线程上,减轻了主线程的负担,能够在高并发场景下获得更好的性能表现。
多线程的局限性
- 复杂度增加:多线程引入了上下文切换、线程同步等复杂问题,因此只在特定情况下使用。
- 并非万能:对于一些纯CPU密集型的操作,单线程模型反而可能更高效。