秒杀系统优化方案(下)吐血整理

2019-10-11 05:34 来源:未知

高并发的接口/系统有一个共同的特性,那就是”快”。在系统其它条件既定的情况下,系统处理请求越快,用户得到反馈的时间就越短,单位时间内服务器能够处理请求的数量就会越多。所以”快”几乎可以算是高并发系统的要满足的必要条件,要评估一个系统性能如何,某次优化是否提高系统的容量,”快”是一个很直观的衡量标准。

三、并发下的处理

  • 配置数据库连接池C3P0
      配置连接池的原因是因为我们要做一个高并发的秒杀系统,可能一些连接会被锁住,其他的线程就可能会拿不到连接的情况,所以我们要调整一下连接池的属性来更符合我们的场景
<!-- 数据库连接池 -->
<bean id="dataSource" class="com.mchange.v2.c3p0.ComboPooledDataSource">    
     <!-- 配置连接池属性 -->
     <property name="driverClass" value="${driver}"/>
     <property name="jdbcUrl" value="${url}"/>
     <property name="user" value="${username}"/>
     <property name="password" value="${password}"/>

     <!-- c3p0连接池的私有属性 -->
     <property name="maxPoolSize" value="30"/>
     <property name="minPoolSize" value="10"/>
     <property name="autoCommitOnClose" value="false"/>
     <!-- 获取连接超时时间 -->
     <property name="checkoutTimeOut" value="1000"/>
     <!-- 当获取连接失败重试次数 -->
     <property name="acquireRetryAttempsts" value="2"/>
</bean>
  • 事务 锁来防止并发导致数据错乱
      建议所有的数据操作都写在一个sql事务里面。下面举三个例子来说明情况。
    ①签到功能:一天一个用户只能签到一次,签到成功后用户获得一个积分,我们可以把添加签到和添加积分放到一个事务里面,这样在添加失败,或者编辑用户积分失败的时候可以回滚数据。
    ②在高并发情况下用户进行抽奖,很可能会导致用户参与抽奖的时候积分被扣除,而奖品实际上已经被抽完了。我们可以在事务里面,通过WITH(UPDLOCK)锁住商品表,或者update表的奖品剩余数量和最后编辑时间字段,来把数据行锁住,然后进行用户积分的消耗,都完成后提交事务,失败就回滚,这样就放置数据错乱。
//当我们用UPDLOCK来读取记录时可以对取到的记录加上更新锁
//从而加上锁的记录在其它的线程中是不能更改的只能等本线程的事务结束后才能更改
update commodity with (updlock) set count = count-1 where id=?;

③ 如果要实现这样一个需求:cache里面的数据必须每天9点更新一次,其他时间点缓存每小时更新一次。并且到9点的时候,凡是已经打开页面的用户会自动刷新页面。
  这里面包含的用户触发缓存更新的逻辑:用户刷新页面,当缓存存在的时候,会获取到最后一次缓存更新的时间。如果当前时间>9点,并且最后缓存时间在9点之前,则会从数据库中重新获取数据保存到cache中。如果大量用户在9点之前已经打开了页面,而且在9点之后还未关闭页面,那么就会导致在9点的时候会有很多并发请求过来,数据库服务器压力暴增。
  要解决这个问题,最好就是只有一个请求去数据库获取,其他都是从缓存中获取数据。此时,我们就可以用锁来解决:从数据读取到缓存那段代码前面加上锁,这样在并发的情况下只会有一个请求是从数据库里获取数据,其他都是从缓存中获取。

但是不是所有的方法都需要加事务,比如读操作。

  • 事务时间要尽可能短
      当在高并发系统进行写入操作的时候就会锁定你写入的那行代码,要是写入时间很长那么锁定的时间也很长,不利于高并发的操作。特别是网络操作运行时间一般都比较长,所以最好不要穿插进来。

  • 利用缓存处理高并发

  • 把被用户大量访问的静态资源缓存在CDN中
      在秒杀的时候,如果秒杀没有开始,用户看到喜欢的商品,用户就会不停刷新这个页面。所以类似于秒杀详情页这些被用户大量访问的页面静态资源(如html、css、js)就应该部署到CDN节点上,也就是用户访问的那些html已经不在系统中了,而是在CDN节点上。

用户大量刷新→CDN(detail页静态化,静态资源js、css等)→高并发系统
  • 合理使用nosql缓存数据库
      高并发接口,比如秒杀地址接口是没办法使用CDN缓存的,因为CDN适合我这个请求对应的资源不变的,比如JavaScript,JavaScript拿回来在浏览器执行,它的内容是不变的。但是高并发接口的返回数据是在变化的,比如秒杀接口:一开始没有秒杀,随着时间推移已经开启秒杀,再往后秒杀已经关闭了。所以高并发接口不适合放在CDN缓存,但是适合放在服务器端缓存。
      后端缓存可以用应用系统来控制,比如先访问数据库拿到高并发接口的数据,然后放在redis缓存里面,下次访问直接在缓存里面找。
      使用这种方法的好处就是一致性维护成本低:请求地址要求拿到高并发接口的数据的时候,先访问服务器端缓存,若没有再访问数据库。如果高并发接口的数据需要改变的时候,我们可以等待缓存超时再更新数据,或者直接穿透到数据库更新,又或者当数据库数据更新的时候主动更新一下缓存。

  • 使用一级缓存,减少nosql服务器压力
      一级缓存使用站点服务器缓存去存储数据,注意只存储部分请求量大的数据,并且缓存的数据量要控制,不能过分的使用站点服务器的内存而影响了站点应用程序的正常运行。

  • 善用原子计数器
      在秒杀系统中,热点商品会有大量用户参与进来,然后就产生了大量减库存竞争。所以当执行秒杀的时候系统会做一个原子计数器(可以通过redis/nosql实现),它记录的是商品的库存。当用户执行秒杀的时候,就会去减库存,也就是减原子计数器,保证原子性。当减库存成功之后就回去记录行为消息(谁去减了库存),减了会后作为一个消息当到一个分布的MQ(消息队列)中,然后后端的服务器会把其落地到MySQL中。

原子计数器:主要是高并发的统计的时候要用到。比如:
increment() 和 decrement() 操作是原子的读-修改-写操作。为了安全实现计数器,必须使用当前值,并为其添加一个值,或写出新值,所有这些均视为一项操作,其他线程不能打断它。

  • 善用redis的消息队列
      使用redis的list,当用户参与到高并发活动时,将参与用户的信息添加到消息队列中,然后再写个多线程程序去消耗队列(pop数据),这样能避免服务器宕机的危险。
      通过消息队列可以做很多的服务,比如定时短信发送服务,使用sorted set(sset),发送时间戳作为排序依据,短信数据队列根据时间升序,然后写个程序定时循环去读取sset队列中的第一条,当前时间是否超过发送时间,如果超过就进行短信发送。

  • 事务竞争的优化
      在高并发秒杀系统中,第一个用户执行减库存操作,在commit/rollback以前,第二个秒杀用户也要执行减库存,但是因为一个用户得到了锁,其他用户就必须进行等待(因为当事务不去提交/回滚的话行级锁是没办法释放的)。也就是说后面的线程想减库存,必须等到前面的线程释放哈行锁。这就变成了一个串行的操作:同一个商品减库存,大家都要排队等,就产生了大量阻塞操作。而且,sql语句发送给数据库也可能存在网络延迟,这样后面的用户等待时间就更长了。

解决方案
①MySQL源码层的修改方案:在update后面加上这样一句话:/ [auto_commit]/,当你执行完这条update的时候它会自动回滚。回滚的条件是:update影响的记录数是1就可以commit,如果为0就会rollback。也就是不给java客户端和MySQL之间网络延迟,然后再由java客户端其控制commit和rollback,而是直接通过语句发过去你就告诉我commit和rollback。这个成本比较高,需要修改MySQL源码
②使用存储过程:存储过程的本质就是让一组sql组成一组事务,然后再MySQL端完成,避免客户端完成事务造成性能的干扰。一般情况下,spring声明事务和手动控制事务都是客户端控制事务。这些事务在行级锁没有那么高的竞争情况下是完全OK的,但是秒杀是一个特殊的应用场景,它会在同一行中产生热点,大家都竞争同一行,这个时候存储过程就能够发挥作用了,他把整个sql执行过程放在MySQL端完成,MySQL执行sql的效率非常高。*简单的逻辑我们可以使用存储过程,太过复杂的就不要依赖了。

-- 秒杀执行存储过程
DELIMITER $$ -- onsole ; 转换为 $$
-- 定义存储过程
-- 参数:in 输入参数; out 输出参数
-- row_count():返回上一条修改类型sql(delete,insert,upodate)的影响行数
-- 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;
$$
-- 代表存储过程定义结束

DELIMITER ;

SET @r_result = -3;
-- 执行存储过程
call execute_seckill(1001, 13631231234, now(), @r_result);
-- 获取结果
SELECT @r_result;

③通常我们的操作是:减库存(rowLock)→插入购买明细→commit/rollback(freeLock)。我们可以在这个基础上进行一些简单的优化,调换操作的顺序:插入购买明细→减库存(rowLock)→commit/rollback(freeLock),我这样们的延迟就只会发生在update语句这个点上。

  • 脚本合理控制请求
      比如用脚本防止用户重复点击导致多余的请求。

  • 使用具有高并发能力的编程语言去开发

nodejs就是一个具有高并发能力的编程语言,它使用单线程异步时间机制,不会因为数据逻辑处理问题导致服务器资源被占用而导致服务器宕机,我们可以使用NodeJs写web接口。

apache模式,以下简称A模式。一共有三个点餐窗口,三位服务人员,三位厨师(请自行脑补画面,但是别乱想)。顾客在任一窗口点餐[所谓多线程],点完后服务员传达厨师,等待厨师出餐,服务员返给顾客[同步返回响应结果]。顾客本次购物结束。服务员进行下一位顾客的点餐[接收下一个请求]。
  nodejs模式,以下简称N模式。一共只有一个点餐窗口一位服务员[单线程],一位厨师[CPU]。顾客在窗口点餐,点完后服务员传达厨师,厨师进行出餐,而服务员不必等待[不必等待当前请求返回结果],直接进行下一位顾客的点餐,然后继续传达下一个顾客的订单给厨师。厨师挨个完成后抛出给出餐窗口[异步返回响应结果],顾客到出餐窗口取餐,本次购物结束。

比如要统计用户通过各种方式(如点击图片/链接)进入到商品详情的行为次数,如果同时有1w个用户同时在线访问页面,一次拉动滚动条屏幕页面展示10件商品,这样就会有10w个请求过来,服务端需要把请求的次数数据入库,这样服务器分分钟给跪了。
  要解决这些访问量大的数据统计接口的问题,我们可以通过nodejs写一个数据处理接口,把统计数据先存到redis的list中,然后再使用nodejs写一个脚本,脚本的功能就是从redis里取出数据保存到mysql数据库中。这个脚本会一直运行,当redis没有数据需求要同步到数据库中的时候,sleep,然后再进行数据同步操作。

  • 集群
      集群是一种多服务器结构,也就是把同一个业务,部署在多个服务器上(区别于分布式,分布式是把个业务分拆多个子业务,部署在不同的服务器上),这样就可以提高单位时间内执行的任务数来提升效率,把压力分担到多台服务器上。
      我们可以集群部署Mysql数据库,或者NoSQL DB服务器(如mongodb服务器、redis服务器),把一些常用的查询数据,并且不会经常变化的数据保存到NoSQL DB服务器,来减少数据库服务器的压力,加快数据的响应速度。

  • 构建一个好的服务器架构
    大致的服务器架构如下:
    服务器
    ├负载均衡
    │├Nginx
    │└阿里云SLB
    ├资源监控
    └分布式

    数据库
    ├主从分离、集群
    ├分布式
    └表优化、索引优化等

NoSQL
├redis
│├主从分离
│└集群
├mongodb
│├主从分离
│└集群
├memcache
│├主从分离
│└集群
└...
CDN
├html
├css
├js
└image

高并发情境中,更新用户相关缓存需要分布式存储,比如使用用户ID进行hash分组,把用户分不到不用的缓存中,这样一个缓存集合的总量不会很大,不会影响查询效率。

3.4    中间代理层

可利用负载均衡(例如反响代理Nginx等)使用多个服务器并发处理请求,减小服务器压力。

秒杀业务分析

秒杀业务的典型特点有:

  1. 瞬时流量大
  2. 参与用户多,可秒杀商品数量少
  3. 请求读多写少
  4. 秒杀状态转换实时性要求高

一次秒杀的流程可以分为三个阶段:

  1. 活动未开始活动开始前,用户进入活动页,这个阶段有两种请求,一种是加载活动页信息,一个是查询活动状态得到未开始的结果, 一个用户进入页面两个请求各发起一次,这两种请求占比各半。
  2. 活动进行中这个阶段持续时间非常短,看到抢购按钮的用户大量发起秒杀请求,瞬时秒杀请求占比增高,能不能抗住秒杀请求就是秒杀系统是否能抗住高并发的关键。
  3. 活动结束当商品被抢购完,进入结束状态,请求情况同活动开始前

图片 1各阶段流量图其实贯穿整个活动的只有三种请求,加载活动页请求,读取活动状态请求,秒杀请求

一、什么是高并发

高并发是指在同一个时间点,有大量用户同时访问URL地址,比如淘宝双11、定时领取红包就会产生高并发;又比如贴吧的爆吧,就是恶意的高并发请求,也就是DDOS攻击(通过大量合法的请求占用大量网络资源,以达到瘫痪网络的目的)。

3.5.2   服务层

当用户量非常大的时候,拦截流量后的请求访问量还是非常大,此时仍需进一步优化。

1.    业务分离:将秒杀业务系统和其他业务分离,单独放在高配服务器上,可以集中资源对访问请求抗压。——应用的拆分

2.    采用消息队列缓存请求:将大流量请求写到消息队列缓存,利用服务器根据自己的处理能力主动到消息缓存队列中抓取任务处理请求,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。

3.    利用缓存应对读请求:对于读多写少业务,大部分请求是查询请求,所以可以读写分离,利用缓存分担数据库压力。

4.    利用缓存应对写请求:缓存也是可以应对写请求的,可把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。

可以将缓存和消息中间件 组合起来,缓存系统负责接收记录用户请求,消息中间件负责将缓存中的请求同步到数据库。

 

方案:**本地标记 redis预处理 RabbitMQ异步下单 客户端轮询**

描述:通过三级缓冲保护,1、本地标记 2、redis预处理 3、RabbitMQ异步下单,最后才会访问数据库,这样做是为了最大力度减少对数据库的访问。

实现:

  1. 在秒杀阶段使用本地标记对用户秒杀过的商品做标记,若被标记过直接返回重复秒杀,未被标记才查询redis,通过本地标记来减少对redis的访问
  2. 抢购开始前,将商品和库存数据同步到redis中,所有的抢购操作都在redis中进行处理,通过Redis预减少库存减少数据库访问
  3. 为了保护系统不受高流量的冲击而导致系统崩溃的问题,使用RabbitMQ用异步队列处理下单,实际做了一层缓冲保护,做了一个窗口模型,窗口模型会实时的刷新用户秒杀的状态。
  4. client端用js轮询一个接口,用来获取处理状态

加载活动页请求

主要是展示活动相关配置信息,活动背景图片,优惠力度,活动规则等相对静态的内容,通过web项目渲染成页面。

对于这样的请求,我们可以使用varnish反向代理,以页面相关的参数比如本次秒杀的活动ID和城市ID的hash为key把整个页面缓存在varnish机器上,而秒杀活动的状态等动态信息通过ajax来刷新。

图片 2varnish作用机制

达到的效果是活动期间,加载页面请求都会打到varnish机器直接返回,而不会给web和service带来任何压力。

秒杀状态就三种,未开始,可抢,已抢完,由两个因素共同决定

  1. 活动开始时间
  2. 剩余库存

读取秒杀状态的请求数并发也是非常高的,对于这个接口也要加上合适的缓存来处理。 对于活动开始时间,是一个较固定且不会发生变化的属性,并且,同时在线的秒杀活动数目并不多,所以把它也作为discount相关的信息,选择用响应快的ehcache来缓存。

对于库存,剩余库存个数,一般来说是全局需要一致的,可以用memcached来缓存,在秒杀的过程中,库存变化的非常快,如果直接对库存个数进行缓存,那么秒杀期间就需要频繁的更新缓存,像之前说的,虽然缓存是用来扛并发的,但要调用缓存的时机也要合理,memcached处理的并发请求越少,相对成功率就会越高。 其实对于秒杀活动来说,当时的剩余库存数在秒杀期间变化非常快,某个时间点上的库存个数并没有太大的意义,而用户更关心的是 能不能抢,true or false。如果缓存true or false的话,这个值在秒杀期间是相对稳定的,只需要在库存耗尽的时候更新一次,而且为了防止这一次的更新失败,可以重复更新,利用memcached的cas操作,最后memcached也只会真正执行一次set写操作。 因为秒杀期间查询活动状态的请求都打在memcached上,减少写的频率可以明显减轻memcached的负担。

其实活动状态除了活动时间和库存之外,还有第三个因素来决定,下面说到秒杀请求的优化时会详细来说

二、高并发带来的后果

  • 服务端
      导致站点服务器、DB服务器资源被占满崩溃。
      数据的存储和更新结果和理想的设计不一致。
  • 用户角度
      尼玛,网站这么卡,刷新了还这样,垃圾网站,不玩了。

3.5     后端优化

那么,如何才能做得快呢?有两个需要注意的原则 :

3.5.1   控制层(网关层)

限制同一UserID访问频率:尽量拦截浏览器请求,但针对某些恶意攻击或其它插件,在服务端控制层需要针对同一个访问uid,限制访问频率。

1.    利用缓存

设置缓存有效时间,在缓存中计数,如果在缓存的有效时间内请求的次数超了的话,就返回请求访问太频繁。

2.    利用RateLimiter

RateLimiter是guava提供的基于令牌桶算法的限流实现类,通过调整生成token的速率来限制用户频繁访问秒杀页面,从而达到防止超大流量冲垮系统。(令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

做得少

世界上最快的程序,是什么都不做的程序。一个接口负责的功能越少,读取信息量越少,速度越快。

对于一个需要承受高并发的接口,在功能上,尽量不涉及一些难以缓存和预热的数据。 一个典型的例子,用户维度个性化的数据,用户和用户的信息不同,userId数量又很多,即使加上缓存,缓存命中率依然很低,压力还是会打到数据库,不光接口快不了,高并发的sql也会给数据库带来风险。

举一个例子,在点评电影早期的秒杀活动页上,展示了一个用户当前秒杀资格的信息,由于不同用户抢到秒杀资格的时间、优惠不同,每次都需要读数据库的来取,也就是每个用户进入主页都会产生一条sql。还有一个例子,一般电商搞大促的时候,比如同时有多个优惠活动可以降低商品的价格,而一般只展示最低价的优惠,同时用户一个优惠只能参与一次,这样不同用户参与了不同活动之后可以享受的最低价就会随之改变,如果要在商品页面上展示这个动态价格,就免不了取到各个用户参加这些在线优惠的信息。

如果遇到这样的数据,要怎么解决呢?一个办法是尝试转移数据的维度:刚才说的秒杀活动资格信息,如果以用户userId为key,会出现缓存命中率低,仍要sql读的情况,但是能够秒到的用户数量其实很少,所以如果以这次秒杀活动id为key,存储一个成功秒到用户的userid的list,就能够解决缓存命中率低的问题。

还有一个办法是可以把这些需要个性化数据的功能在业务流程上后移,流量漏斗,越往后流量越少,创建订单级的sql查询是可接受的。 刚才说的第二个例子,商品最优惠的价格,可以排除用户相关信息,只在商品列表/详情上展示只和优惠相关的最低价,而在提交订单的时候才真正去取用户参加活动情况,如果用户已经参加过给出提示并选择次优的优惠。商品的列表/详情页都在用户路径上相对靠前的位置,排除了用户个性化信息可以让商品列表/详情更容易缓存,响应速度更快,系统可承受的高并发量更高。

我们写业务代码的时候都有对应的业务对象,它们都存在一定的业务范围之内,比如类目、地区、日期等自身相关的维度。 一个系统中的业务对象,在多个维度的细分下,对应的量并不多,但如果一次全部都展示在一个页面/接口下,即使覆盖上了缓存,也会由于缓存占用空间过大或者缓存key数目过多、网络传输耗时、对象序列化反序列耗时等拖慢接口/页面响应速度。一般只要看一下这个页面/接口给出的业务对象的数量级,就能大致知道这个接口的性能了。

大家在做设计的时候,一般会估算一个接口的量级,如果一看就有几千几万个业务对象,就不会这样设计了,但是需要警惕的是业务对象数量级可变的情况,比如随着业务发展数量会快速增长,或者某些特殊维度下业务对象特别多。设计的时候要按照预估的最大量级来,并且对接口/页面做出数量的限制,如果发现当前返回的业务对象过多,可以继续根据业务维度来拆分,分次分批来处理。

举一个例子,比如一个影院下所有的活动场次,开始的时候一家影院下的场次有限,几十一百场,很好展示,后来随着业务发展,一个影院下各个影院下场次数到了几百一千,一次全部拿完,在高并发时,memcached缓存的multi get会出现很多超时,请求会打到mysql数据库,给系统很大压力。之后我们做了改造项目,每次根据用户的交互按照影片、日期、影院的维度来分批取,一次只有十几个场次,接口响应变快了,服务的压力也小的多。

3.7  优化秒杀流程

  1. 秒杀活动开始之前有个活动倒计时,时间到了则会放开秒杀的权限,并生成一个验证码展示在前面页面,并把验证结果存在redis中,这里利用redis有过期时间的特性,也给验证码的缓存加了个过期时间。这里的redis缓存用的是redis的string类型。
  2. 在秒杀之前先要填一个验证码verifyCode,点击秒杀按钮时,先发送ajax请求到后台获取真实的秒杀地址path,这里秒杀地址是隐藏的,目的是防止有人恶意刷秒杀接口。所谓隐藏地址,其实是在请求地址中加一段随机字符串,这段字符串是变化的,因此秒杀请求地址是动态的;
  3. 先说下如何获取真实的秒杀地址,后台先访问redis,验证一下这个验证码有没有过期以及这个verifyCode是不是正确,验证码验证通过后,先删除这个验证码缓存,然后生成真实地址;
  4. 真实地址随机字符串由uuid以及md5加密生成,并且保存在redis中,并且设置了有效期;
  5. 从浏览器端向秒杀地址发起请求,带上path参数去后台调用真正的秒杀接口,下面是秒杀接口的逻辑
  6. 访问redis,验证path有没有过期,以及是不是正确。这里验证path以及上面的校验验证码,都是用userId对应生成的一个key值去取redis中的数据;
  7. path验证通过后,先访问内存标识,看秒杀的这个商品有没有卖完,减少对redis的不必要访问。每一种参与秒杀活动的商品都在内存里用HashMap设置了一个标识,标识某个商品id商品是否卖完了。这里的是否卖完的内存标识设置以及每种参与秒杀商品的库存存入redis是在系统启动时做的;
  8. 如果内存标识中这个商品没有卖完,则要看这个用户在这次活动中是否重复秒杀,因为我们的秒杀规则是一个用户id对于某个商品id的商品只能秒杀一件。如何判断该用户有没有秒杀过这件商品呢,秒杀记录也保存在redis缓存中
  9. 如果判断秒杀过则返回提示,如果没有秒杀过,继续;
  10. 上面说过系统加载时redis中保存了各商品对应的库存,这里用到redis的原子操作的方法decr,将对应商品的库存减1,此时数据库时的库存还没有减,因此是预减库存
  11. desc方法返回该商品此时的库存,如果小于0,说明商品已经卖完了,此次秒杀无效,并且设置该商品的内存标识为true,表示已卖完
  12. 正确地预减库存后,然后就要真正操作数据库了,数据库一般是性能瓶颈,比较耗时,因此决定用异步方式处理。对于每一条秒杀请求存入消息队列RabbitMQ中,消息体中要包含哪个用户秒杀哪个商品的信息,这里是封装了一个消息体类,这样一个秒杀请求就进入了消息队列,一个秒杀请求还没有完成,真正的秒杀请求的完成得要持久化到数据库,生成订单,减了数据库的库存才能算数,这时在客户端显示的一般是排队中,比如以前在抢购小米手机时,我就看到这样的展示,过一会再刷新页面就显示没抢到;
  13. 消息队列处理秒杀请求。先从消息体中解析出用户id和商品id,查数据库看这个商品是否卖完了查数据库看该用户对于这个商品是否有过秒杀记录数据库减库存,数据库生成订单,这两项持久化地写数据库操作放在同一个事务中,要么都执行成功,要么都失败。并把秒杀记录对象,包括秒杀单号、订单号、用户id、商品id,存入redis中如果数据库减库存失败,表明商品卖完了,则要在redis中设置该商品已卖完的标识消息队列处理秒杀请求。先从消息体中解析出用户id和商品id,查数据库看这个商品是否卖完了查数据库看该用户对于这个商品是否有过秒杀记录
  14. 数据库减库存,数据库生成订单,这两项持久化地写数据库操作放在同一个事务中,要么都执行成功,要么都失败。并把秒杀记录对象,包括秒杀单号、订单号、用户id、商品id,存入redis中如果数据库减库存失败,表明商品卖完了,则要在redis中设置该商品已卖完的标识
  15. ajax发起秒杀请求,秒杀请求的处理逻辑最后也只是把这条请求放入消息队列,并不能返回是否秒杀成功的结果。因此,当秒杀请求正确响应后,即请求放入消息队列后,需要另外一个请求去轮询秒杀结果,秒杀成功的标志是生成秒杀订单,并把秒杀订单对象放入redis中。所以轮询秒杀结果,只用去轮询redis中是否有对应于该用户的该商品的秒杀订单对象,如果有,则表明秒杀成功,并在前台给出提示。

上面的秒杀流程对应的流程图如下:
步骤1到12,主体是redis预减库存,生成消息队列:

图片 3

 

步骤13到14是处理消息队列:

图片 4

步骤15,是客户端请求秒杀结果:

图片 5

 

秒杀请求

秒杀请求是一个秒杀系统能不能抗住高并发的关键 因为秒杀请求和之前两个请求不同,它是写请求,不能缓存,而且是活动峰值的主力。

一个用户从发出秒杀请求到成功秒杀简单地说需要两个步骤: 1. 扣库存 2. 发送秒杀商品 这是至少两条数据库操作,而且扣库存的这一步,在mysql的innodb引擎行锁机制下,update的sql到了数据库就开始排队,期间数据库连接是被占用的,当请求足够多时就会造成数据库的拥堵。 可以看出,秒杀请求接口是一个耗时相对长的接口,而且并发越高耗时越长,所以首先,一定要限制能够真正进行秒杀的人数。

图片 6秒杀流程图

上面说了,秒杀业务的一个特点是参与人数多,但是可供秒杀的商品少,也就是说只有极少部分的用户最终能够秒杀成功 比如有2500个名额,理论上来说先发送请求的2500个用户能够秒杀成功,这2500个用户扣库存的sql在数据库排队的时候,库存还没有消耗完,比如2500个请求,全部排队更新完是需要时间的,就比如说0.5s 在这个时间内,用户会看到当前仍然是可抢状态,所以这段时间内持续会有秒杀请求进入,秒杀的高峰期,0.5秒也有几万的请求,让几万条sql来竞争是没有意义的,所以要限制这些参与到扣库存这一步的人数。

可抢状态需要第三个因素来决定,那就是当前秒杀的排队人数。 加在判断库存剩余之前,挡上一层排队人数的校验, 即有库存 并且 排队人数 < 限制请求数 = 可抢,有库存 并且 排队人数 >= 限制请求数 = 抢完

比如2500个名额秒杀名额,目标放过去3000个秒杀请求

那么排队人数记在哪里? 这个可以有所选择,如果只记请求个数,可以用memcached的计数,一个用户进入秒杀流程increase一次,判断库存之前先判断队列长度,这样就限制了可参与秒杀的用户数量。

图片 7排队秒杀流程图

发起秒杀先去问排队队列是不是已满,满了直接秒杀失败,同时可以去更新之前缓存了是否可抢 true or false的缓存,直接把前台可抢的状态变为不可抢。没满继续查询库存等后续流程,开始扣库存的时候,把当前用户id入队。 这样,就限制了真正进入秒杀的人数。

这种方法,可能会有一个问题,既然限制了请求数,那就必须要保证放过去的用户能够秒完商品,假设有重复提交的用户,如果重复提交的量大,比如放过去的请求中有一半都是重复提交,就会造成最后没秒完的情况,怎么屏蔽重复用户呢? 就要有个地方来记参与的用户id,可以使用redis的set结构来保存,这个时候set的size代表当前排队的用户数,扣库存之前add当前用户id到set,根据add是否成功的结果,来判断是否继续处理请求。

最终,把实际上几万个参与数据库操作的用户从减少到秒杀商品的级别,这是一个数据库可控制的范围,即使参与的用户再多,实际上也只处理了秒杀商品数量级的请求。

1.分库存 一般这样做就已经能够满足常规秒杀的需求了,但有一个问题依然没有解决,那就是加锁扣库存依然很慢 假设的活动秒杀的商品量能够再上一个量级,像小米卖个手机,一次有几W到几十万的时候,数据库也是扛不住这个量的,可以先把库存数放在redis上,然而单一库存加锁排队依然存在,库存这个热点数据会成为扣库存的瓶颈。

一个解决的办法是 分库存,比如总共有50000个秒杀名额,可以分50份,放在redis上的50个不同的key,那么每份上1000个库存,用户进入秒杀流程后随机到其中一个库存来修改,这样有50个库存数来竞争,缩短请求的排队时间。

这样专门为高并发设计的系统最大的敌人 是低流量,在大部分库存都好近,而有几个剩余库存时, 用户会看到明明还能抢却总是抢不到,而在高并发下,用户根本就觉察不到。

2.异步消息 如果有必要继续优化,就是扣库存和发货这两个费时的流程,可以改为异步,得到秒杀结果后通过短信/push异步通知用户。 主要是利用消息系统削峰填谷的特性 来增加系统的容量。

图片 8流量图先用varnish挡掉了所有的读取状态请求 然后用ehcache缓存活动时间,挡掉活动未开始时查询活动状态的请求 memcached缓存是否可抢的状态,挡掉活动开始后到结束状态的活动查询请求 redis队列挡掉了活动进行中,过量的秒杀请求 到最后只留下了秒杀商品数量级的请求到数据库中。

1.      如何解决库存的超卖问题?

卖超原因:

(1)一个用户同时发出了多个请求,如果库存足够,没加限制,用户就可以下多个订单。(2)减库存的sql上没有加库存数量的判断,并发的时候也会导致把库存减成负数。

解决办法:

(1):在后端的秒杀表中,对user_id和goods_id加唯一索引,确保一个用户对一个商品绝对不会生成两个订单。

(2):我们的减库存的sql上应该加上库存数量的判断

数据库自身是有行级锁的,每次减库存的时候判断count>0,它实际上是串行的执行update的,因此绝对不会卖超!。

UPDATE seckill

        SET number = number-1

        WHERE seckill_id=#{seckillId}

        AND start_time <#{killTime}

        AND end_time >= #{killTime}

        AND number > 0;

 2.    如何解决少卖问题—Redis预减成功而DB扣库存失败?

前面的方案中会出现一个少卖的问题。Redis在预减库存的时候,在初始化的时候就放置库存的大小,redis的原子减操作保证了多少库存就会减多少,也就会在消息队列中放多少。

现在考虑两种情况:

1)数据库那边出现非库存原因比如网络等造成减库存失败,而这时redis已经减了。

2)万一一个用户发出多个请求,而且这些请求恰巧比别的请求更早到达服务器,如果库存足够,redis就会减多次,redis提前进入卖空状态,并拒绝。不过这两种情况出现的概率都是非常低的。

两种情况都会出现少卖的问题,实际上也是缓存和数据库出现不一致的问题

但是我们不是非得解决不一致的问题,本身使用缓存就难以保证强一致性:

在redis中设置库存比真实库存多一些就行。

3.   秒杀过程中怎么保证redis缓存和数据库的一致性?

在其他一般读大于写的场景,一般处理的原则是:缓存只做失效,不做更新。

采用Cache-Aside pattern:

失效:应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。

更新:先把数据存到数据库中,成功后,再让缓存失效。

 4.  Redis中的库存如何与DB中的库存保持一致?

Redis中的数量不是库存,它的作用仅仅时候只是为了阻挡多余的请求透传到db,起到一个保护DB的作用。因为秒杀商品的数量是有限的,比如只有10个,让1万个请求去访问DB是没有意义的,因为最多只有10个请求会下单成功,剩余的9990个请求都是无效的,是可以不用去访问db而直接失败的。

因此,这是一个伪问题,我们是不需要保持一致的。

 5.   为什么要隐藏秒杀接口?

html是可以被右键->查看源代码,如果秒杀地址写死在源文件中,是很容易就被恶意用户拿到的,就可以被机器人利用来刷接口,这对于其他用户来说是不公平的,我们也不希望看到这种情况。所以我们可以控制让用户在没有到秒杀时间的时候不能获取到秒杀地址,只返回秒杀的开始时间。

当到秒杀时间的时候才返回秒杀地址即seckill_id以及根据seckill_id和salt加密的MD5,前端再次拿着seckill_id和MD5才能执行秒杀。假如用户在秒杀开始前猜测到秒杀地址seckill_id去请求秒杀,也是不会成功的,因为它拿不到需要验证的MD5。这里的MD5相当于是用户进行秒杀的凭证。

6.   一个秒杀系统,500用户同时登陆访问服务器A,服务器B如何快速利用登录名(假设是电话号码或者邮箱)做其他查询?

主从复制,读写分离

 

一个大型网站应用一般都是从最初小规模网站甚至是单机应用发展而来的,为了让系统能够支持足够大的业务量,从前端到后端也采用了各种各样技术,前端静态资源压缩整合、使用CDN、分布式SOA架构、缓存、数据库加索引、读写分离等等。 这些技术是高并发系统所必须的,但是今天先不细说,而先谈谈在这些架构既定的情况下,一些高并发业务/接口实现时应该注意的原则,以及通过工作中一个6万QPS的秒杀活动,来介绍一下秒杀业务的特点以及如何优化。

3.5.3  数据库层

  数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入队列和缓存,让最底层的数据库高枕无忧。但依然可以进行如下方向的优化:

 对于秒杀系统,直接访问数据库的话,存在一个【事务竞争优化】问题,可使用存储过程(或者触发器)等技术绑定操作,整个事务在MySQL端完成,把整个热点执行放在一个过程当中一次性完成,可以屏蔽掉网络延迟时间,减少行级锁持有时间,提高事务并发访问速度。

 

  1. 做得少,一方面是指在功能特性上有所为,有所不为,另一方面是指一次处理的信息量要少。
  2. 做得巧,根据业务自身的特点,选择合理的业务实现方式,选择合理的缓存类型和缓存调用时机。

4. 问题解析

做得巧

平时涉及到的业务,总有属于它的特性,比如实时性要求多高,数据一致性要求多高,涉及什么维度的数据,量有多大等等,我们要根据这些特性来选择实现的方案,比如一些统计数据,如某类目下所有商品的最低价,按照逻辑需要遍历商品来获取,但这样每次实时读取所有的对象,涉及读取缓存数据库操作,接口会很耗时,但如果选择作业离线计算,把计算结果写表,加上缓存,搜索直接读取,显然会快很多了。

涉及到业务各阶段特性的例子就是秒杀系统,在第二部分秒杀实践中我会详细介绍。

除了业务特性方面,缓存是业务对抗高并发非常重要的一个环节,合理选择缓存的类型和调用缓存的时机非常重要。

我们知道内存运算速度快于远程连接,所以存储上来说效率如下 内存 <= ehcache < redis <= memcached < mysql 可以看出,尽量少的远程连接,常规覆盖数据库访问的缓存,都能提高程序的性能。

要根据不同缓存的特性和原理,才能根据业务选出最合适的,来看看几种常用的缓存 :

  1. varnish,可以作为反向代理,缓存一些资源,例如可以把struts,freemarker动态生成的页面存储起来,达到直接挡掉到达web服务器的请求。
  2. ehcache,主要存储在当前机器内存中,存取非常快,缺点是内存有限,各台机器内存中各存一份,失效时间不一致,数据就会出现不一致,一般用来缓存不常变化,且缓存个数较少的数据。
  3. memcached缓存,kv分布式缓存集群,可扩展性好,可以存储个数较多的缓存对象,也可以承接高流量的访问,读取缓存时远程连接,一般耗时也在零点几到几ms不等。
  4. redis,nosql,是内存的kv存储,可以做为缓存使用,也可以持久化,它的性能和memcached相近。而redis最大的特点是一个data-structure store,这时redis官网首页介绍redis的第一句话,它可以保存list,hash,set,sorted set等数据结构,使用时和memcached区别是,它不用将数据取到客户端再做逻辑判断,而是可以直接在redis服务器上完成操作,比如查看某个元素是不是一个范围内,队列的长度有多长等。redis可以用来做分布式服务器的进程间的通信,比如我们经常有需要分布式锁的场景,控制同一个用户发券的并发等。

根据业务需要选择了合适类型的缓存后,还要合理去使用。 虽然说缓存是为了抵挡数据库的流量而生,本身性能非常强大,但仍然是受到缓存服务器性能甚至服务器网卡流量的限制的,不合理的使用比如单个key对应的缓存对象过大、一次读取中缓存key数量过多、短时间内频繁更新缓存等都是系统的隐患、并发越高时就越能体现。

3.3   前端优化

3.1   初始方案问题分析

在前面针对数据库的优化中,由于数据库行级锁存在竞争造成大量的串行阻塞,我们使用了存储过程(或者触发器)等技术绑定操作,整个事务在MySQL端完成,把整个热点执行放在一个过程当中一次性完成,可以屏蔽掉网络延迟时间,减少行级锁持有时间,提高事务并发访问速度。

可是问题时并发的流量实际上都是直接穿透让MYSQL自己去抗,比如说库存是否卖完以及用户是否重复秒杀都完全是靠查询数据库去判断,造成数据库不必要的负担非常大,然而这些都可以放在缓存做一个标记在服务层进行拦截,对于中小规模的并发还可以,但是真正的超高并发,显然这个还不完善。

3.3.2   限流手段

1. 使用数学公式验证码

描述:点击秒杀前,先让用户输入数学公式验证码,验证正确才能进行秒杀。

好处:

1)防止恶意的机器人和爬虫

2)分散用户的请求

实现:

1)前端通过把商品id作为参数调用服务端创建验证码接口

2)服务端根据前端传过来的商品id和用户id生成验证码,并将商品id 用户id作为key,生成的验证码作为value存入redis,同时将生成的验证码输入图片写入imageIO让前端展示。

3)将用户输入的验证码与根据商品id 用户id从redis查询到的验证码对比,相同就返回验证成功,进入秒杀;不同或从redis查询的验证码为空都返回验证失败,刷新验证码重试

 

2. 禁止重复提交

用户提交之后按钮置灰,禁止重复提交 

接上篇秒杀系统优化方案(上)吐血整理

3. 深入优化设计

3.2    优化的方向和思路

方向:将请求尽量拦截在系统上游

传统秒杀系统之所以挂,请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小【一趟火车其实只有2000张票,200w个人来买,基本没有人能买成功,请求有效率为0】 

图片 9

思路:限流和削峰

限流:屏蔽掉无用的流量,允许少部分流量流向后端。

削峰:瞬时大流量峰值容易压垮系统,解决这个问题是重中之重。常用的消峰方法有异步处理、缓存和消息中间件等技术。

 

异步处理:秒杀系统是一个高并发系统,采用异步处理模式可以极大地提高系统并发量,其实异步处理就是削峰的一种实现方式。

缓存:秒杀系统本身是一个典型的读多写少的应用场景【一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%】,非常适合使用缓存。

消息队列:消息队列可以削峰,将拦截大量并发请求,这也是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理。

3.3.1   静态资源缓存

  1. 页面静态化

对商品详情和订单详情进行页面静态化处理,页面是存在html,动态数据是通过接口从服务端获取,实现前后端分离,静态页面无需连接数据库打开速度较动态页面会有明显提高。

2.页面缓存

通过CDN缓存静态资源,来抗峰值。不使用CDN的话也可以通过在手动渲染得到的html页面缓存到redis。

版权声明:本文由彩民之家高手论坛发布于编程技术,转载请注明出处:秒杀系统优化方案(下)吐血整理