/miaoshaV2.0

Refactored and perfected the SECKILL V1.0, adding some new features

Primary LanguageJava

秒杀项目V2.0

1、前言:

之前的秒杀V1.0过于粗糙。实习数月以来,学习到开发中的新"姿势"。 回顾之前代码,深感自己老项目不足,现进行项目重构与功能完善。

2、简介:

秒杀项目V1不足之处:

当时too_young,代码的结构层次混乱,规范性不足,异常处理不完善,功能不全 此第二版本中,对以上问题进行完善。

主要使用的技术: SpringBoot、SpringMVC、Mybatis-Plus、MySQL、Redis、Zookeeper、ElasticJob等

所做完善或增加的新功能说明:

  • 【完善】对项目结构进行重构,逻辑分层更清楚
  • 【完善】基于SpringBoot、Mybatis-plus简化开发
  • 【add】增加代码规范性,完善异常处理
  • 【add】增加用户登录拦截,用户登录,用户注册功能, done_90%(2020-07-25)
  • 【add】基于Es-job开发定时任务进行缓存预热防止缓存雪崩, done_100%(2020-07-25)
  • 【add】redis进行过滤限流防刷, 加入IP黑名单功能,定时解封,done_100%(2020-07-29)
  • 【add】基于Redis#zSet实现的延时队列,模拟下单后30分钟内未付款的,取消其订单, done_80%, 延时队列已经实现,尚未模拟使用(2020-08-01)
  • 【add】引入消息队列削峰, 将请求处理放到消息队列中依次处理来削峰,但是也降低了用户体验。(TODO)
  • 【add】后端整体限流,使用计数器或者令牌桶(TODO)
  • 【add】加入durid数据库连接池支持,并配置好sql监控页面,初始账户密码均为admin,done_100%(2020-08-14)

系统模块分层,使结构清晰,易于扩展:

  • seckill-admin 主要是登录校验(TODO)
  • seckill-app 主入口,controller及其处理逻辑
  • seckill-common 通用工具,比如Redis,线程池,延时队列
  • seckill-core 对数据进行的处理,主要使用的是mybatis-plus

3、如何设计一个秒杀系统

主要分析秒杀系统的特点及代码优化思路:

系统特点

  • 高性能:秒杀涉及大量的并发读和并发写,因此支持高并发访问这点非常关键
  • 一致性:秒杀商品减库存的实现方式同样关键,有限数量的商品在同一时刻被很多倍的请求同时来减库存,在大并发更新的过程中都要保证数据的准确性。
  • 高可用:秒杀时会在一瞬间涌入大量的流量,为了避免系统宕机,保证高可用,需要做好流量限制
  • 其他逻辑:不可重复秒杀、防止超卖、防作弊等

优化思路

后端优化:

  • 限流:屏蔽掉无用的流量,允许少部分流量走后端。假设现在库存为 10,有 1000 个购买请求,最终只有 10 个可以成功,99% 的请求都是无效请求
  • 削峰:秒杀请求在时间上高度集中于某一个时间点,瞬时流量容易压垮系统,因此需要对流量进行削峰处理,缓冲瞬时流量,尽量让服务器对资源进行平缓处理
  • 异步:将同步请求转换为异步请求,来提高并发量,本质也是削峰处理
  • 利用缓存:创建订单时,每次都需要先查询判断库存,只有少部分成功的请求才会创建订单,因此可以将商品信息放在缓存中,减少数据库查询
  • 负载均衡:利用 Nginx 等使用多个服务器并发处理请求,减少单个服务器压力

前端优化:

  • 限流:前端答题或验证码,来分散用户的请求
  • 禁止重复提交:限定每个用户发起一次秒杀后,需等待才可以发起另一次请求,从而减少用户的重复请求
  • 本地标记:用户成功秒杀到商品后,将提交按钮置灰,禁止用户再次提交请求
  • 动静分离:将前端静态数据直接缓存到离用户最近的地方,比如用户浏览器、CDN 或者服务端的缓存中

防作弊优化:

  • 隐藏秒杀接口:如果秒杀地址直接暴露,在秒杀开始前可能会被恶意用户来刷接口,因此需要在没到秒杀开始时间不能获取秒杀接口,只有秒杀开始了,才返回秒杀地址 url 和验证 MD5,用户拿到这两个数据才可以进行秒杀
  • 同一个账号多次发出请求:在前端优化的禁止重复提交可以进行优化;也可以使用 Redis 标志位,每个用户的所有请求都尝试在 Redis 中插入一个 userId_secondsKill 标志位,成功插入的才可以执行后续的秒杀逻辑,其他被过滤掉,执行完秒杀逻辑后,删除标志位
  • 多个账号一次性发出多个请求:一般这种请求都来自同一个 IP 地址,可以检测 IP 的请求频率,如果过于频繁则弹出一个验证码
  • 多个账号不同 IP 发起不同请求:这种一般都是僵尸账号,检测账号的活跃度或者等级等信息,来进行限制。比如微博抽奖,用 iphone 的年轻女性用户中奖几率更大。通过用户画像限制僵尸号无法参与秒杀或秒杀不能成功

4、部分优化具体分析

乐观锁

悲观锁虽然可以解决超卖问题,但是加锁的时间可能会很长,会长时间的限制其他用户的访问,导致很多请求等待锁,卡死在这里,如果这种请求很多就会耗尽连接,系统出现异常。乐观锁默认不加锁,更失败就直接返回抢购失败,可以承受较高并发 每个线程在校验库存的时候会拿到当前商品的乐观锁版本号,然后在扣库存时,如果版本号不对,就会扣减失败,抛出异常结束,这样每个版本号就只能有一个线程操作成功,其他相同版本号的线程秒杀失败,就不会存在卖超问题了

Redis限流防刷

项目采用了Redis限流,也就是计数器限流,因为其实现比较简单,统计每个ip每分钟访问次数,超过指定次数,加黑名单(可以记录数据库,也可以Redis中设置一个标识位),请求时直接查看是否为黑名单,然后是黑名单直接返回不做处理。

后端流量限流

与以上防刷限流针对特定ip不同的是,后端流量限流为整体限流。比如10件商品的秒杀,放1000个请求来秒杀下单是没有必要的。 一些常见的限流算法:

  • 计数器算法:通过一个计数器 counter 来统计一段时间内请求的数量,并且在指定的时间之后重置计数器。该方法实现简单,但是有临界问题。例如,假设我们限流规则为每秒钟不超过 100 次接口请求,第一个 1s 时间窗口内,100 次接口请求都集中在最后的 10ms 内,在第二个 1s 的时间窗口内,100 次接口请求都集中在最开始的 10ms 内,虽然两个时间窗口内流量都符合限流要求,但是在这两个时间窗口临界的 20ms 内会集中有 200 次接口请求,如果不做限流,集中在这 20ms 内的 200 次请求就有可能压垮系统。
  • 滑动窗口法:滑动窗口算法是计数器算法的一种改进,将原来的一个时间窗口划分成多个时间窗口,并且不断向右滑动该窗口。流量经过滑动时间窗口算法整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从流量曲线上来看会更加平滑,可以部分解决上面提到的临界突发流量问题。
  • 令牌桶法:(nginx应该也是使用此限流算法)令牌桶算法的流程:
    • 接口限制 t 秒内最大访问次数为 n,则每隔 t/n 秒会放一个 token 到桶中
    • 桶内最多存放 b 个 token,如果 token 到达时令牌桶已经满了,那么这个 token 就会被丢弃
    • 接口请求会先从令牌桶中取 token,拿到 token 则处理接口请求,拿不到 token 则进行限流处理
    • 因为令牌桶存放了很多令牌,那么大量的突发请求会被执行,但是它不会出现临界问题,在令牌用完之后,令牌是以一个恒定的速率添加到令牌桶中的,因此不能再次发送大量突发请求
  • 漏桶算法:桶的大小是固定,桶是有洞的。桶满后,水进不来;桶内的水会以固定速率漏水 在应对秒杀,大促等高性能压力的场景时,为了保证系统的平稳运行,必须针对超过预期的流量,通过预先设定的限流规则选择性的对某些请求进行限流

缓存和数据一致性

常见的策略是:先更新数据库,然后删除缓存。使用懒加载避免重复更新效率低下,且产生脏数据可能性小。 但是在秒杀中,每次都删除缓存,因此导致多次缓存都不能命中,能命中缓存的次数很少,因此这种方案并不可取。会影响并发量。 所以采用先更新数据库再更新缓存,扣库存使用的是乐观锁,操作成功才更新缓存。

Redis缓存预热

使用定时任务,每十五分钟扫描十五分钟内即将开始的商品信息放入缓存。 可以使用Spring-Boot的注解实现@Scheduled(cron = "0/5 * * * * ?"),实际是基于quartz,或者基于分布式定时任务框架Es-Job来实现

异步

减库存放在redis中,预减库存。然后通过消息队列削峰异步扣减数据库库存,下单。对于防止重复秒杀,可以在缓存中设置【商品+用户标识】来标记购买过,每次先查一下,已经购买过直接返回重复购买。 降低了用户体验,但是保证系统高并发下可用性。

负载均衡

单台服务器的处理性能是有瓶颈的,当并发量十分大时,无论怎么优化都满足不了需求,这时候就需要增加一台服务器分担原有服务器的访问压力,通过负载均衡服务器 Nginx 可以将来自用户的访问请求发到应用服务器集群中的任何一台机器

Nginx 示例配置如下:

在项目的配置文件 application.properties 中分别设置两个应用的端口号如 8888 和 9999 。

server.port=8888 server.port=9999 然后进入nginx/conf文件目录将nginx.conf配置文件中的http部分修改为如下代码:

http {
    include       mime.types;
    default_type  application/octet-stream;

    #log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
    #                  '$status $body_bytes_sent "$http_referer" '
    #                  '"$http_user_agent" "$http_x_forwarded_for"';

    #access_log  logs/access.log  main;

    sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    keepalive_timeout  65;

    #gzip  on;

    upstream server_miaosha{
        server 127.0.0.1:8888 weight=1;
        server 127.0.0.1:9999 weight=1;
    }

    server {
        listen  80;
        server_name  localhost;

        #charset koi8-r;

        #access_log  logs/host.access.log  main;

        location / {
            #root html;
            #index index.html index.htm;
            set $xheader $remote_addr;
            if ( $http_x_forwarded_for != '' ){
                set $xheader $http_x_forwarded_for;
            }
            proxy_set_header X-Real-IP $xheader;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header Host $http_host;
            proxy_redirect off;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            proxy_pass http://server_miaosha;
        }

        #error_page  404     /404.html;

权重weight可以根据个人需求进行设置,本文均设置为 1 ,表示访问 IP + 80 端口时两个应用按 1:1 进行轮询。

5、数据库表的设计

秒杀系统的数据库的设计 以下为主要数据库表的设计,包括建立了一些合适的索引

  • 秒杀商品表
CREATE TABLE `seckill_product` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '商品id',
  `name` VARCHAR(120) NOT NULL COMMENT '商品名称',
  `number` INT(11) NOT NULL COMMENT '库存数量',
  `start_time` DATETIME NOT NULL COMMENT '秒杀开启时间',
  `end_time` DATETIME NOT NULL COMMENT '秒杀结束时间',
  `create_time` DATETIME NOT NULL COMMENT '创建时间',
  `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '创建时间',
  `version` INT(11) NOT NULL COMMENT '版本号',
  PRIMARY KEY (`id`),
  KEY `idx_start_time` (`start_time`),
  KEY `idx_end_time` (`end_time`)
) ENGINE=INNODB AUTO_INCREMENT=1004 DEFAULT CHARSET=utf8 COMMENT='秒杀库存表';
  • 秒杀系统订单表
CREATE TABLE `seckill_order` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `order_id` varchar(32) NOT NULL COMMENT '订单id',
  `passport` BIGINT(20) NOT NULL COMMENT '用户账号',
  `product_id` BIGINT(20) NOT NULL COMMENT '秒杀的商品id',
  `status` TINYINT(4) NOT NULL COMMENT '状态标示:-1=无效,0=成功',
  `create_time` DATETIME NOT NULL,
  `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_passport` (`passport`),
  UNIQUE KEY `idx_productid_passport` (`product_id`, `passport`)
) ENGINE=INNODB DEFAULT CHARSET=utf8 COMMENT='秒杀系统订单表';
  • 用户信息表
CREATE TABLE `seckill_user` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `passport` varchar(128) NOT NULL COMMENT '通行证',
  `password` varchar(128) NOT NULL COMMENT '密码',
  `phone_number` bigint(20) NOT NULL COMMENT '手机号码',
  `create_time` DATETIME NOT NULL,
  `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_id_userid` (`passport`)
) ENGINE=INNODB DEFAULT CHARSET=utf8 COMMENT='用户信息表';
  • ip黑名单
CREATE TABLE `seckill_black_list` (
  `id` BIGINT(20) NOT NULL AUTO_INCREMENT,
  `ip` varchar(128) NOT NULL COMMENT 'ip地址',
  `date_num` bigint(20) NOT NULL COMMENT '封禁日期',
  `create_time` DATETIME NOT NULL,
  `update_time` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_ip` (`ip`),
  KEY `idx_date_num` (`date_num`)
) ENGINE=INNODB DEFAULT CHARSET=utf8 COMMENT='ip黑名单表';
  • 一些sql脚本
INSERT  INTO 
`seckill_product`(`id`,`name`,`number`,`start_time`,`end_time`,`create_time`,`version`,`create_time`) 
VALUES 
(1000,'1000元秒杀iphone8',100,'2018-05-10 15:31:53','2018-05-10 15:31:53','2018-05-10 15:31:53',0,now()),
(1001,'500元秒杀ipad2',100,'2018-05-10 15:31:53','2018-05-10 15:31:53','2018-05-10 15:31:53',0,now()),
(1002,'300元秒杀小米4',100,'2018-05-10 15:31:53','2018-05-10 15:31:53','2018-05-10 15:31:53',0,now()),
(1003,'200元秒杀红米note',100,'2018-05-10 15:31:53','2018-05-10 15:31:53','2018-05-10 15:31:53',0,now());

TRUNCATE TABLE

DELECT * FROM 

6、 参考文章