暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

秒杀系统的防刷处理(上)

Alleria Windrunner 2021-10-24
1557
前面几篇我们从流量出发,对我们的秒杀系统做了高可用处理,我们来回顾一下主要有哪些手段:
  • 流量隔离

  • 流量管控

  • 流量消峰

  • 流量限流

  • 降级处理

  • 热点数据处理

  • 容灾处理


接下来我们来讨论另外一个话题:如何保证秒杀的公平以及库存的正确性。本篇我们先来看下防刷和风控。
经过前面对秒杀业务的介绍,你现在应该清楚,秒杀系统之所以流量高,主要是因为一般使用秒杀系统做活动的商品,基本都是稀缺商品。稀缺商品意味着在市场上具有较高的流通价值,那么它的这一特点,必定会引来一群“聪明”的用户,为了利益最大化,通过非正常手段来抢购商品,这种行为群体我们称之为黑产用户。
他们确实是聪明的,因为他们总能想出五花八门的抢购方式,有借助物理工具,像“金手指”这种帮忙点击手机抢购按钮的;有通过第三方软件,按时准点帮忙触发 App 内的抢购按钮的;还有的是通过抓取并分析抢购的相关接口,然后自己通过程序来模拟抢购过程的。
可不管是哪种方式,其实都在做一件事,那就是先你一步。因为秒杀的抢购原则无外乎两种,要么是绝对公平的,即先到的请求先处理,暂时处理不了的,会把你放入到一个等待队列,然后慢慢处理。要么是非公平的,暂时处理不完的请求会立即拒绝,让你回到开始的地方,和大家一起再比谁先到,如此往复,直至商品售完。
因此黑产的方法也很简单,就是想法设法比别人快,发出的请求比别人多,就像在一个赛道上,给自己制造很多的分身,不仅保证自己比别人快,同时还要把别人挤出赛道,确保自己能够到达终点。
所以黑产对秒杀业务的威胁是巨大的,它不仅破坏了公平的抢购环境,而且给秒杀系统带来了庞大的性能开销,所以我们不能放任黑产流量对系统的肆意冲击,我们必须对抗它。既然黑产流量的特点是比正常流量快且频率高,那么我们也就可以从这两个方面来着手思考对策。
只针对第一个快的特点,其实在活动开始后,进来的流量我们都无法将其定义为非法流量,这个只能借助像风控这种多维度校验,才能将其识别出来,除非它跳步骤。而第二个高频率的特点,同时也是对秒杀系统造成危害最大的一种,我们还是有很多种手段来应对的。所以这节课我就给你介绍几种比较有效且经过实践的防刷方案,它们专门针对高频率以及跳步奏的非法手段。

防刷
Nginx 有条件限流

在上篇中我已经介绍过 Nginx 限流的语法了,现在咱们就直接来实践。这里呢,我们是根据用户 ID 来做限流防刷的。

首先我们新建一个通用配置文件 common.conf,用来定义限流规则以及后续一些其他的通用配置,所在位置如下:


同时,不要忘记在 nginx.conf 文件中将该配置文件引入进去,和引入 upstream.conf 一样。然后,我们在 common.conf 中定义限流规则如下:

    # limit by user
    limit_req_zone $user_id zone=limit_by_user:10m rate=1r/s;

    意为定义了一个名为 limit_by_user 的限流规则,根据用户 ID 来做限流,限流的速率为同一个用户 1 秒内只允许 1 个请求通过,且为该规则申请的内存大小为 10M。

    这里的 10M 大概是什么概念呢?可以简单粗略地算下,假如一个 user_id 占用的内存大小为 16 字节,那么 10M 的内存大概可以处理单机 10*1024*1024/16=655360 个请求。

    规则配置完毕后,接下来就在我们需要限流的接口引用该规则,这里依然以活动查询接口为例,配置如下:

      #活动数据查询
      location activity/query{
      limit_req zone=limit_by_user nodelay;
      default_type text/plain;
      proxy_pass http://backend;
      }

      其中 nodelay 是被限流后的策略,意为不等待,直接返回。

      配置好之后,我们启动 Nginx,通过 URL 进入到商详页(要在活动进行中时)。


      这时,我们通过鼠标快速地刷新两次页面(点击浏览器中的刷新图标,在 1 秒内完成),来模拟外部的请求,然后看下对应的 access 和 error 日志,结果如下:


      通过 domain-access.log 日志可以看到,两次请求,第一次正常返回,第二次返回给客户端 503 的状态码,原因通过 domain-error.log 可以看到,是触发了根据用户 ID 的限流规则,这样我们的限流防刷功能就实现了。

      以上通过限流的方式来防刷,是非常简单且直接的一种方式,这种方式可以有效解决黑产流量对单个接口的高频请求,但要想防止刷子不经过前置流程直接提单,还需要引入一个流程编排的 Token 机制。


      Token 机制

      Token 我想你是知道的,一般都是用来做鉴权的。放到秒杀的业务场景就是,对于有先后顺序的接口调用,我们要求进入下个接口之前,要在上个接口获得令牌,不然就认定为非法请求。同时这种方式也可以防止多端操作对数据的篡改,如果我们在 Nginx 层做 Token 的生成与校验,可以做到对业务流程主数据的无侵入。

      在 Token 机制下,前端与 seckill-nginx 中的接口交互时序图如下所示:


      现在我们就按照对应的时序,依次给 4 个接口增加上 Token 相关的生成与校验功能。

      在这之前,我们为了更真实地获取用户 ID,需要在 seckill-web 中新增加个登录功能,模拟将 user_id 放到 cookie,这样之后的每次请求,我们就直接从 cookie 中获取 user_id 即可。

      同时根据我们的设计,有几个主要参数是每次接口请求都必须要校验的,那就是用户 ID、产品编号,还有新的 Token,所以我们就定义了一个统一的解析方法,其中用户 ID 从 cookie 中解析,产品编号和 st 从请求的 URL 中解析,如下所示:

        #security token
        set $st "";


        #产品编号
        set $product_id "";


        #用户ID
        set_by_lua_file $user_id Users/Eleven/idea-practic/seckill-nginx/lua/set_common_var.lua;

        set_common_var.lua 主要就是负责参数的解析,并给对应的变量做赋值,如下所示:

          -- 解析通用变量赋值功能


          --通过请求URL获取st,并赋值给变量st
          local param_st = ngx.var.arg_st
          if not param_st then
          param_st = ""
          end
          ngx.var.st = param_st
          --通过请求URL获取产品编号,并赋值给变量product_id
          local param_product_id = ngx.var.arg_productId
          if not param_product_id then
          param_product_id = ""
          end
          ngx.var.product_id = param_product_id


          --通过cookie获取用户ID,并赋值给user_id
          local user_id = ngx.var.cookie_user_id
          if not user_id then
          user_id = ""
          end


          return user_id


          做完之后,我们开始改造活动数据查询接口,改造后如下:

            #活动数据查询
            location activity/query{
            limit_req zone=limit_by_user nodelay;
            content_by_lua_file Users/Eleven/idea-practic/seckill-nginx/seckill-nginx/lua/activity_query.lua;
            #设置返回的header,并将security token放在header中
            header_filter_by_lua_block{
            ngx.header["st"] = ngx.md5(ngx.var.user_id.."1")
            --这里为了解决跨域问题设置的,不存在跨域时不需要设置以下header
            ngx.header["Access-Control-Expose-Headers"] = "st"
            ngx.header["Access-Control-Allow-Origin"] = "http://localhost:8080"
            ngx.header["Access-Control-Allow-Credentials"] = "true"
            }
            }

            这里通过 header_filter_by_lua_block 指令,在返回的 header 里增加流程 Token。这里 st 的生成只是简单地将用户 ID+ 步骤编号做了 MD5,生产上需要更严格一些,需要加入商品编号、活动开始时间、自定义加密 key 等,这样前端通过解析请求响应 header,就可以拿到 st 了,然后将其拼在请求结算页 H5 的 URL 后即可。

            然后再对结算页 H5 的 location 做改造,改造后如下图所示(当然这里可以将 rewrite_by_lua 的内容放到 file,看起来会更整洁一些):

              #进结算页页面(H5)
              location settlement/prePage{
              default_type text/html;
              rewrite_by_lua_block{
              --校验活动查询的st
              local _st = ngx.md5(ngx.var.user_id.."1")
              --校验不通过时,以500状态码,返回对应错误页
              if _st ~= ngx.var.st then
              ngx.log(ngx.ERR,"st is not valid!!")
              return ngx.exit(500)
              end
              --校验通过时,再生成个新的st,用于下个接口校验
              local new_st = ngx.md5(ngx.var.user_id.."2")
              --ngx.exec执行内部跳转,浏览器URL不会发生变化
              --ngx.redirect(url,status) 其中status为301302
              local redirect_url = "/settlement/page".."?productId="..ngx.var.product_id.."&st="..new_st
              return ngx.redirect(redirect_url,302)
              }
              error_page 500 502 503 504 html_fail.html;
              }

              结算页 H5 的改造点,就是将之前的一个 location 拆成了 2 个了,增加了 settlement/prePage,这样点击立即抢购时就可以直接调用这个接口。

              这里你可能会有疑问,这里为什么没有选择将 st 放在 header 中返回呢?因为和活动数据查询接口不同的是,这个接口返回的是 HTML,上个接口返回的是 JSON,所以选择了重定向的方式,这样浏览器就可以获得到新的 st 了,具体代码如上,在 rewrite_by_lua_block 配置中使用 ngx.redirect() 方法来实现重定向。

              同时这里再给你介绍下,如果业务校验不通过,想终止整个请求流程,可以通过 ngx.exit(状态码)来实现,这样就可以将对应状态码返回给前端了。但如果想要做得更友好些,当系统内部异常或者是后端服务器异常时,我们可以指定返回的内容。这就需要通过 error_page 指令来实现,意为出现不同的错误码,我们会转到不同的 location 去做处理。如果是 H5 请求,那就返回对应的错误提示页,如果是 JSON 请求,也可以返回自定义的 JSON 数据,如下所示:

                #错误页
                location = /html_fail.html {
                default_type text/html;
                root /Users/Eleven/idea-practic/seckill-nginx/seckill-nginx/html;
                }


                #以"@"开头定义的location,是内部接口,外部无法访问
                location @json_fail {
                default_type application/json;
                return 200 '{"code":"200001","message":"nginx intercept!!!"}';
                }

                上图就是配置了两个异常处理 location,其中 html_fail.html 的位置和内容如下:

                  <!DOCTYPE html>
                  <html>
                  <head>
                  <meta charset="UTF-8">
                  <title>抢购失败</title>
                  </head>
                  <body>


                  <div>
                  <p style="padding-left:240px;font-size:xx-large;color:red;">出现了一些问题,请再接再厉!!</p>
                  </div>
                  <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
                  <script type="text/javascript">
                  </script>
                  </body>
                  </html>


                  那么做完了上面那么多工作,现在我们就来验证下针对活动数据查询接口和结算页 H5 接口的顺序编排是否生效。

                  我们启动 Nginx,并且进入到商详页,点击立即抢购,正常流程是可以进入到结算页的,这里就不展示了,那如果我们不经过商详页,直接通过 URL 访问结算页 H5 接口(不带 st 或者带错误的 st),效果会如何呢?看下图:


                  出现了我们配置的错误提示页,同时日志中出现了 st 校验不通过的提示:


                  那么使用同样的机制,我们可以把剩下的接口功能给补上,因为做法一样,这里就不多说了。

                  到这,防刷的 Token 机制就介绍完了,这种机制可以有效防止黑产流量跳过中间接口,直接调用下单接口。通过该机制 +Nginx 有条件限流机制,可以有效拦截大部分场景下的刷子流量。但如果我们还想再严格一些,对于黑产不仅仅是想拦截过多的非法请求,而是想全部拦截,那有没有什么办法呢?有,下一篇我们继续介绍黑名单和风控机制。

                  文章转载自Alleria Windrunner,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论