Tech:Redis(基本素养)

同属<专家之路>,写一写 redis 这一个数据结构商店。这里只讲 5-8 种数据结构,相关的操作(命令),简述其内部实现以及我对其 单线程模型的理解,相关数据结构的使用场景。不包含对各大语言的支持,持久化,复制等配置,查询分析,内存分析,阻塞优化等等。评级: 3系。

最初接触 redis 的时候还是想用 C语言 拿来做应用的时候,那时候发现竟找不到顺手的常用数据结构(容器),然后就发现可能也有人有类似的需求且进一步推进发展成了一个库 redis,尽管它今天的用途可能已经很多了,甚至被当做内存数据库来用,不过个人觉得,追本溯源,其出身也就是数据结构。

这里不会去说具体的数据结构实现,比如为什么用 hash table, 什么是跳表(skip table),优缺点等,只是站在日常开发的角度来总结一下: 基本数据结构及其操作。

安装

为了简单,这里还是用 docker 写吧,简单跑个单机版。(用于本机开发,测试)

下面是 docker-compose.yml:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
version: '3'
services:
redis:
container_name: redis
image: redis:latest
hostname: redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
networks:
- redis-net
command: ["redis-server", "--appendonly", "yes"]
restart: always

networks:
redis-net:

volumes:
redis-data:

还是跑在后台吧: docker up -d,然后连接 docker exec -it redis /bin/bash -c redis-cli:

15-06-03-150526.jpg

如果只是显示中文,那么可以用 redis-cli --raw,这么一来就可以显示中文

总体命令如下: docker exec -it redis redis-cli --raw,然后就可以操作了。(GUI客户端好用的都要收费,干脆还是命令行吧)

也可以安装一个 Jetbrain 的插件:

15-39-04-153844.jpg

16-06-14-160558.jpg

(左下角打开 Redis Servers,然后就可以用了,非常方便;但是这货只有 7 天的试用期)

建议:

  • redis 有些命令,结构,比如索引都使用单字节来构建索引,而汉字是2-4字节,此时有些命令或者结构就不好用了。最好不要在 key 上用中文
  • 商业项目再用收费客户端,私人项目或者练习,redis-cli 简洁清晰,超级棒👍

下面从 理解和介绍 API 的角度,来看看这些繁多的命令和结构。

数据结构

根据 redis 弱化多数据库的趋势,这里也仅适用默认的 0 号数据库 (还是用多个 redis 实例吧)

现在版本 5.0,目前数据结构总是还是 8 种,估计以后可能会扩充或者私人团队进行自己的扩充,但常用的总是 5 种。

  • string
  • set
  • list
  • zset
  • hash

这里说的数据类型,其实指的是 key-value 的 value 的外部类型,具体内部实现,因场景而不同,例如 list 内部可能是 ziplist 实现,也可能是 linkedlist 实现或者其他实现(quicklist)。

逐个详说

字符串

它的值是安全字符串,所以存储二进制数据(图片,音频,dat等等)都是没有问题的,貌似最大可以 512 M。

常用的命令一般就是设置和获取,以及增长:

  • set, mset; get, mget (批处理节省了重复的网络IO时间) (单命令可以使用 ex seconds 指定过期时间)
  • setex, setnx (更新和新增的区别)
  • incr, decr, incrby, decrby 等 (要求值是整型)

不常用的比如 append, strlen, getset(返回原值并设置新值), setrange, getrange(基于字节索引,切片)。

因为支持索引操作,可想而知,上述所有操作肯定肯定都在常数级别 O(1),批处理则是线性级别 O(n)。

散列(hash)

这里的 hash 和算法 hash 不太一样,它目的不在于快速存取(当然存取也是常量级),而是声明 value 值是 hash 结构,即其他语言的 mapping, dict 结构。(简单说就是,值也是键值对结构。。。好绕)

对比一下 json 的嵌套对象就知道了。(我个人一般直接理解成 python 中的 dict)

常用命令: (这里没有不常用的,都很常用)

  • hset, hget (不存在返回 nil), hsetex, hsetnx, hmset, hmget,设置或者获取键值对
    • 一般更多使用 hmset 这类,因为一个字典里多半情况下会有多个键值对啊
  • hdel,删除 value 中的 key
  • hlen, 计算 value 中 key 的数目
    • hstrlen, 计算 value 中 value 的数目
  • hexists 判断 value 中的 key 是否存在
  • hkeys 获取 value 中所有的 key
  • hvals 获取 value 中所有的 value
  • hgetall 获取 value 本身 (所有 item 集合) – 一般用 hmget field1 field2 ... 代替,因为数据多了肯定网络阻塞
  • hincrby, hincrbyfloat 增加 value 中某个 key 的值

操作多是按照 value 中的 key 来的,比如 hdel xxx field1,和编程语言中的集合类似。

列表

又是一个集合,关键词: 有序可重复两端操作索引操作切片操作

感觉已经说完了。。。没有。因为其操作的灵活性,通常可以用来当做其他集合使用,比如Stack, Queue。

特别的是,它有提供一些阻塞操作。(用于构建阻塞容器,再好不过了)

这里的左右,需要注意一下,插入和弹出的时候分别指在哪边操作,而不是从哪到哪

常用操作: (增删改查,两端开工)

  • 添加:
    • rpush, lpush, linsert (根据指定元素的 before,after进行插入)
  • 查找:
    • lrange,比如 lrange keyname 0 -1 获取所有的列表所有元素 (含头含尾)
    • llen,获取列表当前长度
    • lindex, 拿到指定 index 的元素 (而不是去获取 index )
  • 删除:
    • lpop, rpop, lrem, ltrim,其中 lrem 可以指定删除个数 lrem key count value,count指定0表示全部的value都删除(并非按照索引来的); ltrim 只保留指定索引范围内的元素
  • 修改:
    • lset,但这个是按照 index 来的(可以理解啊,因为有重复元素,当然不能按照元素来),lset key index new_value

此外,删除或者说弹出,提供了 blpopbrpop阻塞版本:

  • 这个阻塞用于批量操作时保证顺序执行。原型 blpop key1 key2 ... keyn timeout
  • 如果要被弹出的 list 里面没有 element,且timeout为0,那么会阻塞等待里面有元素后再弹出返回;否则按照指定的timeout返回;如果列表不空,那么完成操作就返回; timeout 其实是列表为空时的阻塞等待时间

其实完全可以联想生产者和消费者模型,没pop成功的,继续阻塞在那等。

复杂性方面,不管其怎么实现,只要涉及移动,查找遍历那就是 O(n) 跑不了,两端直接操作,如pop就是O(1)

集合

把元素放在集合(set)中,一般是为了去重。因为 set 无序不重复。并且集合间的操作,比如交,并都是非常有用的。

操作分为两类,集合内和集合间的操作。

集合内操作: 常用的操作比如: sadd, srem, scard(计算元素个数), sismember(判断是否在集合中), srandmember(随机返回集合中的指定个元素), spop(从集合中随机弹出指定个元素), smembers(获取集合中所有元素)。

集合间操作:

  • 求交集: sinter, 多个集合之间求交集
  • 求并集: sunion, 多个集合之间并集
  • 求差集: sdiff, 多个集合之间求差集 (从左到右依次计算)

把集合运算的结果保存为一个新的 key。

  • sinterstore 新key名 集合1 集合2
  • sunionstore …
  • sdiffstore …

保存到新 key 中,它的类型也是 set,以此避免太多数据计算引来的性能问题。(重用计算结果)

有序集合

list 是依据 index 序号来排序,有序集合根据元素自身携带的 score 来排序。

23-41-21-234052.jpg

它增加了一个分数字段,也分集合内和集合间的操作,且操作种类繁多。(比 set 要复杂一些) 主要还要计算两个指标: score 和 rank。(分数和排名)

集合内的操作:

  • zadd key score member [score member] 增加一个新元素 (包括 score 和 member)
  • zcard key 计算集合内的元素个数
    • zcount key min max 计算指定范围内成员的个数
  • zscore key member 获取 member 的分数
    • zincrby key num member 增加集合中某元素的分数(分数可以增加)
  • zrank key member 获取 member 的排名 (正向排名,从底到高)
    • zrevrank key member (反向排名,从高到低)
    • 排名从 0 开始计算
  • zrem key member [member] 删除集合中某个元素
    • 按照范围删除,zremrangebyrankzremrangebyscore
  • 获取某个范围内的成员 (zrange)
    • zrange key start end [withscores] 根据排名,正向排名,获取所有的 即 0 -1
    • zrevrange key start end [withscores] 根据排名,反向排名(从高到低) – 可以指定开区间,默认是闭区间
    • zrangebyscore key min max [withscores] [limit offset count] 根据分数,从低到高 – 可以指定开区间,默认是闭区间
      • 这里指定 +inf 为正无穷大,-inf 为负无穷大。(min max) 表示开区间,无穷大不必加括号。

集合间的操作:

  • 交集: zinterstore 该命令必须指定保存到新key,可以指定参与计算的每个 key (中元素)的权重,以及同时支持对每个元素进行聚合操作。(默认为 sum,可以指定 maxmin)

操作比较复杂,举个例子:

1
2
3
4
5
6
7
8
9
10
11
127.0.0.1:6379> zadd xxx 10 aaa 20 bbb 30 ccc
127.0.0.1:6379> zadd yyy 20 aaa 20 bbb 40 ddd
127.0.0.1:6379> zinterstore new_zset 2 xxx yyy weights 1 0.5 aggregate max

127.0.0.1:6379> zrange xxx_yyy 0 -1 withscores

# 结果如下
aaa
10
bbb
20
  • 并集: zunionstore,可选选项大致同上

总结,zset 的 memeber 依旧不重复,但是因为 score 存在可以保持有序,操作也分为 rank 和 socre 两大类。

内部结构简略

上面已经说了,相关外部类型的内部实现,就是其内部结构。

为什么会有多种实现?空间和时间,不可兼得啊

比如 hash 结构,它内部可能使用的是压缩表(ziplist) 也可能是 hashtable,不同的实现(紧凑的顺序连续存储 VS Hash存储)适用于不同的时间,空间要求,后者明显存取速度 O(1) 非常块,前者一看就是节省空间。(且某些结构还会在数据过多的时候自行调整实现结构)

涉及到优化,内存问题的时候再来详细展开

1
2
3
4
5
# object encoding 查看
127.0.0.1:6379> rpush mylist a b c d e
5
127.0.0.1:6379> object encoding mylist
quicklist

这里其实可以看做一种代理或者门面(找具体子系统成员完成创建),甚至工厂模式。

面向外部抽象模式的编码方式,方便了后续扩展。(外部人员使用起来接口没变)

其次,不同的结构结合不同的场景,因才适用,比如 ziplist 比较节省内存,但数据过多的时候明显,增删效率就不行了,这个时候需要 linkedlist 来加持;在比如 string 的值如果是数值的话,那用 int 实现就了呀,小字符串就用 embstr。

给一幅图吧,大致涉及了主要实现: (新版本有增加实现,比如 quicklist)

18-21-14-182058.jpg

详细,不展开。(当前不展开,也没有必要展开,毕竟本门面向初级,涉及内部实现于生产无直接关联)

(展开需要看客具备相当的能力,比如为什么要用 skiplist 而不用普通的平衡树 avl 或者 rbtree)

命令超级多,但是只要熟悉数据结构,其实就知道该数据结构一般会有哪些命令。

通用命令

加上了我的一些理解,不妥之处,还望担待

  • keys * 查看所有的键(key) — 一般禁用这个命令,特别是键的数量非常多的时候 (后面有 scan 系列替代命令)
    • scan 属于按游标遍历,不会像 key 一样引起阻塞
  • dbsize 查看键的总数, O(1),想也知道,肯定内部有一个变量存储总数,否则每次遍历计算总数,就太傻了
  • exists key 查看键是否存在 (返回存在的数量)
  • del key [key...] 删除 key (可以一次性删除多个) (返回操作成功的数量,删除不存在的,自然返回0)
  • expire key <seconds> 设置键过期时间
    • ttl key 返回剩余过期时间 (-1表示压根没有设置过期时间,-2 键压根不存在)
  • type key 查看 key 的类型
  • flushdb 清除数据库, flushall 清除所有数据库

show 一波试试,还有没有记错: (先注入基本数据,然后操作)

17-00-37-170022.jpg

单线程模型

不晓得您有没有想过,为什么要采用单线程模型 + IO复用,而不是并发型

我当初想不通的是,毕竟这是内存型数据库啊,也没有那么多外部 IO 处理,比如网络。为嘛不用多线程,进程并发模式?下面是我的理解或者思路,逐步向下展开:

  • 首先,确实很有很多网络IO,因为可以有多个 client 同时向 server 发命令 (用于高并发场景更是如此)
  • 单线程模型是指执行命令的时候,同一时间仅有一个命令执行;而不是 client 发送给 server 是单线程(可以同时发,但发送的命令都会进入一个有序队列)
  • 线程的切换和同步竞争是否需要在这些短暂的命令间适用;因为每个命令执行的都非常短(容易引起阻塞的命令一般在生产环境被禁用,比如用 scan 而不用 key),所以保持CPU不停歇执行就可以打满CPU,不需要搞多个线程甚至进程来提高CPU的使用率; 同步控制对于某些数据结构可能繁杂,且锁的性能比不上(异步)任务队列
    • 执行上就是异步的,但用户 client 可能被阻塞,因为通过网络返回是同步的
  • redis可能封装了操作系统底层的 epoll 或者其他高效机制,所以单线程执行亦可

也就是说,不管多少客户端来,一同发送命令,最终执行的时候肯定是有序的(有控制的)。

最后,可能根本的原因,这类数据库性能才是最为要命的,如果去实现一个符合同步机制的结构,每增加一个数据结构就要保证他是线程安全的,顺利与否先不谈,这肯定比不上把代价转移到上层框架控制简便。(且并发数据结构不符合性能要求,且实现验证困难)

使用场景选择

恐怕大多数人停留在 先到缓存中拿,拿不到再去数据库找 的层面,这很好。

不东拉西扯,直接总结一下我的认知: (不详细展开,需要代码支持,只谈理解)

字符串:

  • 缓存场景: 主要是减小数据库读写的压力,其次是提供高并发能力的补充
    • 但是对于缓存的维护则是额外的代价,比如不存在时要去数据库拿,此时还要先序列化,然后写回缓存 (setex)
  • 全局计数器: 因为存取比较快,但实际要考虑的安全,公正问题比较多
  • 共享 session (分布式 session)
    • 传统的 session 模型是客户端拿来一个名为 session 的 cookie 跑来和 server 端存储的比对,以确定用户身份,但这也就适合单机,单服务器的年代了,也可能因为负载均衡的原因需要每个分布式主机上存储一份 session 数据,但这样仍旧可能造成数据不一致问题,为何不集中在 redis 上做管理呢?
    • 每次更新,存储都在 redis 上,高可用,然后 redis 验证过了再把请求放行给相关负载均衡的服务器
  • 限速, 限次
    • redis 中某一个键存在(或者超过一定数量),用户就不能做相应的请求,那么给这个键设置一个生存周期,自然就可以达成限制请求了

hash:

  • 缓存:
    • 传统型的,高度结构化的存储记录,所以并发能力始终不及 NoSQL。那么如果中间用 redis 做了结构化数据的行到非结构化数据的BSON或者JSON转换(这里是 hash),是不是能提高并发能力呢
    • redis hash 对于复杂查询 (比如多条件,连表查询)的支持力度底,或者维护困难

hash 用于缓存还有一个非常明显的好处: 高内聚。(如果用 string,那么势必会导致 key 非常多;就算序列化存储对象字符串,那序列化反序列化不要的成本的呢?)

缺点嘛: 可能需要控制一下内部实现结构的转换,且 hashtable 也确实占用空间。

list:

两端操作的能力,让其可以用模拟其他线性数据结构,比如 stack, queue。

  • 阻塞型消息队列: lpush 生产,然后多个 brpop 消费,早到的消费者早抢到(然后调用返回)。(也不用做并发控制了)
  • 有限数据集合: lpush + ltrim (仅保留指定范围内的数据)
  • stack: 同一端入,同一端出
  • queue: 出入不同端即可

set:

集合操作一方面可以用于去重,也可以用集合间的操作来处理交集,并集等。

  • 社交关系: 利用集合间的交集, sadd + sinter
  • spop/srandmember: 抽奖,产生随机数

zset:

  • 排行榜: zrange, zrangebyscore
  • 点赞统计: 给 zset 中的元素设置 score (zadd), 后续增加点赞 zincrby。

文章目录
  1. 1. 安装
  2. 2. 数据结构
    1. 2.1. 逐个详说
    2. 2.2. 内部结构简略
  3. 3. 通用命令
  4. 4. 单线程模型
  5. 5. 使用场景选择
|