Redis实际应用

Redis如何实现延迟队列

延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:

  • 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;
  • 打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;
  • 点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单;

在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。

使用ZADD <score> <member>命令就可以一直往内存中生产消息。再利用zrangebysocre查询符合条件的所有待处理的到期任务, 通过循环执行队列任务即可。

Redis的大key如何处理

Redis的大key是什么?

大key指的是key对应的value很大。一般地,下面两种情况被称为大key:

  • String类型的值大于10KB
  • Hash、List、Set、ZSet类型的元素个数超过5000个

大key会导致什么问题?

  • 客户端超时阻塞:由于Redis执行命令是单线程处理,那么在操作大key时会耗时很长,从客户端角度来看,就是很长时间无响应
  • 引发网络阻塞:每次获取大key产生的网络流量较大,如果一个key大小为1MB,每秒访问量1000,则会产生1000MB/s的流量,对于普通的千兆网卡的服务器是难以承受的
  • 阻塞工作线程:如果使用del删除大key,会阻塞工作线程,无法处理后续命令
  • 内存分布不均:集群模式在slot分片均匀情况下,会出现数据和查询倾斜的问题,部分有大key的Redis节点占用内存多,QPS也较大

如何定位大key?

1. 通过redis-cli --bigkeys查找大key

1
redis-cli -h 127.0.0.1 -p6379 -a "passwd" --bigkeys
  • 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;
  • 如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用-i参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。

不足:

  • 这个方法只能返回每种类型中最大的那个大key,无法得到大小排在前 N 位的大key;
  • 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大;

2. 使用SCAN命令查找大key

使用SCAN命令对数据库扫描,然后用TYPE命令获取返回的每一个 key 的类型。

对于 String 类型,可以直接使用STRLEN命令获取字符串的长度,也就是占用的内存空间字节数。

对于集合类型来说,有两种方法可以获得它占用的内存大小:

  • 如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List 类型:LLEN 命令;Hash 类型:HLEN 命令;Set 类型:SCARD 命令;Sorted Set 类型:ZCARD 命令;
  • 如果不能提前知道写入集合的元素大小,可以使用 MEMORY USAGE 命令(需要 Redis 4.0 及以上版本),查询一个键值对占用的内存空间。

3. 使用RDBTools工具查找大key

使用RDBTools第三方开源工具,可以用来解析Redis快照(RDB)文件,找到其中的大key。

如何删除大key?

删除操作的本质是要释放键值对占用的内存空间。

释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。

所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。

有两种方法:

1. 分批次删除

对于删除大Hash,使用 hscan 命令,每次获取 100 个字段,再用 hdel 命令,每次删除 1 个字段。

对于删除大List,通过 ltrim 命令,每次删除少量元素。

对于删除大Set,使用 sscan 命令,每次扫描集合中 100 个元素,再用 srem 命令每次删除一个键。

对于删除大ZSet,使用 zremrangebyrank 命令,每次删除 top 100个元素。

2. 异步删除(Redis 4.0以上)

对于Redis 4.0版本以上,可以使用异步删除,通过unlink命令代替del来删除。这样Redis会将大key放入一个异步线程中来删除,避免阻塞主线程。

另外,还可以配置参数,达到某些条件时自动进行异步删除:

1
2
3
4
lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del
noslave-lazy-flush no

Redis管道

使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。

但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。

要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。

Redis事务是否支持回滚

MySQL在执行事务时,会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态。

Redis中并没有提供回滚机制,虽然 Redis 提供了DISCARD命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。

事务执行过程中,如果命令入队时没报错,而事务提交后,实际执行时报错了,正确的命令依然可以正常执行,所以这可以看出 Redis 并不一定保证原子性

不支持回滚的原因在于:这种复杂的功能和Redis追求的简单高效的设计主旨不符合。

如何用Redis实现分布式锁

分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。

Redis本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。

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

  • 如果key不存在,则显示插入成功,可以用来表示加锁成功
  • 如果key存在,则显示插入失败,可以用来表示加锁失败

基于Redis节点实现分布式锁时,对于加锁操作,需要满足三个条件:

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,使用SET命令带上 NX 选项来实现加锁;
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,在SET命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,使用SET命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

例如,有一条分布式命令如下:

1
SET lock_key unique_value NX PX 10000
  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

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

综上,即通过SET命令和Lua脚本在Redis单节点上完成了分布式锁的加锁和解锁。

基于Redis实现分布式锁有何优缺点?

优点:

  • 性能高效(这是选择缓存实现分布式锁最核心的出发点)

  • 实现方便。因为 Redis 提供了 setnx 方法,实现分布式锁很方便

  • 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)

缺点:

  • 超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。
    • 那么如何合理设置超时时间呢? 我们可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。
  • Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。

Redis如何解决集群情况下分布式锁的可靠性?

为了保证集群环境下的分布式锁的可靠性,Redis设计了一个分布式锁算法Redlock。

它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署5个Redis节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。

Redlock算法的基本思路,是让客户端和多个独立的Redis节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么就认为,客户端成功地获得分布式锁,否则加锁失败

Redlock 算法加锁三个过程:

  • 第一步,客户端获取当前时间(t1)。
  • 第二步,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
    • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
    • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
  • 第三步,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):

  • 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;
  • 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。

加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

加锁失败后,客户端向所有Redis节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的Lua脚本就可以了。