分布式锁

在计算机领域,锁可以理解为针对某项资源使用权限的管理,它通常用来控制共享资源,比如一个进程内有多个线程竞争一个数据的使用权限,解决方式之一就是加锁。

分布式锁就是分布式场景下的锁,比如多台不同机器上的进程,去竞争同一项资源,就是分布式锁。

分布式锁的实现机制有两种:主动轮询和监听回调。这里的Redis分布式锁采用的是前者,etcd采用的是后者。

特性

具备哪些特性的分布式锁才是一个优秀的分布式锁?主要从如下几方面来看:

  • 互斥性:锁的目的是获取资源的使用权,所以只让一个竞争者持有锁,这一点要尽可能保证;
  • 抗死锁性(活性):避免锁因为异常永远不被释放。当一个竞争者在持有锁期间内,由于意外崩溃而导致未能主动解锁,其持有的锁也能够被兜底释放,并保证后续其它竞争者也能加锁;
  • 对称性:同一个锁,加锁和解锁必须是同一个竞争者。不能把其他竞争者持有的锁给释放了;
  • 可靠性:需要有一定程度的异常处理能力、容灾能力。

实现方式

分布式锁,一般会依托第三方组件来实现,而利用Redis实现则是工作中应用最多的一种。

今天,就让我们从最基础的步骤开始,依照分布式锁的特性,层层递进,步步完善,将它优化到最优,让大家完整地了解如何用Redis来实现一个分布式锁。

最简版本

首先,当然是搭建一个最简单的实现方式,直接用Redis的setnx命令,这个命令的语法是:

1
setnx key value

如果key不存在,则会将key设置为value,并返回1;如果key存在,不会有任务影响,返回0。

基于这个特性,我们就可以用setnx实现加锁的目的:通过setnx加锁,加锁之后其他服务无法加锁,用完之后,再通过delete解锁,深藏功与名。

支持过期时间

最简化版本有一个问题:如果获取锁的服务挂掉了,那么锁就一直得不到释放,就像石沉大海,杳无音信。所以我们需要一个超时来兜底。

Redis中有expire命令,用来设置一个key的超时时间。但是setnx和expire不具备原子性,如果setnx获取锁之后,服务挂掉,锁还是变成了死锁。

很自然,我们会想到,set和expire,有没有原子操作?

当然有,Redis早就考虑到了这种场景,推出了如下执行语句:

1
set key value nx ex seconds

nx表示具备setnx特定,ex表示增加了过期时间,最后一个参数就是过期时间的值。这样就解决了分布式锁的抗死锁性的问题。

但是还存在一个问题:会存在服务A释放掉服务B的锁的可能。因此,为了解决对称性,下面引入了owner。

为什么会存在释放别人的锁的情况?

因为是 redis 超时自动释放的。 A不知道锁释放了,它依然按照原定逻辑,执行完业务之后解锁,而解锁逻辑只是一个 delete 而已,它才不管现在锁是啥情况,也不知道锁现在是被B获取了,直接 delete 删除,也就把B的锁解了。

加上owner

我们来试想一下如下场景:服务A获取了锁,由于业务流程比较长,或者网络延迟、GC卡顿等原因,导致锁过期,而业务还会继续进行。这时候,业务B已经拿到了锁,准备去执行,这个时候服务A恢复过来并做完了业务,就会释放锁,而B却还在继续执行。

在真实的分布式场景中,可能存在几十个竞争者,那么上述情况发生概率就很高,导致同一份资源频繁被不同竞争者同时访问,分布式锁也就失去了意义。

分布式锁需要满足谁申请谁释放原则,不能释放别人的锁,也就是说,分布式锁,是要有归属的。

所以我们可以进一步给出解决方案:加入一个身份鉴权的判断进行优化。

到这里,除了可靠性以外的分布式锁性质就全部满足了…吗?

引入Lua

让我们再梳理一下完整的流程:竞争者获取锁执行任务,执行完毕后检查锁是不是自己的,最后进行释放。

这下我们发现:执行完毕后,检查锁,再释放,这些操作不是原子化的!可能锁获取时还是自己的,删除时却已经是别人的了。这可怎么办呢?

为了解决分布式场景下的并发安全的问题,Redis引入了Lua脚本。

Redis+ Lua,可以说是专门为解决原子问题而生。

有了Lua的特性,Redis才真正在分布式锁、秒杀等场景,有了用武之地,下面便是改造之后的流程:

看门狗 watchdog

如果业务还没完成,锁却过期了,那么仍会出现不安全问题:

这里就要引出WatchDog机制,WatchDog也叫做看门狗,可以理解为–有一只修狗,每到饭点就会饿,饿了会狗叫,主人听到了,就需要触发一个喂狗的动作。

看门狗机制在Redis分布式锁里面是这么应用的:

看门狗可以看作是程序里面的一个线程,定时向Redis进行续期操作,防止锁在业务完成之前过期了。续期的开始时间可以是超过过期时间的三分之一,比如9秒的过期时间,那么在第3秒的时候开始续期。

当然看门狗也有自己的缺点,看门狗也需要消耗一些资源,而且有时候单纯是因为持有锁的线程本身出现问题,导致业务完成时间超出了过期时间,而看门狗背后一直进行的续期操作,会让其他请求方都拿不到锁,这个时候要考虑保证业务安全性,还是保证其他请求继续执行。一般的解决办法有:设置锁最大生命周期,即使有看门狗超过这个阈值也会释放;独立定期检查锁的持有状态;业务层面增加超时控制等等。

在实际生产环境中,通常不会手写Redis分布式锁,而是使用成熟的框架如Redisson。如果在业务中需要适用场景更复杂的定时逻辑,那还需要自己手写完成。

其实到了这一步,分布式锁的前三个特性:对称性、安全性、互斥性,就满足了。可以说是一个可用的分布式锁了,能满足大多数场景的需要。

可靠性保证

分布式锁的四大特性还剩下可靠性没有解决。

针对一些异常场景,包括Redis挂掉了、业务执行时间过长、网络波动等情况,我们来一起分析如何处理。

前面我们谈及的内容,基本是基于单机考虑的,如果Redis挂掉了,那锁就不能获取了。这个问题该如何解决呢?

一般来说,有两种方法:主从容灾和多级部署。下面我们来分别介绍。

主从容灾

最简单的一种方式,就是为Redis配置从节点,当主节点挂了,用从节点顶包。

全量同步是指当Slave节点第一次连接Master,或者长时间断开连接后重新连接时,Master会将自己的完整数据集传输给Slave。

工作流程:

  1. Slave连接Master:Slave节点连接到Master节点,发送SYNC或PSYNC命令
  2. Master生成RDB文件:Master执行BGSAVE命令,在后台生成RDB全量快照文件
  3. 同时记录新命令:在生成RDB期间,Master将接收到的写命令存储在复制缓冲区(repl buf)中
  4. 发送RDB文件:Master将生成的RDB文件发送给Slave
  5. 发送缓冲区命令:RDB传输完成后,Master将复制缓冲区中的命令发送给Slave
  6. Slave加载数据:Slave先加载RDB文件恢复数据库状态,然后执行复制缓冲区中的命令

增量同步是指在全量同步之后,Master只将新产生的写命令发送给Slave,而不是再次发送整个数据库内容。

工作流程:

  1. 命令传播:Master接收到写命令后,先执行命令,然后将命令写入复制缓冲区
  2. 偏移量记录:Master和Slave都维护一个复制偏移量,记录已经处理的数据量
  3. 发送命令:Master将写命令异步发送给所有Slave
  4. Slave执行命令:Slave接收到命令后执行,并更新自己的偏移量
  5. 断线重连:如果Slave断线重连,会发送PSYNC命令和自己的复制偏移量
  6. 差异命令同步:Master根据Slave的偏移量,从复制缓冲区中发送Slave缺少的命令

但是主从切换,需要人工参与,会提高人力成本。不过Redis已经有成熟的解决方案,也就是哨兵模式,可以灵活自动切换,不再需要人工介入。在下面图片中,你可以不用理会那么多的箭头,只要了解哨兵集群在监视Redis集群就好。

哨兵是什么

哨兵,顾名思义,最重要的就是放哨,即监测Redis节点故障、并且提供故障转移的能力。说白了就是一套用来检测Redis服务运行情况的程序,发现了就做一些对应处理。

这里有一点需要关注,Redis的哨兵服务,本质上也是Redis进程,只是启动参数不同,有不同的职责。

哨兵选主策略

当哨兵集群选举出哨兵Leader后,由哨兵Leader从Redis从节点中选择一个Redis节点作为主节点:

  1. 过滤故障的节点,这里的故障节点是包含了网络状态不好的结点;
  2. 选择优先级slave-priority最大的从节点作为主节点,如不存在,则继续;
  3. 选择复制偏移量最大的从节点作为主节点,如果都一样,则继续;(这里解释下,数据偏移量记录写了多少数据主服务器会把偏移量同步给从服务器,当主从的偏移量一致,则数据是完全同步。)
  4. 选择runid最小的从节点作为主节点。Redis每次启动的时候生成随机的runid作为Redis的标识。

通过增加从节点的方式,虽然一定程度解决了单点的容灾问题,但并不是尽善尽美的。由于同步延迟,Slave可能会损失掉部分数据,分布式锁可能失效,这就会发生短暂的多机获取到执行权限。

例如,如果一个客户端在主节点上获取了一个锁,但是这个锁的信息还没有同步到从节点,而此时主节点故障了,那么从节点上就没有这个锁的信息。当从节点提升为主节点时,其他客户端可能会再次获取同一资源的锁,因为从节点并不知道该锁已经被获取过了。

有没有更可靠的办法呢?

多机部署

如果对可靠性的要求高一些,可以尝试多机部署,比如Redis的RedLock,大概的思路就是多个机器,通常是奇数个,达到一半以上同意加锁才算加锁成功,这样,可靠性会向ETCD靠近。

个人理解:这里的意思是把同一份数据,放在很多个机器上,然后操作的时候,需要多很多个机器上进行相同的操作。 通常 redlock 的单机,就是n个独立的 redis 节点。

所以说实现分布式锁的话可以依靠 Redis,etcd 是另外一种方式,分别对应主动轮询和监听回调两种实现理念。

现在假设有5个Redis主节点,基本保证它们不会同时宕掉,获取锁和释放锁的过程中,客户端会执行以下操作:

1.向5个Redis申请加锁;

2.只要超过一半,也就是3个Redis返回成功,那么就是获取到了锁。如果超过一半失败,需要向每个Redis发送解锁命令;

为什么没获得锁的客户端还要发送解锁命令?

是因为这里可能有Redis 由于主从切换损失数据而错误地给该客户端发了锁,也可能是加锁成功,但是因为网络返回失败。比如有可能 5 个里面2个已经加锁成功了,虽然整体判断为加锁失败,那就得把这两个清除掉。这里一起释放算是一种统一补偿。

3.由于向5个Redis发送请求,会有一定时耗,所以锁剩余持有时间,需要减去请求时间。这个可以作为判断依据如果剩余时间已经为0,那么也是获取锁失败;

4.使用完成之后,向5个Redis发送解锁请求。

这种模式的好处在于,如果挂了2台Redis,整个集群还是可用的,给了运维更多时间来修复。另外,多说一句,单点Redis的所有手段,这种多机模式都可以使用,比如为每个节点配置哨兵模式,由于加锁是一半以上同意就成功,那么如果单个节点进行了主从切换,单个节点数据的丢失,就不会让锁失效了。这样增强了可靠性。就像这样:

1
2
3
4
5
Redis实例1: Master1 --> Slave1A, Slave1B
Redis实例2: Master2 --> Slave2A, Slave2B
Redis实例3: Master3 --> Slave3A, Slave3B
Redis实例4: Master4 --> Slave4A, Slave4B
Redis实例5: Master5 --> Slave5A, Slave5B

在这个架构中,RedLock算法使用5个主节点(Master1-5)进行分布式锁操作,每个主节点都有自己的从节点,用于容灾备份。

Go Deeper

是不是有RedLock,就一定能保证可靠的分布式锁?

先说结论:由于分布式系统中的三大困境(简称NPC),所以没有完全可靠的分布式锁。

让我们来看看RedLock在NPC下的表现:

  • N:Network Delay(网络延迟)

​ 当分布式锁获得返回包的时间过长,此时可能虽然加锁成功,但是已经时过境迁,锁可能很快过期。RedLock算了做了些考量,也就是前面所说的锁剩余持有时间,需要减去请求时间,如此一来,就可以一定程度解决网络延迟的问题。

  • P: Process Pause(进程暂停)

​ 比如发生GC,获取锁之后GC了,处于GC执行中,然后锁超时。其他锁来获取资源,这时候GC回来了,那么两个进程就获取到了同一个分布式锁。

也许你会说,在GC回来之后,可以再去查一次啊?这里有两个问题,首先你怎么知道GC回来了?这个可以在做业务之前,通过时间,进行一个粗略判断,但也是很吃场景经验的;第二,如果你判断的时候是ok的,但是判断完GC了呢?这点RedLock是无法解决的。

个人理解:

A 进程拿到了锁,然后陷入GC,程序暂停,然后锁过期了。

这时候 B进程就能去拿到锁了。

等A从 GC 中恢复的时候,就出现了A、B 两个进程都以为自己拿到了锁的情况。

owner 解决的是可能会释放别人的锁,但是这里说的问题是:AB 同一个时间段都认为自己持有锁,因此Redlock处理不了。

  • C:Clock Drift(时钟漂移)

​ 如果竞争者A,获得了RedLock,在5台分布式机器上都加上锁。为了方便分析,我们直接假设5台机器都发生了时钟漂移,锁瞬间过期了。我们想象夸张一些,漂移导致的误差已经是好几秒了,A认为还没有过期,但是实际上已经过期了,然而 B在此时获得了锁。此时A和B拿到了相同的执行权限。

根据上述的分析,可以看出,RedLock也不能扛住NPC的挑战,因此,单单从分布式锁本身出发,完全可靠是不可能的。要实现一个相对可靠的分布式锁机制,还是需要和业务的配合,业务本身要幂等可重入,这样的设计可以省却很多麻烦。

所以,Red Lock这种比较重的方案,在生产中其实用得不多,本身就不能完全可靠,业务上又基本做了幂等,没必要搞这么复杂,直接主从Redis做分布式锁得了。

事务

事务允许在单次操作中执行一组命令,确保这些命令要么全部执行,要么全不执行。

操作

1
2
Redis Transactions allow the execution of a group of commands in a single step,
they are centered around the commands MULTI, EXEC, DISCARD and WATCH.

通过官网的声明我们可以知道,Redis事务主要通过以下命令实现:

  • MULTI:标记事务开始
  • EXEC:执行事务中的所有命令
  • DISCARD:取消事务,放弃执行事务中的所有命令
  • WATCH:监视一个或多个键,如果在事务执行前这些键被修改,则事务将被打断

以下是操作实例:

非常的好理解:discard就是在事务进行的过程中执行,相当于事务的回滚操作。

watch用来提前来观察数据,具体来说,它用于监视一个(或多个)key ,如果在事务执行之前这个(或这些)key 被其他命令所改动,那么事务将被打断。个人认为这相当于乐观锁的实现。

这里给出一个watch作用的例子:

1
2
3
4
5
6
7
8
9
10
127.0.0.1:6379> watch counter
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr counter
QUEUED
# 在另一个客户端中执行以下命令
# 127.0.0.1:6379> incr counter
127.0.0.1:6379> exec
(nil)

可以看到事务执行失败了,说明watch发挥了作用。

Multi存在的问题

Redis事务存在的问题:

  1. 不支持回滚:命令执行错误不会导致事务回滚,反而会继续执行,这很让人疑惑…
  2. 有限的错误处理:只能检查命令格式错误,无法处理运行时错误
  3. 有限的原子性:如果每个key都要通过watch来监控的话,那实现的难度也太高了

这些问题在一定程度上可以通过Lua脚本来解决,因为Lua脚本执行具有更好的原子性和可控性。

通过使用Lua脚本,我们可以在Redis中实现更复杂的原子操作,弥补Redis事务机制的一些不足。

Lua脚本与Redis

Lua 是一种用标准C语言编写的轻量的脚本语言, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Redis 是2.6 版本通过内嵌支持Lua 环境。执行脚本的常用命令为EVAL。Redis因为是单线程操作,处理过程中,是不会被被打断并切换到其它处理,所以Redis执行Lua,不出异常的情况下,也不会被打断。

使用Lua脚本可以解决Redis事务的一些限制,并提供更强大的原子操作能力。

1. 使用EVAL命令执行Lua脚本

EVAL命令的基本语法:

1
EVAL script numkeys key [key ...] arg [arg ...]

参数说明:

  • script:Lua脚本内容
  • numkeys:键名参数的数量
  • key:键名参数列表,在Lua脚本中通过KEYS[index]访问
  • arg:附加参数列表,在Lua脚本中通过ARGV[index]访问

示例:

1
EVAL "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 first second

2. 使用EVALSHA命令执行已缓存的脚本

为避免每次发送完整脚本内容,可以先用SCRIPT LOAD命令将脚本加载到Redis,然后使用EVALSHA执行:

1
SCRIPT LOAD "return redis.call('GET', KEYS[1])"

Redis会返回脚本的SHA1校验和,然后可以使用EVALSHA执行:

1
EVALSHA "脚本的SHA1校验和" 1 mykey

3. 在Lua脚本中调用Redis命令

有两种方式可以在Lua脚本中调用Redis命令:

  • **redis.call()**:如果命令执行出错,会返回Lua错误

  • **redis.pcall()**:即使命令执行出错,也会继续执行脚本

4. 复杂Lua脚本示例

以下是一个原子计数器的Lua脚本示例:

1
2
3
4
5
6
7
8
9
10
11
EVAL "
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], ARGV[1])
return ARGV[1]
else
local next_value = tonumber(current) + tonumber(ARGV[1])
redis.call('SET', KEYS[1], next_value)
return next_value
end
" 1 counter 5

5. Lua脚本管理命令

  • SCRIPT LOAD:加载脚本到Redis缓存
  • SCRIPT EXISTS:检查脚本是否已加载
  • SCRIPT FLUSH:删除所有已加载的脚本
  • SCRIPT KILL:终止正在执行的脚本

接下来是实际操作:

这样的好处是,相比于原生multi事务,我们可以使用lua编写if-else这种选择逻辑,并且不需要用watch进行监控了。但是比较蛋疼的是,事务如果执行失败的话不会回滚,而只是会中断后续执行。所以所说的 Redis 通过 lua 保证他的原子性,只是说不会被其他命令打断,而不具备完备的原子性。

这也是为什么在分布式系统中,人们经常讨论最终一致性和补偿事务等概念,因为像Redis这样的系统优先考虑了性能和简单性,而不是完整的事务语义。

消息队列

消息队列是什么

消息队列(Message Queue),顾名思义就是传递消息的队列,消息队列有着先入先出的特性,消息队列一般用于异步流程、消息分发、流量削锋等问题,可以通过消息队列实现高性能、高可用、高扩展的架构。

分布式系统有不少消息队列中间件,业界比较出名的消息队列中间件有ActiveMQ、RabbitMQ、ZeroMQ、Kafka、MetaMQ、RocketMQ等,这些队列通常具备可靠性、高性能等特点。

消息队列的基本模型如下:

1
2
3
4
5
6
+-------------+       +----------------+      +-------------+
| | | | | |
| 生产者 | ----> | 消息队列 | ---> | 消费者 |
| Producer | | Message Queue | | Consumer |
| | | | | |
+-------------+ +----------------+ +-------------+

其中:

  • **生产者(Producer)**:负责产生消息并发送到队列
  • **消息队列(Queue)**:存储消息,等待被消费
  • **消费者(Consumer)**:从队列中获取并处理消息

Redis能做消息队列吗?

上面提到的消息队列,比如Kafka都是很优秀的消息队列中间件,但是其实接入维护一个消息队列中间件,还是比较繁重的事务,而且在某些场景,其实我们并不是一定需要有多可靠、多完善的消息队列,比如发用消息队列发短信,我们肯定也经常遇到过,短信没收到的场景吧?没收到重试就行了。

所以,轻量级消息队列也有了市场需要,Redis就很适合来做一个不那么完善的消息队列。

在Redis中,一般有3种方案来做一个轻量级消息队列,下面我们逐一介绍。

List

使用Redis的List数据结构是实现消息队列最简单的方式。List是一个双向链表,我们可以用LPUSH/RPUSH将消息加入队列,用RPOP/LPOP取出消息。(为了实现先入先出的队列功能,因此只能这样对应。)

基本实现原理

1
2
3
           +-----------------------+
LPUSH ---> | msg3, msg2, msg1 | ---> RPOP
+-----------------------+
  • 生产者使用LPUSH将消息推入队列左侧
  • 消费者使用RPOP从队列右侧取出消息

Redis命令示例

生产者操作

1
2
3
4
5
6
127.0.0.1:6379> LPUSH myqueue "message 1"
(integer) 1
127.0.0.1:6379> LPUSH myqueue "message 2"
(integer) 2
127.0.0.1:6379> LPUSH myqueue "message 3"
(integer) 3

消费者操作

1
2
3
4
5
6
7
8
127.0.0.1:6379> RPOP myqueue
"message 1"
127.0.0.1:6379> RPOP myqueue
"message 2"
127.0.0.1:6379> RPOP myqueue
"message 3"
127.0.0.1:6379> RPOP myqueue
(nil)

阻塞式操作

这样能实现基础功能,但有个问题,消费者无法知道LPOP的时机,只能不停按时间间隔轮询,这对消费者而言是一个负担,所以Redis也提供了阻塞版的POP命令:BRPOP,BLPOP。当队列为空时,消费者将被阻塞直到有新的消息到来或超时。

1
2
3
4
5
6
7
8
127.0.0.1:6379> BRPOP myqueue 5  # 等待5秒
1) "myqueue"
2) "message 1"

# 如果5秒内没有消息,将返回nil
127.0.0.1:6379> BRPOP myqueue 5
(nil)
(5.04s)

这样虽然处理了阻塞式操作的问题,但是依然没有解决消息发送失败的问题,即所谓的ACK机制:消费成功了,进行 ACK之后,才会到下一条消息。现在没有ACK,也就是说现在在消费者取消息之后,消息就出队列了,如果消费失败,消息还得想办法放回去。

同时,LIst也不支持多人消费。

这里继续做下去的话,有几种方式:

  1. 先用LRANGE读队列信息,消费完成之后,再POP。但这样消息可能被多个消费者消费,没办法实现一个消费组的逻辑。

  2. POP之后,扔到另一个队列,消费确认了,就删除该信息,但如果是失败情况,那就将数据放置回队头,这就需要用lua来做原子性,但是这样业务开发实属复杂。

1
2
3
4
5
6
7
8
9
10
11
# 1. 消费者使用BRPOPLPUSH命令,将消息从工作队列弹出并同时推入备份队列
# 2. 消息处理成功后,从备份队列中删除该消息
# 3. 另一个进程定期检查备份队列中长时间未处理的消息,重新放入工作队列
#####################
# 消费者
127.0.0.1:6379> BRPOPLPUSH work_queue backup_queue 5
"task data"

# 处理成功后从备份队列删除
127.0.0.1:6379> LREM backup_queue 1 "task data"
(integer) 1

Pub/Sub生产订阅模式

Redis的Pub/Sub(发布/订阅)模式是另一种实现消息队列的方式,它基于”频道”的概念,生产者将消息发布到一个或多个频道,消费者订阅感兴趣的频道接收消息。

基本实现原理

  • 发布者通过PUBLISH命令将消息发送到特定频道
  • 订阅者通过SUBSCRIBE命令订阅一个或多个频道
  • 当发布者发布消息时,所有订阅该频道的订阅者都会收到消息

Redis命令示例

发布者操作

1
2
127.0.0.1:6379> PUBLISH news "Breaking news: Redis is awesome!"
(integer) 2 # 2个客户端接收到消息

订阅者操作

1
2
3
4
5
6
7
8
127.0.0.1:6379> SUBSCRIBE news
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "news"
3) (integer) 1
1) "message"
2) "news"
3) "Breaking news: Redis is awesome!"

模式匹配订阅

Redis还支持使用PSUBSCRIBE进行模式匹配订阅,允许订阅者使用通配符订阅多个频道:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
127.0.0.1:6379> psubscribe okr.*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "okr.*"
3) (integer) 1
1) "pmessage"
2) "okr.*"
3) "okr.1"
4) "cat"
1) "pmessage"
2) "okr.*"
3) "okr.r"
4) "Breaking news: Redis is awesome!"

#另一终端:
127.0.0.1:6379> publish okr.1 cat
(integer) 1
127.0.0.1:6379> PUBLISH okr.r "Breaking news: Redis is awesome!"
(integer) 1

PUB/SUB来实现消息队列,也有两个不足:

  1. 没有ACK功能

  2. 不支持持久化,Redis重启消息会全部丢失,所以PUB/SUB比较适合处理不那么重要的消息

Stream

Redis 5.0引入的Stream是专门为消息队列设计的数据类型,它结合了发布/订阅模式和传统消息队列的优点,并且解决了上述两种方案的一些缺点。可以说基本的消息队列能力它都具备了。

由于校招也不问,这里我给一个资料的指路,感兴趣可以看下。

总结

个人认为Redis提供的队列都不是成熟的消息队列,原则上是不推荐的,但是如果真的场景够轻,同时又不想引入消息队列这么重的技术栈,是可以使用Stream做消息队列的,它是Redis中队列场景最为成熟的解决方案。

限流器

限流器是什么

限流器 (Rate Limiter) 顾名思义,是用来限制流量的。在全端的世界中,限流器可能是用来限制来自客户端的请求,或者服务对其他服务发送请求时,也可以用限流器来限流。举例来说,可以从 IP 的角度,限制每个 IP 每天的请求数;或者以使用者为维度,来限每个使用者在某段时间的请求数量。

限流器的好处:

  • 一来可以阻挡像是 DoS (Denial of Service) 攻击,避免服务器过载;
  • 二来可以降低成本,特别是如果你有用第三方 API,流量大起来帐单费也会变很可观。

限流器多半不会在客户端实作,因为请求在客户端比较容易被伪造;所以一般可能会放在服务器端,或者是放在 API Gateway;或者像是 Upstash 的这个开源套件,是放在 Edge 上。

限流有几种策略,一般常见做法包含:

  • 计数器算法(Fixed Window)
  • 滑动窗口(Sliding Window)
  • 漏桶(Leaky Bucket)
  • 令牌桶(Token Bucket)

计数器算法

计数器算法(Fixed Window Counter)是最简单直观的限流算法。

原理也很简单:在固定的时间窗口内,维护一个计数器,每当有请求到来时,计数器加1,当计数器达到预设阈值时,后续请求被拒绝,直到时间窗口重置。

在具体实现中,如果是单机限流,可以用一个整数+时间戳记录。即用两个变量,一个是时间戳(未来某个时间点),一个记录访问量,通过拦截器的方式,根据不同场景做判断:

1.超出记录时间且访问量未超出,更新记录时间;

2.在记录时间点内,访问量未超出,访问量加1;

3.记录时间点内,访问量超出,拒绝请求。

分布式限流的话,一般使用Redis来做,可以利用 Redis 天然的过期时间,到了设定时间就自动过期。同时,记得要使用 LUA 脚本来保证 redis 的原子操作,即保证“检查请求次数是否达到阈值”与“次数+1”两步操作的原子性。

Redis实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 计数器限流器 Lua脚本
local key = KEYS[1] -- 限流器key,通常与用户标识或API相关
local limit = tonumber(ARGV[1]) -- 限流阈值
local expire_time = tonumber(ARGV[2]) -- 时间窗口(秒)

local current = redis.call('get', key)
if current and tonumber(current) >= limit then
-- 超过限流阈值,拒绝请求
return 0
else
-- 未超过阈值,计数器加1
redis.call('incr', key)
-- 设置过期时间,确保时间窗口结束后自动重置
redis.call('expire', key, expire_time)
return 1
end

优缺点

  • 优点:实现简单,易于理解,内存占用小
  • 缺点:存在临界问题。例如,在一分钟的前30秒和后30秒各发送限制数量的请求,实际上在一分钟内总请求数是限制的两倍,但由于跨越了两个时间窗口,不会被限流。这个问题叫做请求突刺

为了解决这个问题,人们又发明了滑动窗口算法(Sliding Window)。

滑动窗口算法

滑动窗口算法是计数器算法的改进版,通过滑动时间窗口解决了计数器算法的临界问题。

相比于计数器短发的固定窗口值,滑动窗口将时间划分为更小的区间,并且随着时间的推移,窗口不断向前滑动,只统计窗口内的请求数量。

举个例子:

如果是普通窗口,时间区间就是 1:00-2:00,2:00-3:00 这样。在 2:00 前后请求量很高的话,那就会造成突刺,突破上限。

而滑动窗口始终是基于当前,如果现在是2:00,那窗口就是1:00-2:00;如果是2:15,窗口就是1:15-2:15。

如果你在2点前几乎怼满了一波,那你必须得等当前时间滑得够远,你才能怼第二波,这就解决了突刺。

Redis实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 滑动窗口限流器 Lua脚本
local key = KEYS[1] -- 限流器key
local limit = tonumber(ARGV[1]) -- 限流阈值
local window_size = tonumber(ARGV[2]) -- 时间窗口大小(秒)
local current_time = tonumber(ARGV[3]) -- 当前时间戳

-- 移除窗口外的所有数据
redis.call('zremrangebyscore', key, 0, current_time - window_size)

-- 获取窗口内的请求数
local count = redis.call('zcard', key)

if count >= limit then
-- 超过限流阈值,拒绝请求
return 0
else
-- 记录本次请求
redis.call('zadd', key, current_time, current_time .. ":" .. math.random())
-- 设置过期时间
redis.call('expire', key, window_size)
return 1
end

使用示例:

1
2
3
4
5
6
# 获取当前时间戳
$ current_time=$(date +%s)

# 允许访问
127.0.0.1:6379> EVAL "local key = KEYS[1]; local limit = tonumber(ARGV[1]); local window_size = tonumber(ARGV[2]); local current_time = tonumber(ARGV[3]); redis.call('zremrangebyscore', key, 0, current_time - window_size); local count = redis.call('zcard', key); if count >= limit then return 0 else redis.call('zadd', key, current_time, current_time .. ':' .. math.random()); redis.call('expire', key, window_size); return 1 end" 1 user:123:api:login 100 60 $current_time
(integer) 1

实际上滑动窗口的最大优势是:滑动的本质是基于当前的物理时间看窗口请求量,而不仅仅是根据所谓窗口的精确度决定。因此,滑动窗口一定可以解决计数器算法解决不了的突刺超额问题。

从复杂度来讲,滑动窗口虽说比计数器难一些,但也属于一个量级。所以,如果实际开发中在计数器和滑动窗口上选型,那么不用犹豫,选滑动窗口吧。

到此为止介绍的都是计数类算法,下面开始给大家介绍 Traffic Shaping 类的解决方式,我们先来看漏桶算法(Leaky Bucket)。

漏桶算法

漏桶算法(Leaky Bucket)以恒定的速率处理请求,多余的请求会被存储在桶中或被丢弃。

原理是:把请求比作水滴,漏桶以固定速率漏水(处理请求)。当水滴流入速度过快,超过桶容量时,多余的水滴会溢出(请求被拒绝)。

优缺点

  • 优点:处理速率恒定,流量平滑,避免了突发请求对系统的冲击
  • 缺点:不支持突发流量,即使系统处于空闲状态,也不允许突发请求;这也就意味着漏桶无法充分利用满性能资源,还是非常蛋疼的。

怎么优化呢?一般有两种解决方案:

  1. 动态调节水滴速度(就像输液瓶一样):通过对后端进行不断试探,尽可能始终维持在性能处理的极致,这种方式的弊端在于带来了更多的复杂性和耦合性,属于特定优化了。

  2. 用另一种桶,令牌桶(Token Bucket)。

令牌桶算法

一言以蔽之:令牌桶算法以恒定速率向桶中添加令牌,请求需要消耗令牌才能被处理,当桶中没有令牌时,请求被拒绝。

举个例子:

我们桶里面能放1000个令牌,1ms产生一个令牌,那1s就可以装满这个桶,请求要处理,先从桶里拿令牌,这样1s就能处理1000次,令牌桶算法很像生产消费模式,同时出口流量也很稳定。

令牌桶算法能够在请求量小的时候,积累令牌,这种模式在限制数据的平均传输速率的同时还允许某种程度的突发传输。

Redis实现示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- 令牌桶限流器 Lua脚本
local key = KEYS[1] -- 限流器key
local capacity = tonumber(ARGV[1]) -- 桶容量
local rate = tonumber(ARGV[2]) -- 令牌放入速率(令牌/秒)
local now = tonumber(ARGV[3]) -- 当前时间戳

-- 获取上次添加令牌的时间和当前令牌数
local last_token_time = tonumber(redis.call('hget', key, 'last_token_time') or now)
local tokens = tonumber(redis.call('hget', key, 'tokens') or capacity)

-- 计算从上次添加到现在应该新增的令牌数
local new_tokens = math.min(capacity, tokens + math.floor((now - last_token_time) * rate))

if new_tokens < 1 then
-- 没有足够令牌,拒绝请求
return 0
else
-- 有足够令牌,消耗一个令牌并更新
new_tokens = new_tokens - 1
redis.call('hset', key, 'last_token_time', now)
redis.call('hset', key, 'tokens', new_tokens)
redis.call('expire', key, 60) -- 设置过期时间
return 1
end

四种算法对比

算法 应对突发流量能力 实现复杂度 内存占用 适用场景 均匀度
计数器 简单 简单场景,对精确度要求不高 不均匀
滑动窗口 中等 中等 需要较精确控制请求频率的场景 不均匀
漏桶 中等 需要稳定输出的场景 强均匀
令牌桶 较复杂 允许突发流量但需要限制总体速率的场景 均匀

生产中怎么选

在实际生产中,令牌桶因为其均匀性及突发流量容忍性,更受青睐。腾讯云团队,阿里线上管控体系,Shopee 金融团队,都使用了令牌桶来做限流。

而唯一可以和令牌桶 battle 的漏桶,漏桶没有针对突发流量的处理,严格限制,个人感觉这不是缺陷而是特性,并不是每个场景,都需要支持突发流量的,如果要很严格限定流量,漏桶会是最好的选择。

至于计数器和滑动窗口算法,优势就是简单,但限流算法实际都不复杂,所以这个优势就很不明显了,生产上不建议使用。

限流是开发领域一个非常重要的话题,毕竟流控做好了,才不容易过载,才能有个稳定的系统。

秒杀(超简版)

什么是秒杀(真的有人不知道吗🥲

秒杀通常指因为某种活动瞬时产生巨大流量的场景,比如双十一0点抢10000个苹果折扣手机,这种活动通常会吸引几十万甚至数百万人参与,而且大家都盯着0点,等0点一到就是海量的请求。

那么秒杀场景下要解决的问题有哪些呢?

1.高并发的海量请求

无需多言。

2.不能超卖

因为秒杀有时候就是赔本赚吆喝,价格可能比成本价还低。而这时候要是比原计划的数量卖多了,那到底发不发货呢?

发货会超预算亏损,要是超卖数量过多,说不定厂子都要倒闭了;不发货会被投诉,影响商家声誉。

不管怎样,都是硬伤,只能找程序员赔钱了(大雾

3.避免少卖

少卖会比超卖好一些,商家不存在经济上的损失。但要是被眼尖的消费者发现的话,也是免不了一场麻烦的。所以我们还是要尽可能避免这种情况。

4.防作弊/脚本/黄牛

黄牛可能是开脚本,一次发很多请求过来,抢到之后再转卖。但我们做活动,希望的就是回馈客户,进而吸引用户,而不是去让黄牛赚外快。因此,我们要尽量挡住黄牛的魔爪。

通常来说,为了打击黄牛,最常见的方式是限购,一个用户最多只能抢到N份,这样可以大大保障正常用户的权益。

具体怎么做呢,为了性能,我们还是将限制逻辑加入到Redis中,所以我们的Lua脚本中,原本是第一步查询库存,第二步扣减库存,需要优化为第一步查询库存,第二步查询用户已购买个数,第三步扣减库存,第四步记录用户购买数

这里需要注意的是,如果使用Redis集群,那么Redis的数据分片,需要根据用户来分Key,不然用户数据会查询不到。

当秒杀脚本需要同时操作库存键和用户购买记录键时,如果这两个键被分配到不同的节点,脚本就无法原子性地执行,因为Redis集群不支持跨节点的事务和Lua脚本执行。

例如,假设有这些键:

  • product:1001:stock (商品库存)
  • user:123:purchased:1001 (用户购买记录)

如果这两个键被哈希到不同的节点,就会导致脚本执行失败或查询不到用户数据。

这就是HashTag(哈希标签)的用处:通过HashTag可以强制多个键被分配到同一个节点。在Redis中,只有花括号 {} 内的部分会被用来计算哈希值。

因此,应该这样设计键:

  • {user:123}:product:1001:stock
  • {user:123}:purchased:1001

这样,无论是库存键还是购买记录键,都会根据同一个用户ID(user:123)进行分片,确保相关数据在同一个节点上,从而保证Lua脚本的原子执行。

有了限购,我们可以保证货品不会被黄牛占据太多,那么还剩一个问题,黄牛大多是通过代码来抢购,点击速度比人点击快得多,这样就导致了竞争不公平。

怎么解决呢?某个用户请求接口次数过于频繁,一般说明是用脚本在跑,可以只针对该用户做限制。

针对IP做限制也是常见做的做法,但这样容易误杀,主要考虑到使用同一个网络的用户,可能都是一个出口IP。限制IP,会导致正常用户也受到影响。

更好用的方案是加上一个验证码验证。验证码符合91原则(即90%输入验证码和10%点击业务操作的时间),90%的时间都用在验证码输入上,所以使用脚本点击的影响会降到很低。

当然,我们要明白没有银弹,这种方式缺点在于降低了用户的体验感。

黑话大学习

银色子弹(silver bullet) 是一种由白银制成的子弹,有时也被称为银弹。在西方的宗教信仰和传说中作为一种武器,传说能专门和狼人、吸血鬼一些怪物对抗的利器。银色子弹也可用于比喻强而有力、一劳永逸地适应各种场合的解决方案,英语中“No silver bullet”即意为“没有灵丹妙药”。

怎么高并发

我们可以先将库存名额预加载到Redis,然后在Redis中进行扣减,扣减成功的再通过消息队列,传递到MySQL做真正的订单生成。

我们说回Redis,如果请求量超过6W每秒,就要考虑使用多个Redis来分流。预计有100W请求量,我们就可以临时调度20个Redis实例来支持,一个5W/s,留点Buffer。

这种模式倒是不需要使用Redis Cluster的做法,直接接个Nginx负载均衡就可以了。

拒绝超卖

我们抢购场景最核心的,有两个步骤:

  • 第一步,判断库存名额是否充足;
  • 第二步,减少库存名额,扣减成功就是抢到。

显然两步是原子性操作,那这里就要用到lua脚本。

有了lua加持,让我们再分析一下可能发生的异常情况:

1.正常业务错误,比如库存用完,这种情况符合预期,直接返回给用户即可。

2.访问Redis错误,这种情况返回给用户,让其重试即可。

3.客户端访问Redis超时(由于网络延迟等问题),这种情况下,其实redis内可能库存已经扣减成功,此时不用再重试,免得产生更多的无效扣减,虽然多了一次扣减,但是总数是不变的,只会少卖不会多卖。

避免少卖

少卖什么情况会出现呢?库存减少了,但用户订单没生成。

什么情况会这样呢?有几种可能:

1.上面提到的,减少库存操作超时,但实际是成功的,因为超时并不会进入生成订单流程;

2.在Redis操作成功,但是向Kafka发送消息失败,这种情况也会白白消耗Redis中的库存。

所以说白了,我们只需要保证Redis库存+Kafka消费的最终一致性。但是一致性问题,一直是分布式场景的恶龙,要对付并不容易:

  • 第一种,也最简单的方式,在投递Kafka失败的情况下,增加渐进式重试;

为什么是渐进式重试而不是固定间隔重试呢?

一般失败的话后面其实大概率也是失败,这种为了避免性能损耗拉长时间的作法还是蛮多的。比如说微信支付回调中:

  • 第二种,更安全一点,就是在第一种的基础上,将这条超时消息记录在磁盘上,慢慢重试;
  • 第三种,写磁盘之前就可能失败,可以考虑走WAL路线,但是这样做下去说不定就做成MySQL的undo log/redo log这种WAL技术了,会相当复杂,没有必要。

针对少卖这种极端场景可接受的问题,一般选择第二种方式即可,毕竟是异常情况的小概率事件,真出问题了大不了人工介入。

Redis角色

Redis扮演扣减库存的角色,这个主要源自Redis比关系型存储高很多的处理性能。实际上,除了扣件库存,Redis有时候也可以扮演队列的角色,请求过来先记录在Redis,虽然不如传统消息队列可靠,但胜在轻量。

总结

总得来说,秒杀通过redis和kafka作为数据库的缓冲。redis负责简单的查询和删减库存操作,成功后发送给kafka。为保证操作原子性,这两步需要lua脚本实现。如果与kafka通信失败,会采用渐进式重试或写入磁盘的方式重试。