秒杀系统笔记

登陆

两次MD5加密:前端

  1. 浏览器对密码明文加固定的盐来md5加密一次后传输(这一步感受加不加盐都无所谓,由于前端js是对外的,有没有加盐别人是知道的)
  2. 服务器对接收的密文加盐再作一次加密,第二次加密后的值来与数据库中的数据对比。(这里的盐是注册时,随机生成的,与二次加密后的密码都保存在数据库中)

理解:java

       第一次是为了防止传输过程当中,用户明文密码被截取获取。mysql

       第二次是为了防止数据库被盗后,别人知道了密码的md5值,而后直接伪造请求,传递这个md5值过来登陆成功;二次加密后,由于浏览器传递的值和数据库中存放的值不一致,因此丢失后也不会形成密码丢失问题。redis

页面静态化

生成请求地址一一对应的缓存,能防止瞬间并发高带来的性能问题,但页面的实时性就下降了,由于缓存所展现的内容老是固定的,过时从新渲染后,内容才可能发生变化。算法

表结构设计

--建立秒杀库存表
CREATE TABLE seckill(
  `seckill_id` bigint NOT NULL AUTO_INCREMENT COMMENT '商品库存id',
  `name` varchar (120) NOT NULL COMMENT '商品名称',
  `number` int NOT NULL COMMENT '库存数量',
  `start_time` timestamp NOT NULL COMMENT '秒杀开启时间',
  `end_time` timestamp NOT NULL COMMENT '秒杀结束时间',
  `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立时间',
  PRIMARY KEY(seckill_id),
  key idx_start_time(start_time),
  key idx_end_time(end_time),
  key idx_create_time(create_time)
)ENGINE=InnoDB AUTO_INCREMENT=1000 DEFAULT CHARSET=utf8 COMMENT='秒杀库存表';

--秒杀成功明细表
CREATE TABLE success_killed(
  `seckill_id` bigint NOT NULL COMMENT '秒杀商品id',
  `user_phone` bigint NOT NULL COMMENT '用户手机号,简单起见经过手机来惟一对应用户',
  `state` bigint NOT NULL DEFAULT -1 COMMENT '状态表示:-1:无效,0:成功,1:已付款',
  `create_time` timestamp NOT NULL COMMENT '建立时间',
  PRIMARY KEY (seckill_id,user_phone),
  key idx_create_time(create_time)
)ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='秒杀成功明细表';

秒杀实现逻辑

客户端刷新来访问接口:以服务端的时间和数据库中记录的活动的时间进行对比,来判断秒杀活动是否处于开启中。开启的话,则返回一个惟一令牌。sql

发送秒杀请求时,参数中须要有一个活动id加盐md5计算得出的令牌,这个令牌与当前秒杀活动一一对应,之因此要传令牌,而不是传活动的id,是为了防止别人在活动开始前作好工具来自动秒杀,活动id能提早拿获得,而令牌是活动开始后才能拿获得。这样一来就算作好了秒杀工具,也要在活动开始以后来改参数,工具才能用。可是改参数的话,速度就比别人慢了,别人进入页面,点一个按钮就抢完了。这就从必定程度上防范了自动化秒杀工具的使用。数据库

执行秒杀时,回传秒杀地址中的md5令牌,服务端以相同的算法计算出实际令牌,校验令牌准确性,准确才能够执行后续操做。后端

理解:令牌仅仅在活动开始才会给浏览器生成,而且秒杀操做必需要有令牌,就能保证秒杀的公平性。浏览器

以上令牌实际有两种生成方式:缓存

1. md5(productId + 固定的盐) => 令牌:每一个用户的令牌都相同

2. md5(productId + userId) => 令牌 :每一个用户的令牌都不一样

验证码

不一样人输入验证码的速度不同,因此能够下降(分散)一个短暂时间内的并发请求的压力,同时还能防止机器人刷请求。

数学公式验证码,如1+2-3,输入运算的结果

 

生成图片背景:随机的弧线、线段,用于干扰

生成公式:如生成三个随机数,这三个随机数之间插入两个随机的运算符,四则运算分别对应0-3这四个随机数(生成这个公式注意/0的状况)。

公式以字符串形式存在。使用ScriptEngineManager这个类对字符串公式进行运算,得出结果。而后结果对应用户,保存到redis中,后续秒杀请求中,要对提交上来的验证结果进行校验。校验经过以后,将redis中的结果移除掉,由于校验经过一次后续就没有必要再保留了

限流

限制同一个用户一段时间(10秒内或一分钟等)内对接口的访问次数。

每次访问接口,次数记录到redis中,key=接口名+userid。使用incr进行增长,设置这个key的有效期,超过以后自动删除。递增以后,若是大于某个值,则接口返回拒绝访问。

实现思路:拦截器中计算访问次数,没有超过次数,则放行请求。

 

第一次设置的时候,就设置有效期为1s:

注解实现配置化,同时设置好后续要用的用户信息

 

 

建立一个拦截器,获取方法上的注解,并进行计数判断,肯定是否放行(光标处未给出)

在拦截器中获取好user对象,设置到threadlocal中,接着service方法中就能获取到了。

或者使用【注入自定义对象】,在处理器的处理方法中从threadLocal中获取对象,这样service方法直接注入user对象就可使用了

并发瓶颈问题分析

某一个商品进入秒杀时,成为热点数据,同一个数据行的竞争变得很是大。

解决方案A:Redis预减库存

服务启动后,监听bean生命周期,进行redis数据冗余(productId->productCount)。

接收秒杀请求后,执行redis decr命令,返回数据自减以后的值,若是这个值大于0,说明秒杀成功,由于没有操做mysql,实际的操做不多,一会儿就响应给客户端了,提示客户端正在排队中,服务后台往MQ中放一个消息,表示某个用户秒杀到了某个商品。

客户端轮训服务器,以更新当前当前的排队中的状态,以得知是否成功,服务接口查询mysql中的秒杀记录,而后将当前的状态返回给客户端。

服务器不停地消费消息,实现数据落地(mysql中生成订单、库存数量减1、生成秒杀记录)。这里的逻辑相对于秒杀请求来讲,是异步执行的,生成秒杀记录用于给客户端轮训时更新客户端的状态。数据落地过程当中,可能还没开始执行,则当前秒杀记录为空,执行成功、执行失败时,都要记录秒杀记录,标记成功仍是失败;失败的状况有:库存数量减一时,发现没得减了,则失败。但这个状况基本不会出现,由于已经提早冗余了一份数据到redis中,redis能成功减一的话,mysql里也能成功减一的,由于这两份数据在最开始时,数值是同样的。

 

以上的细节优化:

       redis预减库存时,调用decr函数,这其实是一个阻塞的网络请求。这个函数返回的是减了以后的结果,因此只要有一次出现负数,后续的全部调用都会是负数,这样的网络请求没有必要。因此在服务器内存中,创建一个map(productId -> boolean),标记某个商品是否出现负数了,作好标记后,后续再出现redis预减状况,先判断这个map,而不用每次都调用decr函数了,少一次网络请求,提高接口的响应速度。

 

对MQ的理解:

不用MQ以前,触发某些逻辑是直接调用对应的函数的,同步的话,则会形成阻塞。

使用MQ后,触发某些逻辑就不是调用函数了,而是往消息队列中发送一个消息,而后当前函数就结束了;

另外一个地方提早注册好了消息的处理函数,这个函数会被异步执行,也就是这时候才实际触发了某些逻辑。经过消息队列来解除逻辑之间的同步关系,实现异步调用。

 

经过MQ实现了异步调用。这提高了QPS,可是感受有点奇怪,是否是能够认为这个QPS意义不大,就好比一我的来找你办事情,你还没办就直接跟他说我登记一下,你先回去等通知吧,一小时来了一百我的,你都这么处理,而后跟别说,我这一小时内处理了一百我的的事情,说这句话看上去效率很是高,但实际上有变高吗?由于实际要作的事情时延迟作了。不使用异步处理的话,对于一小时的秒杀活动,则全部的数据处理必须在这一小时内处理完,这会给服务器带来大负荷。而使用异步处理后,这一小时内只须要执行redis预减就好了,而不用执行数据落地,这一小时内执行的是秒杀活动最关键的部分,就是谁能抢获得,就只作这个工做,其余有空在作,如实际的数据落地和状态更新能够在这一小时以后才执行,至关于把这一小时内的集中的工做量,平摊到后续的一段时间内了。实际能够理解为:一件事情的处理效率没有本质变化,只不过异步的话,可让完成这件事情有更多的时间,因此完成起来就更加游刃有余了。

 

缺点:分布式redis、分布式MQ的运维成本、保证数据一致性、如何数据回滚、又须要一个分布式系统来记录谁已经秒杀过了,而不能再屡次秒杀、不适合新手

 

解决方案B

mysql测试,同一行的高并发update,能达到4W QPS左右,因此纯粹讨论mysql,也不慢了。

 

常规实现的瓶颈分析,一次秒杀过程须要发送4次数据库指令:

开启事务、update减库存、(期间还可能会存在GC耗时)、insert添加购买明细、提交事务【数据库服务器与java客户端之间也存在网络延时】

占用行级锁的时间 = update语句开始占用的时间(而不是事务开始时间)  至  提交事务/回滚事务,释放行级锁时间

以上这个时间内包含了 GC耗时、update语句结果返回耗时,insert语句和结果往返耗时。简单来讲就是行级锁占用时间=GC耗时 + java服务端与mysql服务端的网络通讯耗时

 

而对同一行数据的秒杀操做,并发量与行级锁占用时间直接相关。以上步骤出现并发执行时,只能串行。由于每一个事务锁住的都是同一行数据。

 

优化方式:把Java客户端逻辑放到mysql服务端,避免网络延迟和GC耗时

1.mysql源码层的修改方案,定制update语句,使update语句更新行为1时,自动提交,小公司基本没有这样的团队实力去作这样的事

2.使用存储过程,存储过程的目的是将一组sql组成一个事务,在数据库服务端完成,避免客户端去完成事务,从而下降性能的消耗(避免了java GC耗时和mysql与java服务器之间的通讯耗时)

 

简单的优化方式:

以上占用行级锁的时间中,包含了insert操做,因此把insert操做移出来,能提高下降行级锁占用时间。

把insert和update的位置互换一下。

被insert的表使用联合主键(用户手机、秒杀商品id),若是成功插入数据,说明用户之前没秒杀过,插入失败则说明是重复秒杀了。插入成功返回1,失败返回0(这个实现经过insert ignore来实现:加了ignore,若是出现主键冲突,则不报错,而是返回0);

仅当插入成功后,才进行update减库存。若是update影响行数是1,则说明能秒杀到,不然影响行数为0,说明没秒杀到。没秒杀到,则抛出异常,好让事务回滚。

以上优化延迟获取行级锁,而且行级锁的获取到释放之间(事务回滚或提交)的操做更少了,则占用行级锁的时间更少了。原来的逻辑,期间有一个insert操做,互换位置以后,insert操做就不占用行级锁时间了。

理解:行级锁占用从update开始至事务结束,期间的操做越少越好,因此把insert移出去,行锁占用时间,并发效率更高。

// 先使用insert ignore插入秒杀记录,返回影响行数
int insertCount = successKilledDao.insertSuccessKilled(seckillId, userPhone);
if (insertCount <= 0) {
    //出现重复秒杀
    throw new RepeatKillException("seckill repeated");
} else {
    // 库存减一,这里开始占用行锁: update table set num = num - 1 where num > 0
    int updateCount = seckillDao.reduceNumber(seckillId, nowTime);
    if (updateCount <= 0) {
        //没有更新到记录,跑出异常,使事务回滚,还原以前的插入操做
        throw new SeckillCloseException("seckill is closed");
    } else {
        SuccessKilled successKilled = successKilledDao.queryByIdWithSeckill(seckillId, userPhone);
        
        // 返回秒杀成功
        return new SeckillExecution(seckillId, SeckillStateEnum.SUCCESS, successKilled);
    }
}

 

深度优化

使用存储过程,避免GC耗时,以及最大程度下降了java服务端与mysql服务器之间的网络通讯耗时。由于使用存储过程,就只有一次请求往返耗时。

insert ignore:若是中已经存在相同的记录(惟一索引、主键来判断),则忽略当前新数据。【success_killed表使用id和phone做为联合主键】

-- 秒杀执行存储过程
DELIMITER $$ -- console ; 转换为 $$
-- 定义存储过程
-- 参数: in 输入参数; out 输出参数
-- row_count():返回上一条修改类型sql(delete,insert,update)的影响行数
-- row_count: 0:未修改数据; >0:表示修改的行数; <0:sql错误/未执行修改sql
CREATE PROCEDURE `seckill`.`execute_seckill`
  (in v_seckill_id bigint,in v_phone bigint,
    in v_kill_time timestamp,out r_result int)
  BEGIN
    DECLARE insert_count int DEFAULT 0;
    START TRANSACTION;
    insert ignore into success_killed
      (seckill_id,user_phone,create_time)
      values (v_seckill_id,v_phone,v_kill_time);
    select row_count() into insert_count;
    IF (insert_count = 0) THEN
      ROLLBACK;
      set r_result = -1;
    ELSEIF(insert_count < 0) THEN
      ROLLBACK;
      SET R_RESULT = -2;
    ELSE
      update seckill
      set number = number-1
      where seckill_id = v_seckill_id
        and end_time > v_kill_time
        and start_time < v_kill_time
        and number > 0;
      select row_count() into insert_count;
      IF (insert_count = 0) THEN
        ROLLBACK;
        set r_result = 0;
      ELSEIF (insert_count < 0) THEN
        ROLLBACK;
        set r_result = -2;
      ELSE
        COMMIT;
        set r_result = 1;
      END IF;
    END IF;
  END;
$$
-- 存储过程定义结束

优化总结

前端控制,点击秒杀以后,隐藏按钮,防止再次点击

动静态数据分离,CDN缓存(将请求从咱们的服务中剥离出去),后端缓存(redis)

事务竞争优化,减小事务锁的时间

 

序列化优化

redis缓存:实现对热点数据的快速存取,分摊mysql的压力

java对象存入redis:序列化为 byte[] 存入,取出byte[] ,反序列为实际对象

 

序列化最高效的是protostuff。这是谷歌protobuf的再次包装,而不须要本身再写描述文件来帮助序列化和反序列化了,包装后能动态生成描述文件

从redis获取数据,并反序列化

序列化对象,并存入redis

 

其余

作好的java服务,不直接暴露出去,而是经过ngnix反向代理

完整附件:https://files.cnblogs.com/files/hellohello/seckill-master.7z

相关文章
相关标签/搜索