Redis和它的键们(1)—String

String

0 系列开篇

在介绍之前,笔者想介绍一下Redis的设计精髓,也就是其单线程设计,对于一个宣称能抗住十万qps的数据库,其单线程的设计让人不可思议,但redis开创的单线程设计哲学是至今其仍是KV型数据库一方霸主的重要原因,而其骄傲的资本是其立足于内存,而又不止步于内存(持久化、压缩、淘汰算法)的诸多设计。

之所以想介绍它的单线程设计,正是因为Redis的很多数据结构,都是为了阻击单线程架构的宿敌——阻塞而产生的,这也会是系列文章的一个主要脉络

Redis是KV型的数据库,其数据结构代表着对应的键,Redis的键有五大类型,分别是String、List、Hash、Set、Zset,另外还有三种不常用的类型 HyperLogLog、BitMap、Geo,本文着重介绍前五种

1 字符串键

1.1 C语言的字符串实现

字符串键的使用和实现都比较简单

Redis的底层实现是C语言,但是C语言的字符串具有种种弊端,对于Redis来说,最不可接受的有如下几点:

1.1.1 二进制不安全

C语言中用于识别字符串的函数会自动对”\0”(也就是空字符字符作出截断,这对于二进制文本来说是极不安全的,如果有如“redis\0is\0good”的文本,那么C语言就只能识别到”redis”,这从根本上断绝了用它来传输二进制文本的可能

1.1.2 字符串长度获取复杂度太高

C语言中并未维护获取字符串长度的变量,每次获取它的长度,都必须从头到尾遍历字符串一次,才能够获取它的长度,当这个字符串长度太长时,Redis的单线程会不可避免地陷入阻塞,这是Redis地设计者不希望看到的

1.1.3 缓冲区溢出问题

C语言中的字符串拼接默认地假定在该字符串的后面有足够的内存以放下后来的字符串,一旦这个假定成立,就会发生缓冲区溢出,C语言处理此类问题的方式也是相当粗暴,一旦溢出发生,后面”无辜”的数据内存就会被覆盖掉,这对于数据库来说是无法容忍的。

1.2 Redis的利器,SDS

SDS是Redis自己的字符串实现,其对于以上三个问题都给出了很好的解决

我们可以通过如下代码发现,SDS实现的字符串具有更好的封装性,显得更面向对象了

struct sdshdr{
 int len;//记录字符串长度
 int free;//记录可用空间的长度
 char buf[];//保存每一个字符
//APIs
}
  • 通过len,我们得以实现常数复杂度获取长度
  • char数组保存空间,以实现二进制安全
  • 而free有什么用呢?

    • 和C语言中字符串的缓冲区大小完全听天由命不同,每一个SDS被创建、修改时,都会有一个等同于自身大小的缓冲区(这个空间最大为1M)
    • 有了这个缓冲区,Redis得以避免频繁的空间重分配,因为迁移字符串是一个极其消耗性能的操作,它必须找到一块新空间,并把原来的字符串一个个搬运过去,这也会增加Redis主线程阻塞的几率
    • 这种做法体现了一种在空间和时间上的权衡,拿空间换时间,其带来的好处就是Redis空间重分配的时间大大减少

1.3 String In Action

接下来笔者想就String在技术层面和业务层面的作用来讲讲String的妙用

String在点赞系统中的应用

点赞系统社区功能里最常见核心的功能之一,其承载的巨大数据量又是一般的业务所不具有的,而String在此系统的设计中扮演了较为重要的作用,这里借用Bilibili的点赞架构来讲述String在计数功能中发挥何种作用(为了方便理解有做改动,思想不变)

  • 核心需求:点赞数目、最近点赞列表
  • 技术选型:Redis+MySQL,更新方式采用CacheAside
  • 数据模型:

    • Redis中:

      • 以likeCount:userId存储某个稿件点赞的数目

        key-value = likeCount:{userId}- {likes},{disLikes}
        //用业务ID和该业务下的实体ID作为缓存的Key,并将点赞数与点踩数拼接起来存储以及更新
      • 以Zset存储最近的点赞,但是此集合不能无限膨胀,需要剪裁,当需要更多信息时,返回DB以查询更多

        key-value = user:likes:{entityId} - member(messageID)-score(likeTimestamp)
        * 用userId作为key,value则是一个ZSet,member为被点赞的实体ID,score为点赞的时间。
        当改业务下某用户有新的点赞操作的时候,被点赞的实体则会通过 zadd的方式把最新的点赞\
        记录加入到该ZSet里面来
        * 为了维持用户点赞列表的长度(不至于无限扩张),需要在每一次加入新的点赞记录的时候,
        按照固定长度裁剪用户的点赞记录缓存。该设计也就代表用户的点赞记录在缓存中是有限度
        长度的,超过该长度的数据请求需要回源DB查询
    • MySQL中:

      • 点赞记录表 - likes : 每一次的点赞记录(用户userId、被点赞的实体ID(entityId)、点赞来源、时间)等信息,并且在userId、entityId两个维度上建立了满足业务求的联合索引。
      • 点赞数表 - counts :以实体ID(entityID)为主键,聚合了该实体的点赞数、点踩数等信息。并且按照entityID维度建立满足业务查询的索引。

        Why Not Set or Zset?

      有人会问了,Redis的效率极高,还支持持久化,为何我不采用set或者Zset以存在Redis里?这对于热点的like数据不是更好吗?

      • 首先说说Set实现like有什么问题,首当其冲的是用无序的set存几乎没有任何拓展性,比较经典的实现是,在以like:{entityId}的set键里存储userId,在调用它isMember以查看是否点赞过,以及其大小时,可以获得风驰电掣的速度,但在面对诸如点赞列表、分析用户在时间尺度上的点赞行为等业务需求上,set显得无能为力
      • 而对于Zset,似乎是实现此结构的天然首选

        Member-存entityId
        Score-存时间戳
        点赞列表-求zset的最后n项即可

        其排序和查找特性似乎是为了上述的需求量身定制,然而Zset的问题比set还要糟糕,set仅仅是业务拓展能力不足,Zset作为点赞的容器极有可能引起redis主线程的阻塞:

        • Zset对于isMember的查询是O(N)的,也就是说无异于去遍历整个链表,这对于业务量很可能在十几万甚至上百万的点赞上来说,是不可接受的开销
        • 还是数据量的问题,对于每个实体来说都要存上十万个实体id与它们的时间戳,这样的实体可能还有上万个,这对Redis是无法承受之重,毕竟Redis也不是专门服务你点赞业务的(这个问题在Set中同样存在)
String在分布式锁中的应用

SET 命令有个 NX 参数可以实现「key不存在才插入」,可以用它来实现分布式锁:

这个命令是:

  1. 加锁

    SET {加锁的键} {客户端标识} NX PX {持有锁的最大时间}
    • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功
    • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败
    • 客户端标识用于表示此锁的拥有权
    • PX 10000表示此锁在10000秒内失效,防止发生异常产生无法释放锁的情况
  2. 释放锁的过程就是删除此键,让我们回想一下CAS思路下的替换操作

    • 首先是确认此锁属于自己(客户端id、是否是原值,等等形式)
    • 若是属于自己,再对其进行改动,如释放、释放后通知在等待锁的线程(此例子中是删除此键)

    这显然是两步操作,需要用LUA脚本来进行原子化,具体的逻辑如下

    // 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
    if redis.call("get",KEYS[1]) == ARGV[1] then
     return redis.call("del",KEYS[1])
    else
     return 0
    end
其他的应用

共享Session、JSON对象、单点过滤,此处不再一一赘述。

1.4 总结

作为KV型数据库中最简单的数据结构,SDS的功用仅仅是为Redis的强大开了个头,但即便是如此简单的结构,我们仍能在其中看见许多其为了避免Redis陷入阻塞噩梦的巧思,在接下来的介绍中我们能看见更多的Redis的精妙设计与实现

1.5 参考资料

《Redis设计与实现》

《Redis开发与运维》

点个赞吧——Bilibili的点赞系统架构设计

小林coding (xiaolincoding.com)

作者:言西早原文地址:https://segmentfault.com/a/1190000043863153

%s 个评论

要回复文章请先登录注册