秒杀系统分析与总结

秒杀是一种常见的营销手段,商品以极低的价格,在特定的时间点开售,引发大量的用户抢购,制造轰动效应。但是它给网站技术带来了极大的挑战,下面我们首先看下秒杀活动的一些业务特性与行为:

1,活动时间短,并发流量极高,对网站的其他业务形成冲击

2,因为秒杀活动,用户访问量突然增加,大大超过平时网站的带宽

3,秒杀活动前,用户为了不错过秒杀,不停的刷详情页,如果请求一直穿透到后端服务器和数据库,会对后端服务器和数据库造成很大的压力

4,并不是所有的用户都会规规矩矩的通过访问页面来进行秒杀,有部分童鞋会对整个页面进行分析,努力找出下单的接口,绕过页面的时间等待,直接对下单接口发起调用,这样不用等待就可以下单了

针对上面的问题,我们主要有以下策略:

1,秒杀系统独立部署,采用独立的域名,与网站现有业务完全隔离。及时秒杀系统崩溃,也不会影响网站原有业务。

2,通过购买或者租借的方式升级带宽以应对秒杀新增的网络带宽,同时购买CDN服务,将一些页面缓存在CDN上,这样可以大大减轻服务器的压力。

3,秒杀系统商品详情页面静态化,这样用户访问静态页不再需要进行业务逻辑处理,也不需要访问数据库。

4,秒杀下单接口动态化生成,只有秒杀开始后才会呈现出来,同时确保即使是秒杀系统的开发者在开始秒杀前也不知道下单url。

5,各个秒杀活动之间分时错开进行,避免同一时刻对网站造成巨大冲击

有了上面的应对策略,下面我们逐个分析秒杀系统中存在的高并发点,看下秒杀系统该如何设计。

一般来说,我们的网站架构大致有如下几层:

秒杀系统分析与总结_第1张图片

然后我们的秒杀流程大致如下:

秒杀系统分析与总结_第2张图片

每一位秒杀成功的用户不可以再参与秒杀。根据以上的分析,能够成功秒杀的用户毕竟是少数,所以多数用户的请求是无效的,我们的出发点在于尽可能的将请求拦截在上游,页面尽可能做得简单下面将根据上面的秒杀流程,逐步分析其中的高并发点以及可能存在的问题,给出具体的解决思路。

1,秒杀列表页

用户无需登录就可以看到秒杀列表页,一般来说,哪些商品会参与秒杀活动都是提前准备好的,所以短时间内页面内容基本没有变化,可以把该页面缓存下来,这里我们使用nginx的缓存功能(当然也可以使用ehcache,redis等进行缓存,不过我们需要遵循一个原则,尽可能的把请求拦截在上游),可以避免后续访问列表页的请求到数据库,其配置大致如下:

a,新建缓存目录用于缓存页面

mkdir –p /home/chengzhang/nginx/cache/webpages

b,配置nginx conf文件

upstream myseckill_http {
    server 127.0.0.1:8080;
    server 127.0.0.1:8081;
}

proxy_cache_path /home/chengzhang/nginx/cache/webpages levels=1:2 keys_zone=webpages:30m max_size=512m;

server {
    listen       80;
    server_name  www.myseckill.com;

    location /seckill/list {
        proxy_pass     http://myseckill_http/myseckill/seckill/list;
        proxy_redirect off;

        add_header X-Cache      $upstream_cache_status;
        proxy_set_header   Host             $host;
        proxy_set_header   X-Real-IP        $remote_addr;
        proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;

        client_max_body_size       10m;
        client_body_buffer_size    128k;

        proxy_connect_timeout      90;
        proxy_send_timeout         90;
        proxy_read_timeout         90;

        proxy_buffer_size          4k;
        proxy_buffers              4 32k;
        proxy_busy_buffers_size    64k;
        proxy_temp_file_write_size 64k;
        keepalive_timeout 30;
        proxy_cache webpages;
        proxy_cache_valid 200 10m;
    }

    location / {
        proxy_pass     http://www.myseckill.com/myseckill/;
        proxy_redirect off;
    }
}

对上面的配置其中几项解释下:

A、proxy_cache_path

格式:proxy_cache_path path [levels=numbers] keys_zone=zone_name:zone_size[inactive=time] [max_size=size]

path:缓存文件存放的位置

levels:缓存目录结构,可以是1、2、3位数字作为目录,最多是3位数字如:1,1:2

keys_zone:指定缓存池名字及大小,每个定义缓存路径必须不同,这里就叫做webpages

inactive:设置每个缓存区缓存文件的有效时长,超过该时长没被访问的缓存被删除,这里设置为30min

max_size:设置不活动的缓存大小,不活动的缓存超过该大小后被删除,这里设置为512m

B,proxy_cache_valid

格式:proxy_cache_valid reply_code [reply code…|any] time;

reply_code:不同的应答代码

time:为不同应答设置不同缓存时长 默认为分钟m,这里应答码200的时间设置为10min

any:代表任何代码

C,$upstream_cache_status

缓存的状态---可能的值为:MISS(未命中)、Hint(命中)、Expired(请求传递到后台)、Stale(后端得到过期的应答)、Updating(正更新,使用旧的应答)等。如果缓存的状态为HINT,就说明命中了缓存,也就是调用了缓存文件。这里加这个header的目的在于方便区分是否访问了缓存。

第一次访问时,如下图所示:

秒杀系统分析与总结_第3张图片

 

可以看到此时访问cache的状态为MISS,第一次访问后,会在目录下生成cache文件:

秒杀系统分析与总结_第4张图片

再次访问列表页:

秒杀系统分析与总结_第5张图片

可以看到,第二次访问命中了缓存,这一点可以从后台日志也可以验证请求没有访问到后端。这里还有个问题需要考虑的是,当缓存失效的那一瞬间如果有大量的请求进来可能会全部穿透到后端访问数据库。这里我们引入一把分布式锁,只有获取到锁的请求才可以访问数据库,然后将页面缓存到redis,返回。而没有获取到这把锁的请求,统一返回提示,3秒后自动刷新列表页,代码大致如下:

String html = getHtmlFromCache();
if (null == html) {
	// 缓存中没有
	if(getRedisLock(key)){
		//获取到了锁
		// 截取生成的html并放入缓存
		ResponseWrapper wrapper = new ResponseWrapper(resp);
		filterChain.doFilter(servletRequest, wrapper);
		html = wrapper.getResult();
		putIntoCache(html);
		resp.setContentType("text/html; charset=utf-8");
		resp.getWriter().print(html);
	}else {
		//没有获取到锁时提示3s后自动再次访问列表页
		resp.setContentType("text/html; charset=utf-8");
		resp.setHeader("refresh","3;url=/seckill/list");
		String message = "当前访问量过多,3秒后自动再次访问,如果失败请手动刷新";
		resp.getWriter().write(message);
	}
}

另一个需要考虑的是css,js,图片(如果有)等,这些是静态资源,如果全部放在服务端,势必对服务器造成较大压力,这里需要CDN来缓解这种压力。

2,秒杀详情页

秒杀详情页应该是访问量最大的页面了,用户为了保证不错过秒杀,会不停的刷详情页,所以静态资源这些需要放在cdn上减少服务端压力。秒杀详情页中的各种信息都相对固定,唯一变化的是当前时间(用于倒计时,多个节点之间需要进行时间同步),所以我们可以把秒杀商品的相关信息缓存起来(比如开始时间,结束时间,库存等),每次访问详情页的时候附上一个实时的当前时间即可。同时在秒杀开始前的刷新基本都是无用功,所以我们可以优化下,第一次进入详情页后,禁止用户手动进行页面刷新(F5),当计时器到时间后自动reload当前页面,当然这种方式只能禁止掉普通用户刷页面,不过也能减少一大部分请求了。

3,秒杀接口url的获取

我们的初衷是,秒杀真正开始前,不能够让用户知道实际的下单接口,也不能有任何单独的接口途径可以供尝试获取下单url。只有到秒杀开始时,才有正确的url下发到客户端。同时为了防止开发人员自己作弊,这个url一定要是个动态的url,我们可以通过md5的方式把加密的结果放在url路径中,这样在实际下单的时候可以校验是否为合法的url。url的生成方式为url=/seckill/+md5(goods_id+sign(固定的一串字符,放在配置文件中,上线时特定人员配置,开发无法查看)+yymmddhhmmss)+/execute,甚至我们可以把时间也加入了进去,这样秒杀接口是动态变化的,只有同一秒访问的请求才是同一个下单地址,这样可以过滤掉大部分的无效请求(秒杀本来就是靠人品的),生成url的操作都是在内存中进行的,不涉及到数据库操作,响应速度还是很快的。

4,执行秒杀

执行秒杀时,会有大量的请求涌入到后端,而我们的商品库存毕竟是少数,所有需要进行过滤,保证只有少数的信息穿透到数据库,我们的处理如下:

1,商品信息在应用启动的时候就初始化加入到redis缓存中,为了防止万一数据库出现异常(比如短暂的抖动了下),redis缓存中的库存我们设置为实际库存中的两倍,这样后续对商品信息的操作都先在库存中进行操作

2,进入秒杀函数时进行必要的参数校验,比如url是否正确等,校验不通过直接返回秒杀失败

3,用户秒杀成功后记录信息到缓存中,如果用户下次再次请求进来,直接返回秒杀已成功

4,对同一个用户的访问进行限流,比如同一个用户1秒内只允许访问一次

5,使用redis计数器,当缓存中的库存数小于0时,返回秒杀失败

6,根据压测中数据库的tps处理能力进行平滑限流

7,考虑到redis master万一异常,在主从切换过程中出现同一用户获取到两把锁,我们的数据库表uk为设置为userId+goodsId,保证同一商品一个用户只能秒杀成功1次

经过上面的层层校验与拦截,最终访问到db的请求少之又少,确保db能够正常处理得过来!

根据以上的思路,本人写了一个简单的demo,已上传到github上https://github.com/reverence/seckill

5,需要考虑的配置优化

1,nginx的可靠性

可以配置两台nginx,一台提供线上服务,一台冗余保证高可用。利用keepalived+virtual ip机制提供服务,当线上的nginx挂了后keepalived机制会探测到,自动切换另一台nginx。

2,redis的可靠性

这里我们采用redis sentinel理论上可以保证访问的可靠性,但是如果访问量巨大的话,可能得考虑分片了,每一个分片中都采用redis sentinel机制。

3,数据库的可靠性

一般分为读可靠性和写可靠性,读可靠性可以通过冗余读库,比如有多个从库提供读。我们这里的秒杀主要考虑写的可靠性,我们不能考虑单纯的主从切换后从库提升为主库就立即提供服务,这样可能从库数据还没同步完导致数据不一致而发生超卖,一种方式为我们提供两个主库,数据库都正常时写入数据的时候同时写两个主库,只有当两个主库都写成功时才返回成功,当其中一个数据库挂了后只写另一个库。另一方式为继续采用主从架构,我们写一个小脚本,轮询检测到数据同步完成后,再提升从库为主库开放写操作,同步这期间写操作都失败。个人认为后一种方式更好,毕竟大多数情况下主库都是好的。

4,非常重要的一点,整套系统最好部署在同一个机房,这样可以大大减少跨机房访问带来的网络延迟。

5,根据具体的业务场景进行优化。

未完待续,可能有遗漏,回想起来了再来补充!

 

你可能感兴趣的:(分布式系统,个人工作中的总结)