秒杀系统的特点

  • 高性能:秒杀涉及到大量的并发读和并发写,因此支持高并发访问这点非常的关键。
  • 一致性:秒杀是有限数量的商品在同一时刻被很多倍的请求同时来减库存,在大并发更新的过程中都要保证数据的准确性。
  • 高可用:秒杀时会在瞬间涌入大量的流量,为了避免系统宕机,保证高可用,需要做好流量限制。

秒杀系统的优化思路

后端优化

  1. 限流:秒杀系统往往是多于商品数量数倍的请求来抢购,我们可以屏蔽掉无用的流量,允许少部分流量走后端。
  2. 削峰:秒杀请求在时间上高度集中于某一个时间点,随时的流量很容易压垮系统,因此需要对流量进行削峰处理,缓冲瞬时流量,尽量让服务器平缓的处理这些请求。
  3. 异步:将同步请求转化为异步请求,来提高并发量。
  4. 利用缓存:创建订单时,每次都需要先查询判断库存,只有少部分成功的请求才会创建订单,因此可以将商品信息放在缓存中,减少数据库查询。
  5. 负载均衡:利用Nginx进行负载均衡,减轻单个服务器的压力。

前端优化

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

防作弊优化

  1. 隐藏秒杀接口:为了避免在秒杀开始之前被用户发现刷接口,因此需要用户在没到秒杀开始不能获取秒杀接口,只有秒杀开始了,才返回秒杀地址url和验证MD5,用户拿到这两个数据才可以进行秒杀。
  2. 对僵尸账号限制:对于一些僵尸账号,可以检测账号的活跃度或者等级信息来进行限制。当然也可以通过用户画像限制僵尸号。

如何做好限流

在应对秒杀,大促销等高性能压力的场景时,为了保证系统的平稳运行,必须针对超过预期的流浪,通过预先设定的限流规则选择性的对某些请求进行限流“熔断”。

下面就介绍一下,我了解到的一些限流算法。

计数器算法

通过一个计数器counter来统计一段时间内请求的数量,并且在指定的时间之后重置计数器。该算法实现简单,但是会出现临界问题
比如,我们的限流规则是每秒不超过100次请求。假如,第一个1s的时间窗口内,请求集中到最后的10ms内,在第二个1s的时间窗口内,100次请求都集中在最开始的10ms内,那们实际上在短短的20ms内就集中的200次请求,那么很能就会压垮系统。

计数器限流算法的Redis Lua实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

-- 资源的唯一标识位
local key=KEYS[1]
-- 限流大小
local limit=tonumber(ARGV[1])

-- 获取当前的流量
local currentLimit=tonumber(redis.call('get',key) or "0")

if currentLimit+1>limit then
-- 已经达到限流大小,返回0
return 0
else
-- 没有达到阈值value+1
redis.call('INCRBY',key,1)
-- 设置过期时间
redis.call('EXPIRE',key,2)
return currentLimit+1
end

滑动窗口算法

滑动窗口算法是计数器算法的一种改进,将原来的一个时间窗口划分为多个时间窗口,并且不断向右滑动该窗口。流量经过滑动时间窗口整形之后,可以保证任意时间窗口内,都不会超过最大允许的限流值,从而流量曲线回更加平滑,可以部分解决上面提到的临界突发流量问题。
但是基于时间窗口的限流算法,只能在选定的时间粒度上限流,对选定时间粒度内的更加细粒度的访问频率不做限制。

令牌桶法

令牌桶法的工作流程如下;

  1. 如果在t秒内限制请求的数量为n,那么每过t/n秒向桶内添加一个token。
  2. 如果令牌桶内的token的数量超过限制b,那么多于的token会被抛弃。
  3. 每次请求进入之时,需要先尝试从令牌桶中拿token,只有拿到了token才会处理接口请求,否则进行限流处理。

基于Redis Lua的令牌桶限流算法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
-- 令牌的唯一标识
local bucketKey = KEYS[1]
-- 上次请求的时间
local last_mill_request_key = KEYS[2]
-- 令牌桶的容量
local limit = tonumber(ARGV[1])
-- 请求令牌的数量
local permits = tonumber(ARGV[2])
-- 令牌流入的速率
local rate = tonumber(ARGV[3])
-- 当前时间
local curr_mill_time = tonumber(ARGV[4])

-- 添加令牌

-- 获取当前令牌的数量
local current_limit = tonumber(redis.call('get', bucketKey) or "0")
-- 获取上次请求的时间
local last_mill_request_time = tonumber(redis.call('get', last_mill_request_key) or "0")
-- 计算向桶里添加令牌的数量
if last_mill_request_time == 0 then
-- 如果是第一次请求,那么令牌桶初始化
-- 更新上次请求时间
redis.call("HSET", last_mill_request_key, curr_mill_time)
return 0
else
-- 计算应该添加的令牌的数量
local add_token_num = math.floor((curr_mill_time - last_mill_request_time) * rate)
end

-- 更新令牌的数量
if current_limit + add_token_num > limit then
-- 如果当前的令牌数量已经超过了容量,多余的则抛弃
current_limit = limit
else
current_limit = current_limit + add_token_num
end
-- 更新令牌的数量
redis.pcall("HSET",bucketKey, current_limit)
-- 设置过期时间
redis.call("EXPIRE", bucketKey, 2)

-- 限流判断

if current_limit - permits < 1 then
-- 达到限流大小(令牌不够)
return 0
else
-- 没有达到限流大小
current_limit = current_limit - permits
-- 更新令牌的数量
redis.pcall("HSET", bucketKey, current_limit)
-- 设置过期时间
redis.call("EXPIRE", bucketKey, 2)
-- 更新上次请求的时间
redis.call("HSET", last_mill_request_key, curr_mill_time)
end

漏桶法

相比于令牌桶算法,漏桶法对于去令牌的频率也有限制,要按照t/n的固定速率来取令牌。

限流规则的合理性

限流规则包含三个部分:时间粒度,接口粒度,最大限流值。时间粒度的选择尤其重要。比如我们可以选择1秒钟不超过1000次,也可以选择10毫秒不超过10次。虽然看起来一致,但是实际的效果却大不系统。比如1秒钟不超过1000次,但是很可能1000次请求就集中在几毫秒内。但如果选择10毫秒不超过10次,会导致误杀许多不应该限流的请求。因此时间粒度的选择要根据实际情况,灵活调整。

如何利用好缓存

缓存可以解决哪些问题

  • 提升性能
    在绝大多数的应用中,查询数据库都是非常耗时的。而很多时候其实都是读多写少的,这个时候正确的使用缓存可以极大的提升系统的性能。

  • 缓解数据库压力
    当用户请求增多的时候增多时,数据库的压力将大大增加,通过缓存能够大大降低数据库的压力。

因此我们可以看出,缓存使用于那些读多写少的情况。还使用于一些对性能要求高的场景,比如秒杀。

缓存的三种模式

Cache Aside 更新模式

这种工作模式是比较常见的工作模式了。
其具体的流程是:

  • 失效:应用程序先从cache中取数据,如果没有拿到,则从数据库中取数据,成功后放到缓存中。
  • 命中:应用程序从cache中取数据,取到后返回。
  • 更新:先把数据存到数据库中,成功后再让缓存失效。

1MZjMD.png

注意点

  1. 先更新数据库,再更新缓存可能会出现并发写操作导致脏数据。这种方法其实是实际应用中推荐的,但是理论上仍然存在问题。假如,有两个线程再同时并发的进行更新,先更新数据库的反而后更新缓存,那么就可能导致缓存中的数据是脏数据。
  2. 先更新缓存,再更新数据库,这个逻辑是错误的,因为并发的读和写可能导致脏数据,假如,有两个线程同时并发的进行更新,一个线程删除了缓存,此时第二个线程来读取缓存,没有命中,然后从数据库中取出老数据,并更新回缓存。这个时候第一个线程也把数据库更新了。这个时候缓存中数据就是过期的就数据了。

Read/Write Through更新模式

在Read/Write Through更新模式中,应用程序只需要维护缓存,数据库的维护由缓存来代理。

1Mmtc8.png
这种模式相较于 Cache Aside模式,缓存的维护工作不再由调用方负责了,而是由缓存服务自己来加载。

Write Behind Caching 更新模式

Write Behind Caching更新模式就是在更新数据时,只更新缓存,不更新数据库,缓存会异步的批量的更新数据库。这样的好处在于可以合并多次更新,是直接的内存操作。但是问题在于,数据不再是强一致的,而且可能丢失。

1MmLuD.png