SpringBoot+Lua+Redis 实现限量秒杀抢购模块

SpringBoot+Lua+Redis 实现限量秒杀抢购模块

秒杀抢购可以说是在分布式环境下一个非常经典的案例。
和普通的电商流程不同,秒杀有如下特点:低廉价格、大幅推广、瞬时售空、一般是定时上架、时间短、瞬时并发量高。

里边有很多痛点比如:
1.高并发: 时间极短、 瞬间用户量大,而且用户会在开始前不断刷新页面,还会积累一大堆重复请求的问题,一瞬间的高QPS把系统或数据库直接打死,响应失败,导致与这个系统耦合的系统也GG,一挂挂一片。
2.链接暴露: 通过技术手段获取秒杀的url,然后在开始前通过这个url传要传递的参数来进行购买操作。
3.超卖: 你只有一百件商品,由于是高并发的问题,一起拿到了最后一件商品的信息,都认为还有,全卖出去了,最终卖了100+件,仓库里根本没这么多货。
4.恶意请求: 因为秒杀的价格比较低,有人会用脚本来秒杀,全给一个人买走了,他再转卖,或者同时以脚本操作多个账号一起秒杀。(就是常见的黄牛党)。

本文将使用jmeter工具模仿瞬时并发,基于redis来实现一个秒杀功能。没有实现前端页面。

一、系统分析

假设有一个需求:在某天的12点,开启秒杀,限量100件商品,每个用户会有唯一ID。

首先分析下, 秒杀系统为秒杀而设计,不同于一般的网购行为,参与秒杀活动的用户更关心的是如何能快速刷新商品页面,在秒杀开始的时候抢先进入下单页面并,而不是商品详情等用户体验细节,因此秒杀系统的页面设计应尽可能简单。

1.1 前端思路:

展示秒杀商品的页面, 页面上有一个秒杀活动开始的倒计时, 在准备阶段内用户会陆续打开这个秒杀的页面, 并且可能不停的刷新页面。

这里需要考虑两个问题:

第一个:资源静态化

秒杀页面的展示我们知道一个html页面还是比较大的,即使做了压缩,http头和内容的大小也可能高达数十K,加上其他的css, js,图片等资源,如果同时有几千万人参与一个商品的抢购,一般机房带宽也就只有1G~10G,网络带宽就极有可能成为瓶颈,所以这个页面上各类静态资源首先应分开存放,然后放到cdn节点上分散压力,由于CDN节点遍布全国各地,能缓冲掉绝大部分的压力。

第二个:时间同步

倒计时出于性能原因这个一般由js调用客户端本地时间,就有可能出现客户端时钟与服务器时钟不一致,另外服务器之间也是有可能出现时钟不一致。
客户端与服务器时钟不一致可以采用客户端定时和服务器同步时间,用于同步时间的接口由于不涉及到后端逻辑,只需要将当前web服务器的时间发送给客户端就可以了,这个接口可以只返回一小段json格式的数据,而且可以优化一下减少不必要cookie和其他http头的信息,所以数据量不会很大,一般来说网络不会成为瓶颈

第三个:操作控制

(1)产品层面,用户点击“购买”或者“下单”后,按钮置灰,禁止用户重复提交请求;
(2)js层面,限制用户在x秒之内只能提交一次请求;

前端层的请求拦截,只能拦住小白用户(不过这也是98%的用户了),有点基础的程序员根本不会吃这一套,写个循环直接调用的http请求,怎么办?

(1)同一个uid,限制访问频度,做页面缓存,x秒内到达站点层的请求,均返回同一页面
(2)同一个item的查询,例如手机车次,做页面缓存,x秒内到达站点层的请求,均返回同一页面

如此限流,又有99%的流量会被拦截在站点层。

至此前端可以为服务端减轻很大的压力。接下来看后端设计思路:

1.2 后端思路:

针对如下几点做出解决方案:

提前知道了url:

可以对url进行加密如 使用随机字符串进行MD5加密,活动中点击秒杀按钮才返回url,和MD5加密字符拼接成完整url:

#PostMapping("/kill/{userid}/{md5}/url")

可以在服务网关层加过滤条件如After:


routes:
  - id: 1             
    # ...

  - id: 2
    uri: lb://kill-server   
    predicates:
      - Path=/kill/exec
      - After=2019-12-01T12:00:00.00+08:00[Asia/Shanghai]  # 在2019-12-01 12:00:00.000之后才可以访问

高并发:

nginx做负载均衡(一个tomcat可能只能抗住几百的的并发,nginx还可以对一些恶意ip做限制)
写请求,可以将商品总数做到缓存或队列中,每次只透过有限的写请求去数据层,如果均成功再放下一批,如果库存不够则请求全部返回“已售完”
读请求,cache集群来抗。 提前把数据库里的东西提前加载到redis来 。

超卖问题:

悲观锁乐观锁redis均可以解决。
但是我个人更加倾向于使用redis解决,在大并发环境下对数据库的操作一定要慎之又慎,能cache的就不要访问库。redis是单线程串行执行
,利用redis的单线程预减库存。
比如商品有100件。那么我在redis存储一个k,v。如count:100。每一个用户线程进来,count减1,等减到0的时候,全部拒绝剩下的请求。所以一定不会出现超卖的现象

恶意请求:

联合主键索引或者Redis Set数据结构均可解决。还是上面说的,更倾向于redis。
联合主键索引 :把用户id商品id作为联合主键索引存储到数据库中,下次如果再想插入一样用户id和商品id的行,是会报错的。(主键索引的唯一性),事务中出现错误,支付操作自然也失败了。
Redis Set数据结构:要知道,set数据结构是不需重复的,我们可以巧妙地使用这点,建立一个<kill,userID>的set结构,点击抢购就对此userID进行add。每次点击前在判断下是否存在次ID即可。

还有诸如MQ削峰解耦、数据库读写分离等一些列应对并发操作的手段在此不一一列举。

二、实现细节

新建一个服务,此处限定开始时间可以直接使用网关After配置,见上面代码。

因为需要整合lua,所以配置DefaultRedisScriptConfig类, 在应用上下文中配置好,避免在执行的时候重复创建脚本的SHA降低性能。

@Configuration
public class DefaultRedisScriptConfig {

    @Bean
    public DefaultRedisScript<String> redisScript() {
        DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
        redisScript.setResultType(String.class);
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/seckill.wlua")));
        return redisScript;
    }
    
}

seckill.wlua文件位置:

seckill.wlua 文件具体代码如下:

local userid=KEYS[1];
local date=KEYS[2];
local kckey='Seckill:'..date..":kc";
local userKey='Seckill:'..date..":user";
local userExists=redis.call("sismember", userKey, userid);
if tonumber(userExists)==1 then
    return 2;
end

local num = redis.call("get", kckey);
if tonumber(num)<=0 then
    return 0;
else
    redis.call("decr", kckey);
    redis.call("sadd", userKey, userid);
end

return 1;

还有RedisTemplate:

@Configuration
public class RedisConfig {

    /*
    关于序列化。
    spring-data-redis提供如下几种选择:

     - GenericToStringSerializer: 可以将任何对象泛化为字符串并序列化
     - Jackson2JsonRedisSerializer: 跟JacksonJsonRedisSerializer实际上是一样的
     - JacksonJsonRedisSerializer: 序列化object对象为json字符串
     - JdkSerializationRedisSerializer: 序列化java对象
     - StringRedisSerializer: 简单的字符串序列化
     */

    @Resource
    private LettuceConnectionFactory lettuceConnectionFactory;

    @Bean
    public RedisTemplate<String, Serializable> redisTemplate() {

        RedisTemplate<String, Serializable> template = new RedisTemplate<>();     
        template.setConnectionFactory(lettuceConnectionFactory);
        RedisSerializer<?> stringSerializer = new StringRedisSerializer();
        jackson2JsonRedisSerializer.setObjectMapper(om);
        template.setKeySerializer(stringSerializer);
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(stringSerializer);
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.afterPropertiesSet(); 

        return template;
    }

Controller关键方法:

/**
     * 秒杀入口
     * @param map 传入参数
     * @return String
     */
    @PostMapping("/bf")
    public String bf( @RequestBody Map<String, String> map ){

        String key = "bf:count";
        Long increment = redisTemplate.opsForValue().increment(key, 1);
        log.info(Thread.currentThread().getName() + " - 访问计数:" + increment );

        JSONObject jo = new JSONObject();

        try {
            String res = seckillService.doSeckill(map.get("id"));
            jo.put("message", res);
            jo.put("success", true);
        } catch (Exception e) {
            e.printStackTrace();
            jo.put("message", "系统繁忙");
            jo.put("success", false);
        }
        return jo.toJSONString();
    }

Service层关键方法:

    @Resource
    private RedisTemplate redisTemplate;
    @Resource
    private DefaultRedisScript redisScript;

    public String doSeckill( String userID ){

        String date = DateUtil.format(new Date(),"yyyyMMdd");    
        Object res = redisTemplate.execute((RedisConnection connection) -&gt; connection.eval(
                redisScript.getScriptAsString().getBytes(),
                ReturnType.INTEGER,
                2,
                userID.getBytes(),
                date.getBytes()
        ));

        long a = res == null ? -1 : (Long) res;
        String message = "";
        if( a == 1 ){
            message = "抢购成功";
        }else if( a== 0 ){
            message = "商品已经被抢完啦";
        }else {
            message = "每个账号限购一次!请勿重复操作。";
        }
        log.info(message);
        return message;

    }

在单元测试保证每个方法都正常后,启动项目。

Jmiter配置 3秒2000线程 打过去。

这里post的请求参数定义一个随机uuid。

可以看到控制台疯狂输出:

注意:101是因为我提前在redis中放了一个测试id,所以第100次访问显示了101。

在计数超过100的时候都会显示抢完,此时在使用RedisDesktop观察数据:

用来存放已抢购userID的set的size为100。即100个用户。

再看商品,为0。抢完了。

验证是否只能抢一次,在已抢购userID的set中,随便复制一个id。

使用postMan来再次访问秒杀接口:

可以看到提示请勿重复操作。即实现了限制重复购买功能。

至此基于Boot+redis+lua实现的秒杀模块全部实现。 希望能对遇到相同问题的朋友有所帮助。

以上。

Comments

No comments yet. Why don’t you start the discussion?

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注