Redis支撑秒杀场景

前言

秒杀是一个非常典型的活动场景。比如双11、618等电商活动中,都会有秒杀的场景。秒杀场景的业务特点是限时限量,业务系统要处理瞬时的大量高并发操作,而Redis就经常被用来支撑秒杀活动。

秒杀活动对系统的要求

瞬时并发访问量非常高

一般传统数据库每秒只能支撑千级别的并发请求,而Redis的并发处理能力可以达到万级别。所以,当大量请求涌入系统时,先使用Redis拦截大部分请求,就可以避免大量请求发送到数据库,把数据库冲垮。

读多写少

在秒杀场景中,需要先检查商品是否还有库存,只有库存有余量时,系统才能进行库存扣减和下单操作。

库存检查操作是典型的键值对查询,Redis对键值对查询的高效支持,正好和这个操作的要求相匹配。

因为在秒杀场景中,商品库存有限,实际可以下单的用户比较少。所以查询库存(读)的操作要远多于扣减库存(写)的操作。

基于原子操作支撑秒杀场景

在秒杀场景中,我们经常用两个信息来表示商品的剩余库存,分别是商品的总库存和商品已秒杀数量。所以我们可以使用Hash类型的键来保存库存信息,如:

key = itemID
value = { total: 10, orderedNum: 3 }

因为库存检查和库存扣减这两个操作要保证一起执行,需要用Redis原子操作。

原子操作可以是Redis自身提供的命令(如hincrby),也可以是Lua脚本。因为库存检查和库存扣减是两个不同的操作命令,无法使用单一的命令来完成。所以我们需要使用Lua脚本来原子性的执行这个两操作。

那么,如何使用Lua脚本来执行这两个操作呢?

第一步,我们先创建Lua脚本文件,如下:

-- 获取商品库存信息
local info = redis.call("HMGET", KEYS[1], "total", "orderedNum")

-- 商品总库存
local total = tonumber(info[1])

-- 商品已秒杀数量
local orderedNum = tonumber(info[2] or 0)

-- 当次下单的数量
local count = tonumber(ARGV[1])

-- 判断是否还有剩余库存
if (orderedNum + count <= total)
then
    return redis.call('hincrby', KEYS[1], "orderedNum", count)
end

return 0

第二步,在客户端使用EVAL命令来执行这个脚本。

当客户端检查到脚本的返回值不为零时,就表示秒杀成功了。

下面NodeJS客户端实现:

const fs = require('fs');
const redis = require('redis');

redis.createClient().connect()
  .then(async (client) => {
    // 为了方便,直接在这里加载脚本。实际使用时可以在Redis客户端中执行该命令,将返回的摘要值注入到业务逻辑中
    const sha1 = await client.scriptLoad(fs.readFileSync('./demo1.lua'));

    const orderedNum = await client.evalSha(sha1, {
      keys: ['product:10086'],
      arguments: ['2'],
    });
    
    if (orderedNum > 0) {
      console.log('success!!!');
    } else {
      console.log('failed!!!')
    }

    client.disconnect();
  });

值得注意的是,上面客户端代码中使用的evalsha命令,而不是eval命令。因为使用eval命令执行脚本时,每次都需要把文件内容发送到Redis,会增加网络开销。所以建议用script load命令先把Lua脚本加载到Redis中,然后得到脚本的唯一摘要值,再通过evalsha命令来执行脚本。

基于分布式锁支撑秒杀场景

使用分布式锁的思路是,客户端先向Redis申请分布式锁,只有成功拿到锁的客户端才能执行库存的校验和扣减。这样大量的秒杀请求就会在竞争分布式锁时被过滤掉。

分布式锁实现

set product:10086 clientID nx px 1500

其中,product:itemID表示某个商品的操作锁。nx表示如果锁不存在才设置。px 1500则表示锁会在1.5s后过期,以免客户端在此期间发生异常而无法释放锁。

因为在加锁时,每个客户端都是用了一个唯一的标识符clientID作为锁变量的值,所以在释放锁操作时,我们需要判断变量的值,是否等于执行释放操作的客户端的唯一标识。要注意的是,读取锁变量的值和释放锁是两个不同的操作命令,因此也需要使用Lua脚本的方式来确保命令执行的原子性。

为什么释放锁的时候要判断是否为正在执行释放操作的客户端呢?

考虑这么一种情况,假如客户端执行数据操作后,执行释放锁操作的命令。该命令可能在网络中的某个节点上滞留,导致Redis没有移除锁变量。若此时恰好锁失效了,新的客户端就可以持有锁。当滞后的释放命令到达Redis时,如果不通过唯一ID来识别执行释放操作的客户端,就会误释放掉新的客户端持有的锁,该锁又可以给下一个客户端持有,就会导致商品出现超卖的情况。

如果我们只用单实例Redis来保存锁变量,当这个Redis实例发生故障宕机(比如在主节点将锁变量同步到从节点前发生了故障),那么锁变量就没有了。因此我们需要基于多个Redis节点来实现高可靠的分布式锁。

为了避免Redis实例出现故障而导致的锁无法正常工作的问题,Redis的开发者提出了分布式锁的算法Redlock。

Redlock的算法思路是,让客户端和多个Redis实例请求加锁,只有在半数以上的实例成功加锁时,我们才认为客户端加锁成功,否则加锁失败。具体的执行步骤:

  1. 客户端获取当前时间。
  2. 客户端依次向N个Redis实例执行加锁操作
  3. 一旦客户端完成了和所有Redis实例的加锁操作,客户端就要计算整个加锁过的总耗时。

客户端只有在满足下面两个条件时,才认为是加锁成功:

  • 超过半数的Redis实例上成功加锁
  • 客户端获取锁的耗时没有超过锁的有效时间

在满足这个两个条件之后,需要重新计算锁的有效时间(有效时间=锁的最初有效时间-客户端获取锁的总耗时)。如果在锁的有效时间来不及完成共享数据的操作,我们可以释放锁或者通过设置“看门狗的方式”对锁进行续期,以免出现还没完成数据操作,锁就失效的情况。

最后,我们还可以使用切片集群中的不同实例来分别保存分布式锁和商品库存信息。使用这种方式保存后,秒杀请求首先会访问保存分布式锁的实例。如果客户端没有成功拿到锁,就不会去查询商品库存,这样就可以减轻库存信息的实例压力了。

两种方式对比

方案优点缺点
原子操作实现简单不适用于复杂数据操作逻辑,脚本执行时间太长会阻塞Redis实例
分布式锁可以适用于任何需要并发控制的场景实现复杂,需要考虑多种异常情况(比如锁的错误释放、Redis实例故障、锁续期等)
关于我
loading