[面试][资料库]如何解决高并发情境的商品秒杀问题

如果今天有上万人在同一时间抢限量商品,昨天分享的方案基本撑不住。

不过面对这个情境,Redis 表示终於轮到我了!今天这篇文章会以 Node.js + Redis 为范例,带读者一起解决这个问题。

大纲

  1. 如何解决高并发情境的商品秒杀问题

    • 1.1 面试官为什麽会问?
    • 1.2 面试官想从答案确认什麽?
    • 1.3 笔者提供的简答
  2. 回答问题所需具备的知识

    • 2.1 先了解 Redis 的基础知识
    • 2.2 使用 Node.js + Redis 解决高并发秒杀问题
    • 2.3 模拟秒杀情境确认商品没有超卖
  3. 衍伸问题

    • 3.1 如果有数万用户同时查看 TOP100 的点数排行榜,资料库如何设计?
    • 3.2 Redis 跟 Memcached 的差异

1. 如何解决高并发商品秒杀问题

1.1 面试官为什麽会问?

这算是资料库的常见面试题,除了金融、电商喜欢考这题外;社交平台、新创公司、游戏产业也会换个形式考类似的题目。

因为这算是职缺所需具备的技术,如果求职者没有相关经验可能就会止步於这个关卡。

如果有去以上产业的打算,最好有一定的 NoSQL(ex:Redis、MongoDB) 基础再去面试;因为在面试官的眼中有基础跟完全不会的差距很大,求职者可以没有实务经验,但明知产业会碰到这些技术还不去学习就是态度问题了。


1.2 面试官想从答案确认什麽?

  • 能提出高并发情境商品秒杀的解决方案
  • 如果解决方案为 NoSQL,对它的认知有多少
  • 是否接触过同类型的 NoSQL

1.3 笔者提供的简答

通常这类型的秒杀活动都有固定档期,我会先将活动用的商品及库存数量同步到 Redis 资料库;为了避免超卖,在 Client 端下单时会执行 Lua 脚本,当秒杀到指定数量或是时间结束後就不再接受请求,活动结束後将 Redis 的资料同步到关联式资料库


2. 回答问题所需具备的知识

2.1 先了解 Redis 的基础知识

如果有短时间大量访问、需要性能提升的需求,往往会先想到 Redis 这个记忆体资料库。

  • Redis 为什麽快?

    • 记忆体资料库
      记忆体读写本来就快。
    • 单执行绪(single-threaded)
      如果使用多执行绪会耗费时间在上下文的切换以及加锁上面;而单执行绪会依照请求顺序执行,不需考虑同步以及加锁带来的效能问题。
      Redis 是基於记忆体操作,所以效能的瓶颈不在 CPU 而在记忆体、网路频宽;因为 CPU 不是瓶颈,所以可以开多个 Redis 建立 Cluster 分散压力。
    • I/O 多路复用(multiplexing)
      Redis 使用了 epoll IO 多路复用,可以用一条执行绪处理并发的网路请求。

      这个有点抽象,我用缴交作业为情境说明
      假设你是一个老师,采用多路复用的原则批改作业,那你批改的顺序就是看谁先缴交作业,而不是按照学号顺序批改(此作法中间有人没交作业就会卡住);这样就能避免大量无用操作,为非阻塞模式的实现。

  • Redis 资料保存方案
    可以使用 RDB 、AOF 来做持久化。

    • RDB 持久化(Redis Database)
      在指定的时间间隔内将资料 dump 到硬碟;因为是定期操作,如果 Redis 当机会遗失部分资料,此方案适合大规模资料恢复
    • AOF 持久化(Append Only File)
      这个方案可以完整纪录所有资料的变化,因为采用日志追加的方式,所以就算当机也不会影响已经储存的日志,灾难复原的完成度高;缺点是档案比 RDB 大、大规模资料恢复速度较 RDB 慢
  • Redis 资料淘汰机制
    Redis 主要保存的都是热点资讯,在储存资料有限的状态下(记忆体不足,无法写入新资料);就要合理的设定淘汰机制:

    • 选择性移除有设定过期时间的资料
      • volatile-lru:挑选最近较少使用的资料淘汰。
      • volatile-ttl:挑选即将过期的资料淘汰。
      • volatile-random:挑选随机资料淘汰。
    • allkeys-lru:挑选最近较少使用的资料淘汰。
    • allkeys-random:挑选随机资料淘汰。
    • no-enviction:禁止淘汰;若选用这个设定,当数据到达 maxmemory 时会回传 OOM 错误。

    原则上淘汰策略以「volatile-lru、allkeys-lru」为主(淘汰较少使用的资料)。


2.2 使用 Node.js + Redis 解决高并发秒杀问题

  • 目标

    • 产生秒杀商品基础资讯(商品名称、库存)
    • 模拟秒杀情境,确认是否会超卖
    • 确认有保存购买人资讯
  • 使用技术

    • Redis
      本篇文章使用 Redis 的 Hash、List type 来储存资讯。

      如果完全没有相关基础,建议先参考这篇文章来安装 Redis 以及它的 GUI 工具。

    • Lua 脚本
      为了避免超卖,这里采用 Lua 脚本;在 Redis Server 执行 EVAL 指令时,在结果回传前只会执行当下 Lua 脚本的逻辑,其他 Client 端的命令须等待直到 EVAL 执行完为止。

      Lua 脚本的逻辑应尽量简单以保证执行效率,否则会影响 Client 端的体验。

  • 程序架构

    • 主程序:redisSecKill.js

      • 先启用 Redis Client 端(如未安装相关套件,请在专案资料夹下输入 yarn add ioredis)。
      • prepare 函式,以 Hash type 建立参加秒杀的产品库存。
      • secKill 函式模拟使用者购买行为,缓存并执行 Lua 脚本。
      const fs = require("fs");
      const Redis = require("ioredis");
      const redis = new Redis({
        host: "127.0.0.1",
        port: 6379,
        password: "",
      });
      
      redis.on("error", function (error) {
        console.error(error);
      });
      
      async function prepare(item_name) {
        // 参加秒杀活动的商品库存
        await redis.hmset(item_name, "Total", 100, "Booked", 0);
      }
      
      const secKillScript = fs.readFileSync("./secKill.lua");
      
      async function secKill(item_name, user_name) {
        // 1. 缓存脚本取得 sha1 值
        const sha1 = await redis.script("load", secKillScript);
        // console.log(sha1);
      
        // 2. 透过 evalsha 执行脚本
        // redis Evalsha 命令基本语法如下
        // EVALSHA sha1 numkeys key [key ...] arg [arg ...]
        redis.evalsha(sha1, 1, item_name, 1, "order_list", user_name);
      }
      
      function main() {
        console.time("secKill");
        const item_name = "item_name";
        prepare(item_name);
        for (var i = 1; i < 10000; i++) {
          const user_name = "baobao" + i;
          secKill(item_name, user_name);
        }
        console.timeEnd("secKill");
      }
      main();
      
    • Lua 脚本:secKill.lua
      在 Lua 脚本执行逻辑:「确认下单数量」➜「取得商品库存」➜「如果库存足够就下单」➜「储存购买者资讯(List type)」。

      local item_name = KEYS[1]
      local n = tonumber(ARGV[1])
      local order_list = ARGV[2]
      local user_name = ARGV[3]
      if not n  or n == 0 then
        return 0
      end
      local vals = redis.call("HMGET", item_name, "Total", "Booked");
      local total = tonumber(vals[1])
      local booked = tonumber(vals[2])
      if not total or not booked then
        return 0
      end
      if booked + n <= total then
        redis.call("HINCRBY", item_name, "Booked", n)
        redis.call("LPUSH", order_list, user_name)
        return n
      end
      return 0
      

2.3 模拟秒杀情境确认商品没有超卖

  • SETP 1:在专案资料夹下输入 node redisSecKill.js 模拟秒杀
    https://ithelp.ithome.com.tw/upload/images/20210924/20103256NkIEkkJbW7.png
  • SETP 2:待执行完後,用 Redis GUI 程序确认商品没有超卖
    https://ithelp.ithome.com.tw/upload/images/20210924/20103256ccyhUz1LOU.png
  • SETP 3:确认订单都有对应的买家
    https://ithelp.ithome.com.tw/upload/images/20210924/20103256viebw6SuEc.png

补充
文章程序只是 MVP,现实状况还有很多要设计的:

  • 哪些商品被列为秒杀,如何纪录。
  • 设定秒杀开始、结束时间。
  • 如何将 Redis 资料存入关联式资料库中。

3. 衍伸问题

3.1 如果有数万用户同时查看 TOP100 的点数排行榜,资料库如何设计?

考点:对 Redis Zset 这个资料型态的认知与应用

我会使用 Redis 这个记忆体资料库,建立一个有序集合(Zset)来储存用户的资讯。

在设计上,用户(member)是唯一值,且每个用户都会关联一个点数(score),这样用户就可以按照分数来排序。

功能实现上会透过 zrevrange Leaderboard 0 99 withscores 这段指令来显示 TOP100 的点数排行榜。

  • zrevrange:依照点数(score)检视排行榜
  • Leaderboard:排行榜名称(可以自行定义)
  • 0 99:TOP100 的意思
  • withscores:连点数(score)一起显示

3.2 Redis 跟 Memcached 的差异

考点:是否了解过同类型的技术以及差异

  • Memcached 只支援简单的资料型态,而 Redis 支援多种资料型态(String、Hash、List、Set、Zset、Stream)。
  • Memcached 不支援资料持久化,而 Redis 提供 RDB 跟 AOF 两种方案
  • Memcached 没有主从式架构,而 Redis 支援主从式架构与读写分离

同样身为记忆体资料库,Memcached 提供简单的使用方式,而 Redis 提供丰富的功能。


感谢大家的阅读,如果喜欢我的文章可以订阅接收通知;如果有帮助到你,按Like可以让我更有写文的动力,我们明天见~

参考资源

  1. 手把手带你在 MacOS 安装 Redis &Another Redis Desktop Manager(笔者部落格)
  2. 用 Node.js + Redis 解决高并发秒杀问题(笔者部落格)
  3. redis 之 sorted sets 类型及操作
  4. 使用 redis 的有序集合实现排行榜功能

我在 Medium 平台 也分享了许多技术文章
❝ 主题涵盖「MIS & DEVOPS资料库前端後端MICROSFT 365GOOGLE 云端应用自我修炼」希望可以帮助遇到相同问题、想自我成长的人。❞


<<:  Day 20 Flask Session

>>:  Day27 go-elasticsearch(一)

22.MYSQL NOT BETWEEN AND指令

除了有BETWEEN AND 之外,还有NOT BETWEEN AND (NOT BETWEEN A...

[Day28] Flutter with GetX Socket.io

Socket.io 注意server side需要使用3.0.3版本 否则flutter clien...

[Day 19] Leetcode 1137. N-th Tribonacci Number (C++)

前言 September LeetCoding Challenge 2021今天的题目是1137. ...

[2021铁人赛 Day12] General Skills 09

引言 昨天的题目学习到进位制以及「 ASCII code <-> 字元」转换, 关於 ...

Angular-介绍(Day14)

好的,在我们结束Spring Boot API的架设後,再来我们要开始进入前端框架-Angular的...