Redis缓存图解记忆
Redis应用
- 记录帖子的点赞数、评论数和点击数 (hash)。
- 记录用户的帖子 ID 列表 (排序),便于快速显示用户的帖子列表 (zset)。
- 记录帖子的标题、摘要、作者和封面信息,用于列表页展示 (hash)。
- 记录帖子的点赞用户 ID 列表,评论 ID 列表,用于显示和去重计数 (zset)。
- 缓存近期热帖内容 (帖子内容空间占用比较大),减少数据库压力 (hash)。
- 记录帖子的相关文章 ID,根据内容推荐相关帖子 (list)。
- 如果帖子 ID 是整数自增的,可以使用 Redis 来分配帖子 ID(计数器)。
- 收藏集和帖子之间的关系 (zset)。
- 记录热榜帖子 ID 列表,总热榜和分类热榜 (zset)。
- 缓存用户行为历史,进行恶意行为过滤 (zset,hash)。
- 数据推送去重Bloom filter
- pv,uv统计
- 延时队列:使用sortedset,拿时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。
- 位图bitmap:bool 型数据需要存取,比如用户一年的签到记录,每天的签到记录只占据一个位,可以使用位图操作 getbit/setbit 等将 byte 数组看成「位数组」来处理。key 可以设置为 前缀:用户id:年月 譬如 setbit sign:123:1909 0 1
- HyperLogLog: 提供了两个指令 pfadd 和 pfcount,根据字面意义很好理解,一个是增加计数,一个是获取计数。pfadd 用法和 set 集合的 sadd 是一样的,来一个用户 ID,就将用户 ID 塞进去就是,pfcount 和 scard 用法是一样的,直接获取计数值。适合用于记录UV(签到),需要根据 id去重
- 使用keys指令可以扫出指定模式的key列表。如果一亿key中找出10w个已知前缀的数据。但redis是单线程,keys会导致线程阻塞,不适合线上使用,使用scan指令可以无阻塞的提取出指定模式的key列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys指令长。
- 异步队列:rpush生产消息,lpop消费消息。当lpop没有消息的时候,要适当sleep一会再重试。list还有个指令叫blpop,在没有消息的时候,消费者会一直阻塞pop消息住直到消息到来
- 消费多次:使用pub/sub主题订阅者模式,可以实现 1:N 的消息队列。但在消费者下线的情况下,生产的消息会丢失
- 秒杀库存扣减、首页的访问流量高峰
分布式锁
-
场景:
- 避免不同节点重复相同的工作:比如用户执行了某个操作有可能不同节点会发送多封邮件;
- 避免破坏数据的正确性:如果两个节点在同一条数据上同时进行操作,可能会造成数据错误或不一致的情况出现;
-
先拿setnx来争抢锁,抢到之后,再用expire给锁加一个过期时间防止锁忘记了释放。但最好还是用lua脚本实现原子性
-
占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑,先来先占, 用完了,再调用 del 指令释放坑。
-
setnx:
set key val ex 5 nx
。但是这样还是有问题超时问题,可重入问题,因此使用 lua脚本解决 -
lua脚本实现:
# delifequals 删除的时候,去校验是否当前线程锁定的 if redis.call("get",KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end
-
其他方式:
- 基于 MySQL 中的锁:MySQL 本身有自带的悲观锁
for update
关键字,也可以自己实现悲观/乐观锁来达到目的; - 基于 Zookeeper 有序节点:Zookeeper 允许临时创建有序的子节点,这样客户端获取节点列表时,就能够通过当前子节点列表中的序号判断是否能够获得锁;
- 基于 Redis 的单线程:由于 Redis 是单线程,所以命令会以串行的方式执行,并且本身提供了像
SETNX(set if not exists)
这样的指令,本身具有互斥性;
- 基于 MySQL 中的锁:MySQL 本身有自带的悲观锁
-
Redis分布式锁实现
- 分布式锁类似于 "占坑",而
SETNX(SET if Not eXists)
指令就是这样的一个操作,只允许被一个客户端占有。- 但由于
SETNX
和EXPIRE
并不是 原子指令,所以在一起执行会出现问题。不能使用事务。因为EXPIRE
命令依赖于SETNX
的执行结果,而事务中没有if-else
的分支逻辑,如果SETNX
没有抢到锁,EXPIRE
就不应该执行。 - 可以使用lua脚本实现 setnx 和 expire 操作的原子性
- 2.8版本之后:
SET key val ex 5 nx
- 另外,官方文档也在
SETNX
文档中提到了这样一种思路:把 SETNX 对应 key 的 value 设置为 <current Unix time + lock timeout + 1>,这样在其他客户端访问时就能够自己判断是否能够获取下一个 value 为上述格式的锁了。
- 但由于
- 分布式锁类似于 "占坑",而
-
Redisson:实现可重入。主要是判断锁是否存在,存在就设置过期时间,如果锁已经存在了,那对比一下线程,线程是一个那就证明可以重入,锁在了,但是不是当前线程,证明别人还没释放,那就把剩余时间返回,加锁失败。
-
Redis分布式锁问题
- 锁超时:假设现在我们有两台平行的服务 A B,其中 A 服务在 获取锁之后 由于未知神秘力量突然 挂了,那么 B 服务就永远无法获取到锁,所以我们需要额外设置一个超时时间,来保证服务的可用性。
- 但是另一个问题随即而来:如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,也会出现问题。因为这时候第一个线程持有锁过期了,而临界区的逻辑还没有执行完,与此同时第二个线程就提前拥有了这把锁,导致临界区的代码不能得到严格的串行执行。所以Redis 分布式锁不要用于较长时间的任务
- 解决方案: 将锁的
value
值设置为一个随机数,且持有锁的线程中有这个随机数,释放锁时先匹配随机数是否一致,然后再删除 key,这是为了 确保当前线程占有的锁不会被其他线程释放,除非这个锁是因为过期了而被服务器自动释放的。但是匹配value
和删除key
在 Redis 中并不是一个原子性的操作,所以需要Lua 脚本 保证多个指令的原子性执行。
- 解决方案: 将锁的
- 但是另一个问题随即而来:如果在加锁和释放锁之间的逻辑执行得太长,以至于超出了锁的超时限制,也会出现问题。因为这时候第一个线程持有锁过期了,而临界区的逻辑还没有执行完,与此同时第二个线程就提前拥有了这把锁,导致临界区的代码不能得到严格的串行执行。所以Redis 分布式锁不要用于较长时间的任务
- 单点/多点问题:服务 A 申请到一把锁之后,如果作为主机的 Redis 宕机了,那么 服务 B 在申请锁的时候就会从从机那里获取到这把锁,为了解决这个问题,Redis 作者提出了一种 RedLock 红锁 的算法
- GC引发的安全问题:
- 在 GC 的时候会发生 STW(Stop-The-World),这本身是为了保障垃圾回收器的正常执行,但可能会引发问题:
- 服务 A 获取了锁并设置了超时时间,但是服务 A 出现了 STW 且时间较长,导致了分布式锁进行了超时释放,在这个期间服务 B 获取到了锁,待服务 A STW 结束之后又恢复了锁,这就导致了 服务 A 和服务 B 同时获取到了锁,这个时候分布式锁就不安全了。
- 在 GC 的时候会发生 STW(Stop-The-World),这本身是为了保障垃圾回收器的正常执行,但可能会引发问题:
- 锁超时:假设现在我们有两台平行的服务 A B,其中 A 服务在 获取锁之后 由于未知神秘力量突然 挂了,那么 B 服务就永远无法获取到锁,所以我们需要额外设置一个超时时间,来保证服务的可用性。
布隆过滤器
-
是一个很长的二进制向量和一系列随机映射函数
-
布隆过滤器 本质上 是由长度为
m
的位向量或位列表(仅包含0
或1
位值的列表)组成,最初所有的值均设置为0
-
当我们向布隆过滤器中添加数据时,会使用 多个
hash
函数对key
进行运算,算得一个证书索引值,然后对位数组长度进行取模运算得到一个位置,每个hash
函数都会算得一个不同的位置。再把位数组的这几个位置都置为1
就完成了add
操作 -
向布隆过滤器查查询
key
是否存在时,跟add
操作一样,会把这个key
通过相同的多个hash
函数进行运算,查看 对应的位置 是否 都 为1
,只要有一个位为0
,那么说明布隆过滤器中这个key
不存在 -
当布隆过滤器说某个值存在时,这个值 可能不存在;当它说不存在时,那么 一定不存在。
-
布隆过滤器的initial_size估计的过大,会浪费存储空间,估计的过小,就会影响准确率,用户在使用之前一定要尽可能地精确估计好元素数量,还需要加上一定的冗余空间以避免实际元素可能会意外高出估计值很多。
bf.madd codehole user4 user5 user6 bf.mexists codehole user4 user5 user6 user7
-
应用场景:
- 使用新闻客户端看新闻时,它会给我们不停地推荐新的内容,它每次推荐时要去重,去掉那些已经看过的内容
- 布隆过滤器可以显著降低数据库的 IO 请求数量。当用户来查询某个 row 时,可以先通过内存中的布隆过滤器过滤掉大量不存在的 row 请求,然后再去磁盘进行查询。
- 邮箱系统的垃圾邮件过滤功能也普遍用到了布隆过滤器,因为用了这个过滤器,所以平时也会遇到某些正常的邮件被放进了垃圾邮件目录中,这个就是误判所致,概率很低。
- 限流,附近的人GeoHash 等等
坑
-
sync的时候遇到了bgsave 这个时候cpu飙升 ,因为bgsave之后会做一个emptyDB,这个时候做bgsave ,cow(写时复制)的机制就没了,会重新加载整个RDB,然后就swap(在系统的物理内存不够用的时候,把硬盘内存中的一部分空间释放出来,以供当前运行的程序使用)
-
当写的tps(系统吞吐量)较高,slave在加载rdb文件,slave会被阻塞,无法执行master同步过来的命令,复制积压缓冲区数据被冲掉。
-
有个参数client-output-buffer-limit slave 256mb 64mb 60(大于256mb断开,或者连续60秒超过64mb断开) 当加载完数据之后,slave向master发送 sync 命令,由于复制积压数据被冲掉了,slave会再次请求同步数据。如此反复,导致无限同步。
- SAVE 直接调用 rdbSave ,阻塞 Redis 主进程,直到保存完成为止。在主进程阻塞期间,服务器不能处理客户端的任何请求。
- BGSAVE 则 fork 出一个子进程,子进程负责调用 rdbSave ,并在保存完成之后向主进程发送信号,通知保存已完成。 Redis 服务器在BGSAVE 执行期间仍然可以继续处理客户端的请求。
- Pipeline:可以将多次IO往返的时间缩减为一次,前提是pipeline执行的指令之间没有因果相关性。使用redis-benchmark进行压测的时候可以发现影响redis的QPS峰值的一个重要因素是pipeline批次指令的数目。
数据类型
String、Hash、List、Set、SortedSet。还有密集型存储结构HyperLogLog、GeoHash、Pub/Sub。
用于防止缓存穿透:Redis Module:BloomFilter,RedisSearch,Redis-ML
-
SDS:String类型的底层数据类型
struct sdshdr{ int len; // 因此获取value长度的时间复杂度为0(1) int free; // 未使用的长度 char buf[]; }
- Redis是C语言开发的,C语言自己就有字符类型,但是Redis却没直接采用C语言的字符串类型,而是自己构建了
动态字符串(SDS)
的抽象类型。SDS在Redis中除了用作字符串,还用作缓冲区(buffer) set key val
:Redis创建了两个SDS,一个是名为aobing
的Key SDS,另一个是名为cool
的Value SDS,就算是字符类型的List,也是由很多的SDS构成的Key和Value罢了。- 与C语言的不同:
- 1、c语言获取长度需要遍历整个字符串,而SDS直接获取len属性;
- 2、拼接字符串后c语言没有提前计算拼接后的长度容易造成缓冲区溢出,污染数据,而SDS有free字段获取未使用的长度;当 SDS 需要对字符串进行修改时,首先借助于
len
和alloc
检查空间是否满足修改所需的要求,如果空间不够的话,SDS 会自动扩展空间,避免了像 C 字符串操作中的覆盖情况; - 3、二进制安全:Redis就不存在这个问题了,他不是保存了字符串的长度嘛,他不判断空字符,他就判断长度,经常用来保存小文件的二进制数据。C 语言字符串只能保存
ascii
码,对于图片、音频等信息无法保存,SDS 是二进制安全的,写入什么读取就是什么,不做任何过滤和限制; - 4、减少修改字符串时的内存重分配次数
- redis为了减少修改字符串时带来的内存重分配次数的性能开销,采用了:
- 空间预分配:当我们对SDS进行扩展操作的时候,Redis会为SDS分配好内存,并且根据特定的公式,分配多余的free空间,还有多余的1byte空间(这1byte也是为了存空字符),这样就可以避免我们连续执行字符串添加所带来的内存分配消耗。
- 惰性空间释放:当我们执行完一个字符串缩减的操作,redis并不会马上收回我们的空间,因为可以预防你继续添加的操作,这样可以减少分配空间带来的消耗,但是当你再次操作还是没用到多余空间的时候,Redis也还是会收回对于的空间,防止内存的浪费
-
String:key-value
- Redis 中的字符串是一种 动态字符串,这意味着使用者可以修改,它的底层实现有点类似于 Java 中的 ArrayList。底层由SDS实现
- 计数器:许多系统都会使用Redis作为系统的实时计数器,可以快速实现计数和查询的功能。而且最终的数据结果可以按照特定的时间落地到数据库或者其它存储介质当中进行永久保存。使用INCR进行原子性自增操作
- 共享用户Session:用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存Cookie,但是可以利用Redis将用户的Session集中管理,在这种模式只需要保证Redis的高可用,每次用户Session的更新和获取都可以快速完成。大大提高效率。
-
Hash:存储对象
-
这个是类似 Map 的一种结构,这个一般就是可以将结构化的数据,比如一个对象(前提是这个对象没嵌套其他的对象)给缓存在 Redis 里,然后每次读写缓存的时候,可以就操作 Hash 里的某个字段。
-
Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 "数组 + 链表" 的链地址法来解决部分 哈希冲突
-
实际上字典结构的内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式搬迁
- 渐进式rehash:渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。当搬迁完成了,就会使用新的 hash 结构取而代之。
-
扩缩容:正常情况下,当 hash 表中 元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是 原数组大小的 2 倍。不过如果 Redis 正在做
bgsave(持久化命令)
,为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会 强制扩容。当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是 元素个数低于数组长度的 10%,缩容不会考虑 Redis 是否在做
bgsave
。 -
命令:HSET\HGETALL\HGET\HMSET
-
-
List:先进先出。Redis 的列表相当于 Java 语言中的 LinkedList,双向链表
-
可以通过 List 存储一些列表型的数据结构,类似粉丝列表、文章的评论列表、简单的消息队列、热点数据之类的东西。
-
比如可以通过 lrange 命令,读取某个闭区间内的元素,可以基于 List 实现分页查询,这个是很棒的一个功能,基于 Redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西,性能高,就一页一页走。
-
消息队列:Redis的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成队列的设计。比如:数据的生产者可以通过Lpush命令从左边插入数据,多个数据消费者,可以使用BRpop命令阻塞的“抢”列表尾部的数据。
-
文章列表或者数据分页展示的应用。
比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用Redis的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功能。大大提高查询效率。
-
基本操作
LPUSH
和RPUSH
分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素;LRANGE
命令可以从 list 中取出一定范围的元素;LINDEX
命令可以从 list 中取出指定下表的元素,相当于 Java 链表操作中的get(int index)
操作;- LPOP/RPOP/
-
-
Set:无序集合,会自动去重
- 可以基于 Set 玩儿交集、并集、差集的操作,比如交集吧,我们可以把两个人的好友列表整一个交集,看看俩人的共同好友
- Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。
- 命令:SADD/SMEMBERS/SISMEMBER/SCARD/SPOP
-
Sorted Set:有序、去重
-
它类似于 Java 中 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。
-
它的内部实现用的是一种叫做 「跳跃表」 的数据结构
-
写进去的时候给一个分数,自动根据分数排序。而且是插入时就排序好
-
排行榜:有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护可能是多方面:按照时间、按照播放量、按照获得的赞数等。
-
用Sorted Sets来做带权重的队列,比如普通消息的score为1,重要消息的score为2,然后工作线程可以选择按score的倒序来获取工作任务。让重要的任务优先执行。
-
命令:
- zadd \zrange\zrevrange\zcard\zscore\zbank\ZRANGEBYSCORE\ZREM
-
skipList跳跃表:
- 因为 zset 要支持随机的插入和删除,所以它 不宜使用数组来实现。
- 在高并发的情况下,树形结构需要执行一些类似于 rebalance 这样的可能涉及整棵树的操作,相对来说跳跃表的变化只涉及局部
- 在复杂度与红黑树相同的情况下,跳跃表实现起来更简单,看起来也更加直观;
- skiplist 它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是 为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是 3,那么就把它链入到第 1 层到第 3 层这三层链表中。插入操作只需要修改节点前后的指针,而不需要对多个节点都进行调整,这就降低了插入操作的复杂度。
-
-
GeoHash 查找附近的人
-
地球上的任何一个位置都可以使用二维的 经纬度 来表示,当我们使用数据库存储了所有人的 经纬度 信息之后,我们就可以基于当前的坐标节点,来划分出一个矩形的范围,来得知附近的人
-
SQL:
SELECTidFROM positions WHERE x0 - r < x < x0 + r AND y0 - r < y < y0 + r
: -
GeoHash 算法将 二维的经纬度 数据映射到 一维 的整数,这样所有的元素都将在挂载到一条线上,距离靠近的二维坐标映射到一维后的点之间距离也会很接近。当我们想要计算 「附近的人时」,首先将目标位置映射到这条线上,然后在这个一维的线上获取附近的点就行了。
它的核心思想就是把整个地球看成是一个 二维的平面,然后把这个平面不断地等分成一个一个小的方格,每一个 坐标元素都位于其中的 唯一一个方格 中,等分之后的 方格越小,那么坐标也就 越精确
-
Redis 中使用 Geo:
- 在使用 Redis 进行 Geo 查询 时,我们要时刻想到它的内部结构实际上只是一个 zset(skiplist)。通过 zset 的
score
排序就可以得到坐标附近的其他元素,通过将score
还原成坐标值就可以得到元素的原始坐标了。 - 增加:
geoadd
指令携带集合名称以及多个经纬度名称三元组,注意这里可以加入多个三元组。 - 删除:通过 zset 相关的指令来操作 Geo 数据,所以元素删除可以使用
zrem
指令即可。 - 距离:
geodist
指令可以用来计算两个元素之间的距离,携带集合名称、2 个名称和距离单位。 - 获取位置:
geopos
指令可以获取集合中任意元素的经纬度坐标,可以一次获取多个。 - 获取hash:
geohash
可以获取元素的经纬度编码字符串,上面已经提到,它是base32
编码。你可以使用这个编码值去http://geohash.org/${hash}
中进行直接定位,它是 Geohash 的标准编码值。 - 附近的元素:
georadiusbymember
指令是最为关键的指令,它可以用来查询指定元素附近的其它元素,它的参数非常复杂。 - Redis 还提供了根据坐标值来查询附近的元素,这个指令更加有用,它可以根据用户的定位来计算「附近的车」,「附近的餐馆」等。它的参数和
georadiusbymember
基本一致,除了将目标元素改成经纬度坐标值:
- 在使用 Redis 进行 Geo 查询 时,我们要时刻想到它的内部结构实际上只是一个 zset(skiplist)。通过 zset 的
-
在一个地图应用中,车的数据、餐馆的数据、人的数据可能会有百万千万条,如果使用 Redis 的 Geo 数据结构,它们将 全部放在一个 zset 集合中。在 Redis 的集群环境中,集合可能会从一个节点迁移到另一个节点,如果单个 key 的数据过大,会对集群的迁移工作造成较大的影响,在集群环境中单个 key 对应的数据量不宜超过 1M,否则会导致集群迁移出现卡顿现象,影响线上服务的正常运行。
所以,这里建议 Geo 的数据使用 单独的 Redis 实例部署,不使用集群环境。
如果数据量过亿甚至更大,就需要对 Geo 数据进行拆分,按国家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按区拆分。这样就可以显著降低单个 zset 集合的大小。
-
-
ziplist压缩列表:
-
这是 Redis 为了节约内存 而使用的一种数据结构,zset 和 hash 容器对象会在元素个数较少的时候,采用压缩列表(ziplist)进行存储。压缩列表是 一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙。
-
当一个列表只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表的底层实现。
当一个哈希只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希的底层实现。
-
压缩列表是Redis为了节约内存而开发的, 被用作list和hash的底层实现之一。是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结枃。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值
-
添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高。
-
-
-
快速列表:
-
Redis 早期版本存储 list 列表数据结构使用的是压缩列表 ziplist 和普通的双向链表 linkedlist,也就是说当元素少时使用 ziplist,当元素多时用 linkedlist。但考虑到链表的附加空间相对较高,
prev
和next
指针就要占去16
个字节(64 位操作系统占用8
个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。后来 Redis 新版本(3.2)对列表数据结构进行了改造,使用
quicklist
代替了ziplist
和linkedlist
。 -
quickList是一个ziplist组成的双向链表。每个节点使用ziplist来保存数据。本质上来说,quicklist里面保存着一个一个小的ziplist。
-
quickList就是一个标准的双向链表的配置,有head 有tail;
每一个节点是一个quicklistNode,包含prev和next指针。
每一个quicklistNode 包含 一个ziplist,*zp 压缩链表里存储键值。
所以quicklist是对ziplist进行一次封装,使用小块的ziplist来既保证了少使用内存,也保证了性能。
-
-
Stream结构
- 是一个 仅追加内容 的 消息链表,把所有加入的消息都一个一个串起来,每个消息都有一个唯一的 ID 和内容,这很简单,让它复杂的是从 Kafka 借鉴的另一种概念:消费者组(Consumer Group) (思路一致,实现不同):每个 Stream 都有唯一的名称,它就是 Redis 的
key
,在我们首次使用xadd
指令追加消息时自动创建
- 是一个 仅追加内容 的 消息链表,把所有加入的消息都一个一个串起来,每个消息都有一个唯一的 ID 和内容,这很简单,让它复杂的是从 Kafka 借鉴的另一种概念:消费者组(Consumer Group) (思路一致,实现不同):每个 Stream 都有唯一的名称,它就是 Redis 的
-
- Consumer Group:消费者组,可以简单看成记录流状态的一种数据结构。消费者既可以选择使用
XREAD
命令进行 独立消费,也可以多个消费者同时加入一个消费者组进行 组内消费。同一个消费者组内的消费者共享所有的 Stream 信息,同一条消息只会有一个消费者消费到,这样就可以应用在分布式的应用场景中来保证消息的唯一性。
- Consumer Group:消费者组,可以简单看成记录流状态的一种数据结构。消费者既可以选择使用
-
last_delivered_id:用来表示消费者组消费在 Stream 上 消费位置 的游标信息。每个消费者组都有一个 Stream 内 唯一的名称,消费者组不会自动创建,需要使用
XGROUP CREATE
指令来显式创建,并且需要指定从哪一个消息 ID 开始消费,用来初始化last_delivered_id
这个变量。 -
pending_ids:每个消费者内部都有的一个状态变量,用来表示 已经 被客户端 获取,但是 还没有 ack 的消息。记录的目的是为了 保证客户端至少消费了消息一次,而不会在网络传输的中途丢失而没有对消息进行处理。如果客户端没有 ack,那么这个变量里面的消息 ID 就会越来越多,一旦某个消息被 ack,它就会对应开始减少。这个变量也被 Redis 官方称为 PEL (Pending Entries List)。
-
基数统计:
-
基数统计(Cardinality Counting) 通常是用来统计一个集合中不重复的元素个数。
-
给定一系列的随机整数,记录下低位连续零位的最大长度 K,即为图中的
maxbit
,通过这个 K 值我们就可以估算出随机数的数量 N。 -
场景:如果你负责开发维护一个大型的网站,有一天老板找产品经理要网站上每个网页的 UV(独立访客,每个用户每天只记录一次),然后让你来开发这个统计模块,你会如何实现?
-
如果统计 PV(浏览量,用户没点一次记录一次),那非常好办,给每个页面配置一个独立的 Redis 计数器就可以了,把这个计数器的 key 后缀加上当天的日期。这样每来一个请求,就执行
INCRBY
指令一次,最终就可以统计出所有的 PV 数据了。但是 UV 不同,它要去重,同一个用户一天之内的多次访问请求只能计数一次。这就要求了每一个网页请求都需要带上用户的 ID,无论是登录用户还是未登录的用户,都需要一个唯一 ID 来标识。
-
-
B-tree:B 树最大的优势就是插入和查找效率很高,如果用 B 树存储要统计的数据,可以快速判断新来的数据是否存在,并快速将元素插入 B 树。要计算基础值,只需要计算 B 树的节点个数就行了。不过将 B 树结构维护到内存中,能够解决统计和计算的问题,但是 并没有节省内存。
-
bitmap:bitmap 可以理解为通过一个 bit 数组来存储特定数据的一种数据结构,每一个 bit 位都能独立包含信息,bit 是数据的最小存储单位,因此能大量节省空间,也可以将整个 bit 数据一次性 load 到内存计算。如果定义一个很大的 bit 数组,基础统计中 每一个元素对应到 bit 数组中的一位。
- bitmap 还有一个明显的优势是 可以轻松合并多个统计结果,只需要对多个结果求异或就可以了,也可以大大减少存储内存。可以简单做一个计算。但对于大数据的场景仍然不适用。
-
HyperLogLog
-
-
HyperLogLog:
- m 表示分桶个数: 从图中可以看到,这里分成了 64 个桶;
- 蓝色的 bit 表示在桶中的位置: 例如图中的
101110
实则表示二进制的46
,所以该元素被统计在中间大表格Register Values
中标红的第 46 个桶之中; - 绿色的 bit 表示第一个 1 出现的位置:从图中可以看到标绿的 bit 中,从右往左数,第一位就是 1,所以在
Register Values
第 46 个桶中写入 1; - 红色 bit 表示绿色 bit 的值的累加: 下一个出现在第 46 个桶的元素值会被累加;
- HyperLogLog 实际占用的空间大约是
12 KB
,当 计数比较小 的时候,大多数桶的计数值都是 零,这个时候 Redis 就会适当节约空间,转换成另外一种 稀疏存储方式,与之相对的,正常的存储模式叫做 密集存储,这种方式会恒定地占用12 KB
。 - HyperLogLog 提供了两个指令
PFADD
和PFCOUNT
,字面意思就是一个是增加,另一个是获取计数。PFADD
和set
集合的SADD
的用法是一样的,来一个用户 ID,就将用户 ID 塞进去就是,PFCOUNT
和SCARD
的用法是一致的,直接获取计数值:
缓存问题
-
缓存雪崩:key大面积失效,解决方法:
- 设置key过期:
setRedis(key, value, time+Math.random()*1000);
- 设置热点数据永不过期
- 设置key过期:
-
缓存穿透
- 缓存和数据库中都没有的数据,而用户不断发起请求,我们数据库的 id 都是1开始自增上去的,如发起为id值为 -1 的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大,严重会击垮数据库。
- 解决:
- 在接口层增加校验,比如用户鉴权校验,参数做校验,不合法的参数直接代码Return,比如:id 做基础校验,id <=0的直接拦截等。
- 从缓存取不到的数据,在数据库中也没有取到,这时也可以将对应Key的Value对写为null、位置错误、稍后重试这样的值具体取啥问产品,或者看具体的场景,缓存有效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。
- 布隆过滤器:利用高效的数据结构和算法快速判断出你这个Key是否在数据库中存在,不存在你return就好了,存在你就去查了DB刷新KV再return。
-
缓存击穿
- 一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个完好无损的桶上凿开了一个洞。
- 解决:
- 设置热点数据永远不过期。或者加上互斥锁就能搞定了
- 设置热点数据永远不过期。或者加上互斥锁就能搞定了
持久化
-
客户端向数据库 发送写命令 (数据在客户端的内存中)
-
数据库 接收 到客户端的 写请求 (数据在服务器的内存中)
-
数据库 调用系统 API 将数据写入磁盘 (数据在内核缓冲区中)
-
操作系统将 写缓冲区 传输到 磁盘控控制器 (数据在磁盘缓存中)
-
操作系统的磁盘控制器将数据 写入实际的物理媒介 中 (数据在磁盘中)
其中数据安全最重要的阶段是3、4、5阶段
-
RDB做镜像全量持久化,AOF做增量持久化。因为RDB会耗费较长时间,不够实时,在停机的时候会导致大量丢失数据,所以需要AOF来配合使用。在redis实例重启时,会使用RDB持久化文件重新构建内存,再使用AOF重放近期的操作指令来实现完整恢复重启之前的状态。
这里很好理解,把RDB理解为一整个表全量的数据,AOF理解为每次操作的日志就好了,服务器重启的时候先把表的数据全部搞进去,但是他可能不完整,你再回放一下日志,数据不就完整了嘛。不过Redis本身的机制是 AOF持久化开启且存在AOF文件时,优先加载AOF文件;默认开启RDB关闭AOP。AOF关闭或者AOF文件不存在时,加载RDB文件;加载AOF/RDB文件城后,Redis启动成功;AOF/RDB文件存在错误时,Redis启动失败并打印错误信息
-
如果突然宕机?取决于AOF日志sync属性的配置,如果不要求性能,在每条写指令时都sync一下磁盘,就不会丢失数据。但是在高性能的要求下每次都sync是不现实的,一般都使用定时sync,比如1s1次,这个时候最多就会丢失1s的数据。
-
RDB原理:fork和cow。fork是指redis通过创建子进程来进行RDB操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。
-
RDB:
-
Redis 快照 是最简单的 Redis 持久性模式。当满足特定条件时,它将生成数据集的时间点快照,例如,如果先前的快照是在2分钟前创建的,并且现在已经至少有 100 次新写入,则将创建一个新的快照。此条件可以由用户配置 Redis 实例来控制,也可以在运行时修改而无需重新启动服务器。快照作为包含整个数据集的单个
.rdb
文件生成。 -
AOF:
- 每次执行 修改内存 中数据集的写操作时,都会 记录 该操作。假设 AOF 日志记录了自 Redis 实例创建以来 所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例 顺序执行所有的指令,也就是 「重放」,来恢复 Redis 当前实例的内存数据结构的状态。
- 当 Redis 收到客户端修改指令后,会先进行参数校验、逻辑处理,如果没问题,就 立即 将该指令文本 存储 到 AOF 日志中,也就是说,先执行指令再将日志存盘。
- AOF重写:Redis 提供了
bgrewriteaof
指令用于对 AOF 日志进行瘦身。其 原理 就是 开辟一个子进程 对内存进行 遍历 转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件 中。序列化完毕后再将操作期间发生的 增量 AOF 日志 追加到这个新的 AOF 日志文件中,追加完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。 - fsync刷盘:
- AOF 日志是以文件的形式存在的,当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了 内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的。
- 借助
glibc
提供的fsync(int fd)
函数来讲指定的文件内容 强制从内核缓存刷到磁盘。 - 但 "强制开车" 仍然是一个很消耗资源的一个过程,需要 "节制"!通常来说,生产环境的服务器,Redis 每隔 1s 左右执行一次
fsync
操作就可以了。 - Redis 同样也提供了另外两种策略,一个是 永不
fsync
,来让操作系统来决定合适同步磁盘,很不安全,另一个是 来一个指令就fsync
一次,非常慢。
- 4.0混合持久化:将
rdb
文件的内容和增量的 AOF 日志文件存在一起。这里的 AOF 日志不再是全量的日志,而是 自RDB持久化开始到持久化结束 的这段时间发生的增量 AOF 日志,通常这部分 AOF 日志很小
-
COW机制:操作系统多进程 COW(Copy On Write) 机制 。Redis 在持久化时会调用
glibc
的函数fork
产生一个子进程,简单理解也就是基于当前进程 复制 了一个进程,主进程和子进程会共享内存里面的代码块和数据段。所以 快照持久化 可以完全交给 子进程 来处理,父进程 则继续 处理客户端请求。子进程 做数据持久化,它 不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。但是 父进程 不一样,它必须持续服务客户端请求,然后对 内存数据结构进行不间断的修改。
这个时候就会使用操作系统的 COW 机制来进行 数据段页面 的分离。数据段是由很多操作系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复 制一份分离出来,然后 对这个复制的页面进行修改。这时 子进程 相应的页面是 没有变化的,还是进程产生时那一瞬间的数据。
子进程因为数据没有变化,它能看到的内存里的数据在进程产生的一瞬间就凝固了,再也不会改变,这也是为什么 Redis 的持久化 叫「快照」的原因。接下来子进程就可以非常安心的遍历数据了进行序列化写磁盘了。
-
哨兵+主从并不能保证数据不丢失,但是可以保证集群的高可用。
-
1、Redis Sentinal (哨兵模式)着眼于高可用,在master宕机时会自动将slave提升为master,继续提供服务。
- 哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据;
- 数据节点: 主节点和从节点都是数据节点;
- 哨兵组件功能:
- 集群监控:负责监控 Redis master 和 slave 进程是否正常工作。
- 消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
- 故障转移: 当 主节点 不能正常工作时,哨兵会开始 自动故障转移操作,它会将失效主节点的其中一个 从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
- 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。
- 选出哨兵:
- 故障转移操作的第一步 要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送
slaveof no one
命令,将这个从服务器转换为主服务器。 - 在失效主服务器属下的从服务器当中, 那些被标记为主观下线、已断线、或者最后一次回复 PING 命令的时间大于五秒钟的从服务器都会被 淘汰。
- 在失效主服务器属下的从服务器当中, 那些与失效主服务器连接断开的时长超过 down-after 选项指定的时长十倍的从服务器都会被 淘汰。
- 在 经历了以上两轮淘汰之后 剩下来的从服务器中, 我们选出 复制偏移量(replication offset)最大 的那个 从服务器 作为新的主服务器;如果复制偏移量不可用,或者从服务器的复制偏移量相同,那么 带有最小运行 ID 的那个从服务器成为新的主服务器。
- 故障转移操作的第一步 要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送
-
2、主从复制: 着眼于扩展性,在单个redis内存不足时,使用Cluster进行分片存储。
- Redis同步机制:Redis可以使用主从同步,从从同步。
- 第一次同步时,主节点做一次bgsave,并同时将后续修改操作记录到内存buffer,待完成后将RDB文件全量同步到复制节点,从节点接受完成后将RDB镜像加载到内存。
- 加载完成后,再通知主节点将bgsave期间修改的操作记录同步到复制节点进行重放就完成了同步过程。后续的增量数据通过AOF日志同步即可,有点类似数据库的binlog。
- 至少要三个实例:M1所在的机器挂了,哨兵还有两个,两个人一看他不是挂了嘛,那我们就选举一个出来执行故障转移不就好了。
- 主从数据同步:你启动一台slave 的时候,他会发送一个psync命令给master ,如果是这个slave第一次连接到master,他会触发一个全量复制。master就会启动一个线程,生成RDB快照,RDB快照的数据生成的时候,缓存区也必须同时开始接受新请求,还会把新的写请求都缓存在内存中,RDB文件生成后,master会将这个RDB发送给slave的,slave拿到之后做的第一件事情就是写进本地的磁盘,然后加载进内存,然后master会把内存里面缓存的那些新命名都发给slave。
- Redis同步机制:Redis可以使用主从同步,从从同步。
-
3、Redis Cluster 集群
- 原理:Redis 集群中内置了
16384
个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 集群的配置信息,当客户端具体对某一个key
值进行操作时,会计算出它的一个 Hash 值,然后把结果对16384
求余数,这样每个key
都会对应一个编号在0-16383
之间的哈希槽,Redis 会根据节点数量 大致均等 的将哈希槽映射到不同的节点。 - 集群作用:
- 数据分区: 数据分区 (或称数据分片) 是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加;另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。Redis 单机内存大小受限问题,在介绍持久化和主从复制时都有提及,例如,如果单机内存太大,
bgsave
和bgrewriteaof
的fork
操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出…… - 高可用: 集群支持主从复制和主节点的 自动故障转移 (与哨兵类似),当任一节点发生故障时,集群仍然可以对外提供服务。
- 数据分区: 数据分区 (或称数据分片) 是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加;另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。Redis 单机内存大小受限问题,在介绍持久化和主从复制时都有提及,例如,如果单机内存太大,
- 集群分区:
- 哈希值 % 节点数:计算
key
的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。不过该方案最大的问题是,当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要 重新计算映射关系,引发大规模数据迁移。 - 一致性哈希分区:一致性哈希算法将 整个哈希值空间 组织成一个虚拟的圆环,范围是 [0 - 232 - 1],对于每一个数据,根据
key
计算 hash 值,确数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器: - 带有虚拟节点的一致性哈希分区(Redis采用):该方案在 一致性哈希分区的基础上,引入了 虚拟节点 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 槽(slot)。槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。槽是数据管理和迁移的基本单位。槽 解耦 了 数据和实际节点 之间的关系,增加或删除节点对系统的影响很小
- 哈希值 % 节点数:计算
- 集群节点通信机制
- 每个集群之间的两两连接是通过了
CLUSTER MEET <ip> <port>
命令发送MEET
消息完成的 - 在 集群 中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护
- 群中的每个节点,都提供了两个 TCP 端口:
- 普通端口: 即我们在前面指定的端口 (7000等)。普通端口主要用于为客户端提供服务 (与单机节点类似);但在节点间数据迁移时也会使用。
- 集群端口: 端口号是普通端口 + 10000 (10000是固定值,无法改变),如
7000
节点的集群端口为17000
。集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。
- 协议:单对单、广播、Gossip 协议等
- 广播是指向集群内所有节点发送消息。优点 是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点 是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
- Gossip 协议的特点是:在节点数量有限的网络中,每个节点都 “随机” 的与部分节点通信 (并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip 协议的 优点 有负载 (比广播) 低、去中心化、容错性高 (因为通信有冗余) 等;缺点 主要是集群的收敛速度慢。
- 集群中的节点采用 固定频率(每秒10次) 的 定时任务 进行通信相关的工作:节点间发送的消息主要分为
5
种:meet 消息
、ping 消息
、pong 消息
、fail 消息
、publish 消息
。
- 每个集群之间的两两连接是通过了
- 集群数据存储:
- 最关键的是
clusterNode
和clusterState
结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。 clusterNode
结构保存了 一个节点的当前状态,包括创建时间、节点 id、ip 和端口号等。每个节点都会用一个clusterNode
结构记录自己的状态,并为集群内所有其他节点都创建一个clusterNode
结构来记录节点状态。clusterState
结构保存了在当前节点视角下,集群所处的状态、故障转移、槽迁移等需要的信息。
- 最关键的是
- 原理:Redis 集群中内置了
并发
就好比下单,支付,退款三个顺序你变了,你先退款,再下单,再支付,那流程就会失败,那数据不就乱了?你订单还没生成你却支付,退款了?明显走不通了,这在线上是很恐怖的事情。
-
某个时刻,多个系统实例都去更新某个 key。可以基于 Zookeeper 实现分布式锁。每个系统通过 Zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 Key,别人都不允许读和写。
-
你要写入缓存的数据,都是从 MySQL 里查出来的,都得写入 MySQL 中,写入 MySQL 中的时候必须保存一个时间戳,从 MySQL 查出来的时候,时间戳也查出来。
-
每次要写之前,先判断一下当前这个 Value 的时间戳是否比缓存里的 Value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。
缓存双写一致性
-
1、如果你的系统不是严格要求 “缓存+数据库” 必须保持一致性的话,最好不要做这个方案,即:读请求和写请求串行化,串到一个内存队列里去。
-
最经典的缓存+数据库读写的模式,就是 Cache Aside Pattern
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
- 为什么是删除缓存,而不是更新缓存?在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值:比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。
- 举个栗子:一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100 次;但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。
- 实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。就是一个 Lazy 计算的思想
-
2、先删缓存再更新数据库
- 问题:先删除缓存,数据库还没有更新成功,此时如果读取缓存,缓存不存在,去数据库中读取到的是旧值,缓存不一致发生。
- 解决:延时双删
- 为了避免更新数据库的时候,其他线程从缓存中读取不到数据,就在更新完数据库之后,再sleep一段时间,然后再次删除缓存。
- 1、线程1删除缓存,然后去更新数据库
- 2、线程2来读缓存,发现缓存已经被删除,所以直接从数据库中读取,这时候由于线程1还没有更新完成,所以读到的是旧值,然后把旧值写入缓存
- 3、线程1,根据估算的时间,sleep,由于sleep的时间大于线程2读数据+写缓存的时间,所以缓存被再次删除
- 4、如果还有其他线程来读取缓存的话,就会再次从数据库中读取到最新值
-
3、先更新数据库再删除缓存
-
问题:更新数据库成功,如果删除缓存失败或者还没有来得及删除,那么,其他线程从缓存中读取到的就是旧值,还是会发生不一致。
-
解决1:消息队列:先更新数据库,成功后往消息队列发消息,消费到消息后再删除缓存,借助消息队列的重试机制来实现,达到最终一致性的效果。
-
解决2:维护一个监听binlog消息的消息队列来做删除缓存的操作
-
解决3:设置缓存过期时间:每次放入缓存的时候,设置一个过期时间,比如5分钟,以后的操作只修改数据库,不操作缓存,等待缓存超时后从数据库重新读取。
-
Memcached
-
Redis 相比 Memcached 来说,拥有更多的数据结构,能支持更丰富的数据操作
-
Redis 原生支持集群模式:支持 Cluster 模式,而 Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据。
-
由于 Redis 只使用单核,而 Memcached 可以使用多核,所以平均每一个核上 Redis 在存储小数据时比 Memcached 性能更高。而在 100k 以上的数据中,Memcached 性能要高于 Redis
线程模型
Redis 内部使用文件事件处理器 file event handler
,这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 Socket,根据 Socket 上的事件来选择对应的事件处理器进行处理。
文件事件处理器的结构包含 4 个部分:
- 多个 Socket
- IO 多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
多个 Socket 可能会并发产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 Socket,会将 Socket 产生的事件放入队列中排队,文件事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。
Redis命令
-
keys
-
scan可以无阻塞提取出指定模式的key队列,但有一定重复概率,可用于找出1亿key中10w个以已知前缀开头的数据,再去重即可
-
smembers可以返回集合键当前包含的所有元素
-
机制
- 过期策略:定时删除+惰性删除
- 定期删除,默认100ms就随机抽一些设置了过期时间的key,去检查是否过期,过期了就删了。
- 惰性删除:查询了我看看你过期没,过期就删了还不给你返回
- 定时删除:在设置键的过期时间的同时,创建一个定时器 timer(O(N),使用了无序链表存储时间事件,但需要遍历获取最近需要删除的数据). 让定时器在键的过期时间一到,立即执行对键的删除操作。
-
内存淘汰策略:4.0版本增加volatile-lfu 和 allkeys-lfu
-
noeviction:返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但DEL和几个例外)
-
allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。
-
volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。
-
allkeys-random: 回收随机的键使得新添加的数据有空间存放。
-
volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。
-
volatile-ttl: 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放。
如果没有键满足回收的前提条件的话,策略volatile-lru, volatile-random以及volatile-ttl就和noeviction 差不多了。
-
volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
-
allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
-
-
LRU
- 在LRU实现的理论中,我们希望的是,在旧键中的第一半将会过期。Redis的LRU算法则是概率的过期旧的键。
缓存类型
- 本地缓存:就是在进程的内存中进行缓存,比如我们的 JVM 堆中,可以用 LRUMap 来实现,也可以使用 Ehcache 这样的工具来实现。本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。
- 分布式缓存:一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。
- 多级缓存:例如调用后端服务接口获取信息时,可以使用本地+远程的多级缓存。实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。
- Memcache:
- 使用多线程异步IO,利用cpu多核优势,性能高,但只能存储sting类型的k-v
- 钙化问题、数据可以设置失效期
- key 不能超过 250 个字节;value 不能超过 1M 字节;key 的最大失效时间是 30 天;只支持 K-V 结构,不提供持久化和主从同步功能。
秒杀系统
-
业务规则层面:针对流量特别大的场景,可以分时分段开展活动,原来统一 10 点抢口罩,现在 6 点,6 点半,7 点,…每隔半个小时进行一次活动,达到流量摊匀的效果。
-
前端层面:
-
针对一般情况:前端侧会进行一个按钮置灰的操作,当你点击完一次之后,按钮会变灰,防止用户重复提交抢口罩的请求。
-
针对脚本:后端 controller 层会进行处理,简单来说就是对同一个用户抢口罩的请求进行校验,对于同一个用户在短时间内发送的大量请求进行次数限制,以及请求去重处理,或者进行拦截
-
资源静态化:
秒杀一般都是特定的商品还有页面模板,现在一般都是前后端分离的,页面一般都是不会经过后端的,但是前端也要自己的服务器啊,那就把能提前放入cdn服务器的东西都放进去,反正把所有能提升效率的步骤都做一下,减少真正秒杀时候服务器的压力。
-
秒杀链接加盐:URL动态化,就连写代码的人都不知道,你就通过MD5之类的摘要算法加密随机的字符串去做url,然后通过前端代码获取url后台校验才能通过。
-
限流:前端按钮置灰,后端一旦产品卖光了,return了一个false,前端直接秒杀结束,然后你后端也关闭后续无效请求的介入了。还可以引入Sentinel、Hystrix
-
-
nginx层
- 对于前端打过来的真实的抢口罩请求,在 Nginx 这里进行请求的分发,打到 Node 集群的某一个机器上
- 健康检测,Node 集群的机器同样有可能挂掉,所以会利用 Nginx 进行检测,发现挂了的机器,会干掉重启,保证集群的高可用。检测有两种机制,被动检测跟主动检测
- 负载均衡
-
风控层
- 通过风管分析出来这个用户是真实用户的概率没有其他用户概率大,那就认为他是机器了,丢弃他的请求。
-
后端:
-
微服务进行拆分
-
redis集群:Redis集群,主从同步、读写分离,哨兵
-
库存预热:开始秒杀前你通过定时任务或者运维提前把商品的库存加载到Redis中去,让整个流程都在Redis里面去做,然后等秒杀介绍了,再异步的去修改库存就好了。
-
事务:Redis本身是支持事务的,而且他有很多原子命令的,大家也可以用LUA,还可以用他的管道pipeline,乐观锁
-
限流&降级&熔断&隔离:限流,顶不住就挡一部分出去但是不能说不行,降级,降级了还是被打挂了,熔断,至少不要影响别的系统,隔离,不拖累其他系统
-
消息队列(削峰填谷)
-
分布式事务:
-
所以TCC和最终一致性其实不是很适合,TCC开发成本很大,所有接口都要写三次,因为涉及TCC的三个阶段。
最终一致性基本上都是靠轮训的操作去保证一个操作一定成功,那时效性就大打折扣了。
因此可以使用2PC和3PC
-
-
-
Node Service层:四个阶段:同步、复制进程、多线程、以及事件驱动。
- Master-Worker 模式:Node 提供了 child_process 模块,或者说 cluster 模块也行,可以利用 child_process 模块直接创建子进程。解决了单线程使用多核 CPU 的资源利用问题,再加上 Node 异步非阻塞的特性,带来性能上的提升
- 主子进程通信:在 Node 中管道只是属于抽象层面的一个叫法而已,具体的实现都扔给一个叫 libuv 的家伙去做, Node 的 3 层架构,libuv 是在最下层, Node 可以跨平台嘛,libuv 会针对不同的平台,采用不同的方式实现进程间的通信。
- 客户端请求转发:句柄传递-去除代理
- 句柄实际上就是一种可以用来标识资源的引用。
- 使主进程收到客户端的请求之后,将这个请求直接发送给工作进程,而不是重新与子进程之间建立新的连接来转发数据。
- 子进程服务高可用问题:在子进程刚出异常的时候,就给主进程发一个自杀信号,主进程收到信号后,立即创建新的子进程
- Node集群:搭建 Node 集群了,进一步提高整个系统的高可用,Node 集群的负载均衡
- 因此数据一致性:Node 多个进程之间是不允许共享数据,容易造成超买现象,因此需要使用redis
-
Redis层
- 写一个定时脚本,在秒杀还未开始之前,就把当前口罩的库存数量写入 Redis,让 Node 服务的请求从 Redis 这里拿数据进行处理,然后异步的往 kafka 消息队列里面写入抢到口罩用户的订单信息
- 使用redis事务:事务可以一次执行多个命令,事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- redis集群、哨兵、持久化
-
消息队列层
- kafka 消息队列就是基于生产者-消设计模式滴,按具体场景与规则,针对上层请求让生产者往队列的末尾添加数据,让多个消费者从队列里面依次读取数据然后自行处理。
- 结合到我们的秒杀场景就是,对订单进行分组存储管理,然后让多个消费者来进行消费,也就是把订单信息写入 db。
-
数据库层
- 单独给秒杀建立一个数据库,为秒杀服务,表的设计也是竟可能的简单点,现在的互联网架构部署都是分库的。