这是我参与「第五届青训营 」笔记创作活动的第10天。
数据结构
String
在 redis 场景下,一个字符串有以下几个目标:
- 节省空间
- 可以很快地读取数据
- 当发生修改时,很快地可以写入
redis 对字符串做了改进,即 sdshdr,如下图为其存储结构。sds 指针指向 buf,指针右移获取 value,左移获取元数据。
buf 为 char 数组,其分配的空间大于当前存储的数据大小,即在有效的数据后面还有未使用的空间。flags 用于存储数据的类型,alloc 表示为当前 sds 分配的内存空间,而 len 则表示在当前分配的空间内部实际使用了多少长度的数据
这种数据结构可以存储数字、字符串和二进制数据,通常和 expire 配合使用,可以实现有期限的 key,可以用于 [[#连续签到|存储计数]]、Session、[[#限流]] 等场景
QucikList
Quicklist 由一个双向链表和 listpack 实现,QuickList 的整体结构如下图,使用双向链表的方式组织 entry,可以通过前后指针访问到相邻的 entry。 entry 的数据结构为 lispack,其中包含多条元素 element,每个 element 的大小都是相同的,可以使用偏移直接对 element 进行访问, num-element 表示元素的个数,totbyte 表示所有的元素的字节个数,可以根据 totbyte 直接跳转到当前 entry 的末尾。 element 由 encoding-type、element-data、element-tot-len。encoding-type 的长度是可变的,第一个字节表示编码类型,后面的字节用于表示 data 的字节数。element-data 用于存储实际数据,element-tot-len 表示元素的总长度,包括了 type 和 data,其用于从右往左遍历中。
Hash
下图为 Hash 表的存储结构。普通的 hash 表对应的时右上角的 dictht,根据 hash 算法将 entry 放在不同的槽位中,使用拉链的方式来解决冲突问题。当查找一个 key 时,使用 key 的哈希计算结果,到对应的槽位遍历访问到对应的 value。
一开始的槽位比较少,业务数据增多后,哈希冲突会变得严重起来,每个槽位中的数据增多,当发生冲突时遍历槽位的链表开销会比较大,此时需要 rehash 来实现对原来的 hash 表的扩容重构,使每个槽位中的数据比较少,提高查找效率。
在 redis 中,为了不影响用户体验,没有采用对数据直接重构,而是使用了两个 hash 表指针。当 ht[0] 槽位不够用时,开始扩容的操作,为 ht[1] 分配更多的空间,并且设计新的 hash 函数。在 rehash 结束前,redis 屏蔽了用户对 ht[1] 的访问,用户的请求还是从 ht[0] 中获取后返回,同时会将访问的数据以及一部分其他数据迁移到 ht[1] 中,当所有的数据都迁移完毕之后,即扩容完成,将 ht[0] 指向 ht[1] 指向的 hash 表,ht[1] 指向 null。
redis 中的 rehash 将迁移的开销平摊到了每一次请求中,不需要在短时间内使用比较大的开销来迁移数据。
Hash 表即为 Java 中的 HashMap 数据结构,适合存储一份完整的数据,这样就不需要请求多次 key,减少客户端连接的开销,如 [[#用户信息统计]] 等情况。
Zset -zskiplist
在单链表中查询一个元素的时间复杂度为 ,即使该单链表是有序的,我们也不能通过 2 分的方式缩减时间复杂度。
跳跃表是一种有序的数据结构,它通过在每个几点中维持多个指向其他节点的指针,从而达到快速达到访问的节点。如下左图中,如果需要访问 7 号节点,从 head 出发,先访问到其右边的节点 3,如果 3 为当前层的最后一个指针,那么往下面一层,直接访问 3 的右边的节点,可以直接访问到 7 号节点。对于 1 号节点,由于第 1、2 层的第一个数据都比 1 大,所以 head 直接遍历到第三层,并访问其后向指针,即可访问到 1 号节点。
在 redis 中,zskiplist 首先定义了当前跳表的层数和节点数,并且保存了头节点的指针和尾节点的指针。zskiplistNode 的结构包括:
- zskiplistlevel[] 结构体数组,即多个层次的后向指针的数组,结构体中的 span 表示当前节点与后向节点的距离。
- backward 前向指针,每个节点只有一个后退指针,只能回退一个节点,该指针可以实现从后往前遍历列表,从而实现了 倒序 的功能。
- score 成员,所有节点都按 score 从小到大来排序。
- obj 指针结合 dict,其指向 dictEntry 对象包含字符串对象,以及字符串对象对应的 sds 值。
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的 score 可以是相同的:score 相同的节点将按照成员对象在字典中的大小来进行排序,成员对象较小的节点会排在前面 (靠近表头的方向),而成员对象较大的节点则会排在后面 (靠近表尾的方向)。
这种数据结构可以实现 [[#排行榜]] 的业务功能。
应用案例
连续签到
掘金每日连续签到:用户每日有一次签到的机会,如果断签,连续签到计数将归 0。 连续签到的定义:每天必须在 23:59:59 前签到 使用 redis 为用户设置一个 key,其 value 即为签到的次数,并且使用其过期的机制,如果超过了一天,就将该 key 删除,表示断签,计数归 0.
限流
使用时间戳作为 key,每个 key 对应的 value 为当前请求的数量,当有流量访问时,都会对其时间戳的 key 递增。另外需要设置 QPS 限额,如果当前时间戳对应的 value 值已经超过了设置的 QPS,那么就限制访问。当到达下一个时间戳时,key 会更新,流量又可以被放行了。
消息通知
使用 [[#Zset -zskiplist|Zset]] 实现消息队列,当例如当文章更新时,将更新后的文章推送到 ES, 用户就能搜索到最新的文章数据。
用户信息统计
例如用户个人信息的页面,如果需要在一次连接中返回多条数据,则需要对这些数据进行打包,此时使用的时 [[#Hash]] 表的结构。
排行榜
当使用积分排行榜时,如果使用 mysql 进行存储,存储的数据量会很大,因为排行时的人数总是上万的,mysql 对大数据量的排序开销很大。可以使用 redis 中 [[#Zset]] 的数据结构,可以对排行榜的数据实时排序。
分布式锁
并发场景,要求一次只能有一个协程执行,执行完成后,其它等待中的协程才能执行。 可以使用 redis 中的 setNX 命令实现,这里利用了两个特性:
- Redis 是单线程执行命令的,可以实现同一时间只有一个线程抢锁
- setNX 只有未设置过才能执行成功,如果被设置过了,就得等待一段时间后再抢锁或者返回失败。
注意:这样的实现只是体验使用 setNX, 而不是一个高可用的方式。 该实现存在的问题:
(1) 业务超时解锁,导致并发问题。业务执行时间超过锁超时时间,锁一直没有被释放,那么就需要在业务层面实现锁超时的逻辑。 (2) redis 主备切换临界点问题。主备切换后,A 持有的锁还未同步到新的主节点时,B 可在新主节点获取锁,导致并发问题。 (3) redis 集群脑裂,导致出现多个主节点,那么两个主节点都会有锁。