redis在分布式系统中的应用

Posted by Hsz on April 13, 2019

redis在分布式系统中的应用

redis作为一个key-value内存数据库,因为其附带有性能优越的多种数据结构,在应用上带来了很多附加功能呢.在分布式系统中redis常作为一些特殊的中间件存在而非单纯的内存数据库.本文将总结这些特殊用法.

分布式条件下使用redis的注意点

命名空间

redis的设计可以说相当简陋,不像一般的关系数据库会分db,schema,表.能用的只有一个db(单机模式默认16个,集群则只有1个)和key,而key就是一个字符串,因此并不便于管理. 而redis往往却是用在一个分布式系统中,这就很有必要做好分库和命名规范.

习惯上我们通过用::来标识命名空间的方式来给键分组.命名空间按从大范围到小范围的,同级范围按范围名首字母排序的方式进行构造.这一习惯可以很好的避免因为key命名混乱造成的数据管理问题.

使用什么样方式部署redis服务

标准版redis有4种部署方式

  1. 单点部署(standalone),就是在一台机器上部署一个redis实例.

    单点部署

    这是最简单的一种部署方式.有完善的持久化方案,但无法保证在极端情况下数据不丢失,也无法保证高可用,更无法支持横向扩展

  2. 主从模式部署(master-slave),就是一个单点作为主节点,多个节点作为从节点,主从之间通过全量同步(刚建立关系时)和增量同步(建立好关系后)的方式同步数据.

    主从模式部署

    它一般用于单点部署无法满足性能要求时做读写分离,即master做写操作,slaver做读操作.相当于在数据持久化之外增加了热备和对读操作的容灾. 从主从模式开始redis就无法保证数据的强一致性了,因为redis的同步方式是异步同步的,所以说只能保证最终一致性.

  3. 哨兵模式部署(Sentinel),主从模式的升级款,通过哨兵节点监控集群判断master节点是否可用以及对slave的选举来实现高可用.

    哨兵模式部署

    其原理是通过哨兵集群监控主节点状态,如果多数哨兵同意主节点下线则将主节点下线,然后在从节点中选举一个作为主节点. 它是主从模式的升级,通过这种方式确保了redis服务的高可用

  4. 集群模式部署(cluster),去中心化的集群模式.

    集群模式部署

    通过多个master保存整个集群中的全部数据,而数据根据key进行crc-16校验算法进行散列,将key散列成对应16383个slot,而Redis cluster集群中每个master节点负责不同的slot范围.每个master节点下还可以配置多个slave节点,同时也可以在集群中再使用sentinel哨兵提升整个集群的高可用性. 集群模式做到高可用的同时代价是没有了一些特性:

    1. key批量操作支持有限.如MSET,MGET目前只支持具有相同slot值的key执行批量操作.对于映射为不同slot值的key由于执行MSET,MGET等操作可能存在于多个节点上因此不被支持.
    2. key事务操作支持有限,理由和上面一样,只支持多key在同一节点上的事务操作,当多个key分布在不同的节点上时无法使用事务功能.
    3. key作为数据分区的最小粒度,因此不能将一个大的键值对象如hash,list等映射到不同的节点.
    4. 不支持多数据库空间.单机下的Redis可以支持16个数据库,集群模式下只能使用一个数据库空间即db0
    5. 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构.

这4种模式特点的可以总结为如下表格:

模式 数据高一致性 服务高可用 可扩展性 部署难度 功能完整性
单节点 强一致性 简单
主从 最终一致性 一般
哨兵 最终一致性 一般
集群 最终一致性 复杂 不全

并不是说我们应该用哪种方式部署,而是我们应该根据不同的实际情况和使用场景(主要是请求量和服务可用性要求)来选择不同的部署方式和不同的服务端配置.

像一般的场景下单节点就已经是非常够用好维护的了.而如果请求量过高或者读写请求量非常不平衡,又或者只是想有热备功能那么我们就可以用主从模式做读写分离.如果对高可用有较高的要求比如主要用它作为消息中间件这样的场景,那么哨兵模式就是一个可以考虑的模式. 而集群模式虽然强大但相对比较复杂而且使用上会有些限制.

对于自建机房的来说我建议采用如下原则:

  1. 每个实例(redis节点)一台物理机器.
  2. 部署redis节点的机器不要执行其他程序

以上面的原则部署的话再考虑好成本和需求你大致就可以知道应该怎么部署了.

对于托管在云服务上的(比如aws,阿里云)的来说,建议你直接购买他们提供的redis服务,算上维护成本其实也不会比买机器自己搭建贵太多.

尽量避免执行堵塞操作

由于redis使用异步架构,所以一旦有堵塞操作就会造成整体的堵塞,而且集群模式也并不支持堵塞操作.

redis的架构

redis中的堵塞操作包括:

  • keys全量查询:所有要查询全量的操作都是会堵塞的
  • bigkey删除:删除操作的本质是要释放键值对占用的内存空间,一下子释放了大量内存会造成Redis主线程的阻塞.
  • 清空数据库:频繁删除键值对是潜在风险,清空数据库必然也是一个潜在风险.
  • AOF日志同步写:一个同步写磁盘的操作的耗时大约1~2ms,如果有大量写操作需要记录到AOF日志并同步写回就会阻塞主线程.
  • 从库加载RDB文件:RDB文件越大阻塞越久.

多数时候我们要注意的就是前2种操作:

  1. 避免用keys查找键,使用SCAN cursor [MATCH pattern] [COUNT count]迭代的查找键
  2. 避免批量的del键,比较优雅的方式是为每个键设置过期

充分利用键的过期功能

redis的一大优势在于可以为key设置过期,注意redis的过期并不代表键已经被删除了,而是会在如下情况下删除:

  1. 惰性删除,也就是在下次请求时如果发现键存在且已经过期则会被删除
  2. 定时删除,redis的子线程会定时删除已经被标注为过期的键,默认10s,这个可以在配置种设置hz来改变间隔

键可以过期的优势有如下几个:

  1. 一定程度上避免内存无限扩张,如果过期设置合理完全可以不用对key做额外的删除设置,同时保证保存的都是自己需要的键
  2. 过期本身也是信息,可以被利用.如果在redis中设置了notify-keyspace-events则可以获得键的行为,当然由于redis的键空间通知载体是pub/sub,因此可能存在因为断连而丢失数据的情况,因此这个机制可以用但最好有个兜底策略,比如定时同步啥的

键空间通知(keyspace notification)

键空间通知允许你设置你想监听的键的行为,它会将行为描述以pub/sub的形式广播出来.注意集群模式下键空间通知只对单点,因此我们必须监听所有端点的键控空间才能获得集群模式下的所有通知.

键空间通知需要设置配置notify-keyspace-events才能开启.其配置就是这个配置项的值.允许如下字符进行组合

字符 发送的通知
K 激活键空间通知,所有键空间通知以__keyspace@<db>__为前缀,具体键形式为__keyspace@<db>__:<key>,值为event名
E 激活键事件通知,所有键事件通知以__keyevent@<db>__为前缀,具体键形式为__keyevent@<db>__:<event>,值为key名
g 广播DEL,EXPIRE,RENAME等类型无关的通用命令的通知
$ 广播字符串命令的通知
l 广播列表命令的通知
s 广播集合命令的通知
h 广播哈希命令的通知
z 广播有序集合命令的通知
x 过期事件,每当有过期键被删除时发送,注意并不是过期时间为0时发送,而是上面介绍的两种过期删除行为被触发时发送
e 驱逐(evict)事件,每当有键因为maxmemory政策而被删除时发送
A 参数g$lshzxe的别名,也就是监听全事件

一般我们要监听的就是过期,那只要简单设置Ex即可.而监听的话我们只要监听__key*__:*就可以了

下面是几个利用过期的场景

触发定时任务

这个做法不提倡使用,毕竟pubsub并不稳健,但临时用其实问题不大.

我们可以直接利用键空间通知来实现定时任务触发.只要约定好不同的任务监听不同键命名即可.比如我们定义键为crontab::<jobID>::<taskID>,监听器只要监听__keyevent@<db>__:expired并过滤值为crontab::<自己jobID>::*的就可以了.当监听到时就执行任务,否则就一直等待.

简易限流器

我们可以通过设置一个很短过期时间的键来控制用户的访问频率,从而避免恶意用户频繁访问挤爆服务器的情况.基本思路是:

  1. 为每个用户设置一个key,这个key用string类型,并使用计数器功能
  2. 为这个可以设置一个较短的过期时间,比如3s,
  3. 设置一个阈值,计数器到达这个阈值则不再为用户提供服务,一般会根据最大过期时间和系统的承载能力来设置,比如3s就设5次,1min就设置50次这样.
  4. 用户每次请求就向自己的key中加1,同时根据获得的结果判断是否超过阈值来判断是否继续执行用户的这次请求

这样相当于从第一次访问起每隔过期时间内最多就执行设置阈值数量的请求.当然这个方案只是一个很简陋的方案,下面会有更好的解决方案.

分布式锁

在很多分布式系统中我们要保证分发出去的任务同一时间只执行一次,这种时候就可以利用redis的string的setnx配合过期实现一个分布式锁,只有获得了锁的任务才可以执行.

这个锁的实现也很简单–string类型的带过期的key,value为clientID,我们规定只有设置锁的client才可以释放锁.

比如某种任务我们为其设置key为lock::<jobID>有相同jobID的任务,那么每个任务执行如下操作:

  1. 使用setnx <key> <clientID>获取锁
  2. 如果返回0表示未能获取锁,则不执行任务或者等待后重试
  3. 如果返回1表示获取锁成功,则给锁设置过期(根据执行任务的复杂程度,比如10s),执行任务,无论执行成功失败,完成后释放锁(delete掉这个key)

记录用户行为上下文

这也是一个很常见的用法,一般我们要分析一个用户就需要记录它的行为.但行为往往是序列,是有上下文的,比如一个用户看到了页面上10个推荐项,然后他点击了其中一个,那这次的点击自然是和他看到的页面上的10个推荐项有关的.但往往我们收集的数据都是孤立的不带上下文信息的因此最简单的办法是通过一个id将用户的上下文行为串联起来.比较常见的上下文大致有如下几种:

  1. 一次会话上下文行为,通常我们从第一次打开app/网页开始算,到用户主动退出app/网页或者退到后台超过30分钟没有新动作作为结束

  2. 一次查找上下文行为,通常我们从进入一个页面开始算,到用户最终找到想使用的物品或者超过30分钟没有新动作作为结束

上下文完全可以被自定义,他们的处理方式都是一样的,就是通过string类型的setnx配合过期来实现

  1. 用户每次请求在自己固定的会话idkey上(比如SessionID::<uid>)使用setnx为其分配一个上下文id,(这个id可以是uuid或者snowflake)
  2. 用户每次请求在自己固定的会话缓存key上(比如SessionCache::<uid>)分配一个空的list结构
  3. 如果设置成功,说明是一个新的行为上下文的开始,清空会话缓存key上的数据,将这个事件附上新的会话id,并插入到其中,并设置好会话id和会话缓存过期为最大过期时间
  4. 如果失败,说明行为在已经存在的上下文中了,将这个事件附上旧的会话id并插入会话缓存,同时延长会话id和会话缓存过期到最大过期时间

这种方式好处是一个用户只会有定义上下文种类数量的key需要维护,而且由于都有过期.过期时间设置的当总体redis空间占用并不会太大.如果同时使用list结构将每次请求的事件都存储起来的话就可以快速查找用户当前的上下文行为,有利于相关调用.即便不存通过这个id快速查找数据库中的上下文也不会太慢.

全局计数器

redis的string(INCR)hash(HINCRBY)的filed都支持作为计数器,可以生成最大$2^64$的连续整数.这个功能通常用于给分布式系统做全局唯一键(当然更好的办法是使用uuid或者snowflake算法)

下面是几个常见的使用场景

用户id生成器

虽然mysql,pg都有自增类型,但真到了一定数据规模,分库分包就势在必行,这种时候一般mysql/pg的自增就失效了,因此更好的办法是将用户的id生成放在外部,这时候就可以使用redis的全局计数器功能了.

用作用户id的优势在于:

  1. 是整型数据,节省存储空间,用户id通常会关联很多表,如果用字符串型必然会占用不少空间
  2. 天然可以排序,用户id的早晚本身就是信息,可以在一些情况下用于区分新老用户
  3. 可以结合下面的bitmap用于做在线用户查询
  4. 足够大.

全局去重

redis提供了好几种去重的方式,他们各有特点

数据结构 特点
Set 最传统的集合数据类型,功能全面,但空间占用大
sorted set 带权重的集合,除了set的功能外它会带一个用于排序的权重
bitmap 位图,本质上就是个string,它只能为int型的数据做去重,它的空间占用只和加入的最大值有关,最大占用空间为512M,通常可以比使用set更加省空间.但代价是要更多的使用cpu
Hyperloglog 这个结构只能用于去重计数,而且无法保证精确,通常只在粗略估计的时候使用,但它比bitmap更加省空间.

需要注意上面几个结构的集合计算接口和比如排序接口都是要cpu的,因此如果要用redis做去重而且需要做这些计算,最好是单独弄个实例做以避免阻塞线上

下面是几个使用全局去重的场景

快速排序(sorted set)

Redis的有序集合(sorted set)可以快速进行排序,比如做搜索引擎,我们就可以将权重计算做成一个分布式任务,然后将url的权重按命令ZADD <key> <weight> <url>放入redis,需要取值时使用ZRANGE <key> <from> <to> WITHSCORES就可以取出范围内的url了

在线用户查询(bitmap)

如果用户数量又超大那我们要查看某个用户在不在线就成了问题,如果刚好我们的用户id是整型,比如是用全局计数器生成用户id,那还有一个扩展应用就是可以快速查找用户是否在线,原理很简单,使用setbit <key> <offset> <value>

方法也很简单:

  1. 用户登录就为用户设置id为offset的位置为1
  2. 用户下线就为用户设置id为offset的位置为0
  3. 查看用户是否在线就查看用户id位置的offset是否为1,为1就在线.为0就不在线

这个里面关键问题在于如何确定用户是否上线下线.一般可以用如下方法:

  1. 用户产生请求就视为上线
  2. 用户主动调用下线接口视为下线
  3. 用户检查上文中介绍的用户会话上下文自己的会话idkey是否存在(也可以使用reids的事件广播功能,后文会讲)

计算活跃用户(Hyperloglog)

这个可以使用Hyperloglog配合日期使用,我们可以定义key的模式为dau::<年-月-日>,每次请求就将用户id放入当天的key中.这样使用PFCOUNT就可以看到每天的日活.而多天的日活也可以使用PFMERGE destkey sourcekey [sourcekey ...]聚合,完了之后再PFCOUNT destkey来获得.

一般最多我们会关注日活,近3日活跃用户,近7日活跃用户,近15日活跃用户,近30日活跃用户,因此我们可以将key的过期设置为32天,然后弄个定时任务每天把上一天的数据存起来

作为缓存

redis最常用的功能自然是缓存.这个功能相当于给分布式系统提供了一个公用内存,很多时候网站的用户访问会话信息就是存缓存的.这样可以避免过早的触及数据库的io瓶颈.

由于redis的key自带过期,这也是一种方便的缓存管理工具,每个缓存都应该设置好过期避免冷数据停留在其中.使用redis作为缓存也分简单复杂,大致可以按复杂程度和功能分为如下3个阶段:

简单缓存

最简单的缓存大致是这样:

st=>start: 用户先去缓存找结果 
find=>operation: 找结果
findOK=>condition: Yes
or No?
compute=>operation: 重新计算结果
computeOK=>condition: Yes
or No?
cache=>operation: 缓存结果并设置过期时间
return=>inputoutput: 返回结果
returnNull=>inputoutput: 返回空
ed=>end

st->find->findOK
findOK(yes,left)->return
findOK(no)->compute->computeOK
computeOK(yes,left)->cache->return
computeOK(no)->returnNull

带分布式锁的缓存

很多时候我们的缓存是提供给多个实例使用的,为了避免重复刷新缓存,我们可以为缓存设置分布式锁

st=>start: 用户先去缓存找结果 
find=>operation: 找结果
findOK=>condition: Yes
or No?
compute=>operation: 重新计算结果
computeOK=>condition: Yes
or No?
getlock=>operation: 尝试获取锁
getlockOK=>condition: Yes
or No?
cache=>operation: 缓存结果并设置过期时间
return=>inputoutput: 返回结果
returnNull=>inputoutput: 返回空
ed=>end

st->find->findOK
findOK(yes,left)->return
findOK(no)->compute->computeOK
computeOK(yes,left)->getlock->getlockOK
getlockOK(yes)->cache->return
getlockOK(no)->return
computeOK(no)->returnNull

带定时刷新的缓存

上面的方式实际上我们是被动的刷新缓存内容,这会造成最多缓存过期时间这么长时间的数据不会刷新,一种方式是降低缓存的过期时间,但过期时间越短相当于计算的越多,因此越浪费资源.另一种就是主动去刷新缓存.通常定时刷新缓存需要额外起一个线程,定时去执行缓存的刷新工作.

带防击穿的缓存

上面的方式另一个漏洞就是如果一直无结果或者缓存服务挂了,会造成一直计算,这种被称作缓存击穿.处理这种情况有几种方式:

  1. 无结果也缓存,取到空值后一样返回.这可以解决空值的缓存击穿问题
  2. 为每次请求设置限流,也就是如下结构
st=>start: 用户先去缓存找结果 
find=>operation: 找结果
findOK=>condition: Yes
or No?
getlimiter=>operation: 请求限流器是否允许执行
getlimiterOK=>condition: Yes
or No?
compute=>operation: 重新计算结果
computeOK=>condition: Yes
or No?
getlock=>operation: 尝试获取锁
getlockOK=>condition: Yes
or No?
cache=>operation: 缓存结果并设置过期时间
return=>inputoutput: 返回结果
returnNull=>inputoutput: 返回空
ed=>end

st->find->findOK
findOK(yes,left)->return
findOK(no)->getlimiter->getlimiterOK
getlimiterOK(yes)->compute->computeOK
computeOK(yes,left)->getlock->getlockOK
getlockOK(yes)->cache->return
getlockOK(no)->return
computeOK(no)->returnNull
getlimiterOK(no)->returnNull

扩展模块

redis支持自定义扩展模块,通常自定义模块不会考虑集群化部署,我们一般都是为它单独起个实例用它做些个特殊工作,官方维护的第三方模块列表可以在这里找到,其中不乏一些真正有用的东西,比如:

  1. redis-cell令牌桶算法实现,一个专为限流设计的算法
  2. RedisBloom一个专注于去重和topk的模块等

加载模块有三种方式:

  1. 配置文件中设置
...
loadmoudule /path/module.so [argv0] [argv1]...
...
  1. 启动时命令行中设置
--loadmodule /path/module.so [argv0] [argv1]...
  1. 客户端中配置
> module load <path> [argv0] [argv1] ...

我们可以使用命令module list查看已经加载的模块

自定义模块

我们也可以自己写模块来扩展,redis的模块时符合c99标准的c语言动态链接库,它需要满足接口int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc). 这个接口会在加载模块时被调用,我们需要在其中调用如下两个接口:

  • int RedisModule_Init(RedisModuleCtx *ctx, const char *modulename,int module_version, int api_version);这个接口用于初始化模块,他会将模块注册到模块列表

  • int RedisModule_CreateCommand(RedisModuleCtx *ctx, const char *name, RedisModuleCmdFunc cmdfunc, const char *strflags, int firstkey, int lastkey, int keystep);用于创建redis中的命令,只有创建的命令客户端才能调用这个命令,这其中RedisModuleCmdFunc cmdfunc是调用命令时执行的函数,它的签名必须为int mycommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc); 而我们主要就是要实现这个mycommand,需要注意它的返回值固定是int,通常它是错误码,redis命令返回数据需要调用函数RedisModule_ReplyWith<type>(ctx,values...);支持的返回函数包括:

  • RedisModule_ReplyWithError(RedisModuleCtx *ctx, const char *err);返回错误
  • RedisModule_ReplyWithLongLong(RedisModuleCtx *ctx, long long 12345);返回整型数
  • RedisModule_ReplyWithSimpleString(RedisModuleCtx *ctx,const char *msg);返回简易字符串
  • int RedisModule_ReplyWithStringBuffer(RedisModuleCtx *ctx, const char *buf, size_t len);返回一个定长的字符型buffer
  • int RedisModule_ReplyWithString(RedisModuleCtx *ctx, RedisModuleString *str);返回安全字符串
  • RedisModule_ReplyWithArray(ctx,<len>);返回定长数组,其用法:

      RedisModule_ReplyWithArray(ctx,2);
      RedisModule_ReplyWithStringBuffer(ctx,"age",3);
      RedisModule_ReplyWithLongLong(ctx,22);
    
  • RedisModule_ReplyWithArray(ctx, REDISMODULE_POSTPONED_ARRAY_LEN);返回不定长的数组,其用法是

      RedisModule_ReplyWithArray(ctx, REDISMODULE_POSTPONED_ARRAY_LEN);
      number_of_factors = 0;
      while(still_factors) {
          RedisModule_ReplyWithLongLong(ctx, some_factor);
          number_of_factors++;
      }
      RedisModule_ReplySetArrayLength(ctx, number_of_factors);
    

我们当然也可以使用c++或者其他系统级编译语言来写,只要它可以转成c接口即可.