实践出真知:全网最强秒杀系统架构解密,不是所有的秒杀都是秒杀(中篇)
高并发“黑科技”与致胜奇招
假设,在秒杀系统中我们使用Redis实现缓存,假设Redis的读写并发量在5万左右。我们的商城秒杀业务需要支持的并发量在100万左右。如果这100万的并发全部打入Redis中,Redis很可能就会挂掉,那么,我们如何解决这个问题呢?接下来,我们就一起来探讨这个问题。
在高并发的秒杀系统中,如果采用Redis缓存数据,则Redis缓存的并发处理能力是关键,因为很多的前缀操作都需要访问Redis。而异步削峰只是基本的操作,关键还是要保证Redis的并发处理能力。
解决这个问题的关键思想就是:分而治之,将商品库存分开放。
暗度陈仓
我们在Redis中存储秒杀商品的库存数量时,可以将秒杀商品的库存进行“分割”存储来提升Redis的读写并发量。
例如,原来的秒杀商品的id为10001,库存为1000件,在Redis中的存储为(10001, 1000),我们将原有的库存分割为5份,则每份的库存为200件,此时,我们在Redia中存储的信息为(10001_0, 200),(10001_1, 200),(10001_2, 200),(10001_3, 200),(10001_4, 200)。
此时,我们将库存进行分割后,每个分割后的库存使用商品id加上一个数字标识来存储,这样,在对存储商品库存的每个Key进行Hash运算时,得出的Hash结果是不同的,这就说明,存储商品库存的Key有很大概率不在Redis的同一个槽位中,这就能够提升Redis处理请求的性能和并发量。
分割库存后,我们还需要在Redis中存储一份商品id和分割库存后的Key的映射关系,此时映射关系的Key为商品的id,也就是10001,Value为分割库存后存储库存信息的Key,也就是10001_0,10001_1,10001_2,10001_3,10001_4。在Redis中我们可以使用List来存储这些值。
在真正处理库存信息时,我们可以先从Redis中查询出秒杀商品对应的分割库存后的所有Key,同时使用AtomicLong来记录当前的请求数量,使用请求数量对从Redia中查询出的秒杀商品对应的分割库存后的所有Key的长度进行求模运算,得出的结果为0,1,2,3,4。再在前面拼接上商品id就可以得出真正的库存缓存的Key。此时,就可以根据这个Key直接到Redis中获取相应的库存信息。
移花接木
在高并发业务场景中,我们可以直接使用Lua脚本库(OpenResty)从负载均衡层直接访问缓存。
这里,我们思考一个场景:如果在秒杀业务场景中,秒杀的商品被瞬间抢购一空。此时,用户再发起秒杀请求时,如果系统由负载均衡层请求应用层的各个服务,再由应用层的各个服务访问缓存和数据库,其实,本质上已经没有任何意义了,因为商品已经卖完了,再通过系统的应用层进行层层校验已经没有太多意义了!!而应用层的并发访问量是以百为单位的,这又在一定程度上会降低系统的并发度。
为了解决这个问题,此时,我们可以在系统的负载均衡层取出用户发送请求时携带的用户id,商品id和秒杀活动id等信息,直接通过Lua脚本等技术来访问缓存中的库存信息。如果秒杀商品的库存小于或者等于0,则直接返回用户商品已售完的提示信息,而不用再经过应用层的层层校验了。 针对这个架构,我们可以参见本文中的电商系统的架构图(正文开始的第一张图)。
Redis助力秒杀系统
我们可以在Redis中设计一个Hash数据结构,来支持商品库存的扣减操作,如下所示。
seckill:goodsStock:${goodsId}{
totalCount:200,
initStatus:0,
seckillCount:0
}
在我们设计的Hash数据结构中,有三个非常主要的属性。
- totalCount:表示参与秒杀的商品的总数量,在秒杀活动开始前,我们就需要提前将此值加载到Redis缓存中。
- initStatus:我们把这个值设计成一个布尔值。秒杀开始前,这个值为0,表示秒杀未开始。可以通过定时任务或者后台操作,将此值修改为1,则表示秒杀开始。
- seckillCount:表示秒杀的商品数量,在秒杀过程中,此值的上限为totalCount,当此值达到totalCount时,表示商品已经秒杀完毕。
我们可以通过下面的代码片段在秒杀预热阶段,将要参与秒杀的商品数据加载的缓存。
/**
* @author binghe
* @description 秒杀前构建商品缓存代码示例
*/
public class SeckillCacheBuilder{
private static final String GOODS_CACHE = "seckill:goodsStock:";
private String getCacheKey(String id) {
return GOODS_CACHE.concat(id);
}
public void prepare(String id, int totalCount) {
String key = getCacheKey(id);
Map<String, Integer> goods = new HashMap<>();
goods.put("totalCount", totalCount);
goods.put("initStatus", 0);
goods.put("seckillCount", 0);
redisTemplate.opsForHash().putAll(key, goods);
}
}
秒杀开始的时候,我们需要在代码中首先判断缓存中的seckillCount值是否小于totalCount值,如果seckillCount值确实小于totalCount值,我们才能够对库存进行锁定。在我们的程序中,这两步其实并不是原子性的。如果在分布式环境中,我们通过多台机器同时操作Redis缓存,就会发生同步问题,进而引起“超卖”的严重后果。
在电商领域,有一个专业名词叫作“超卖”。顾名思义:“超卖”就是说卖出的商品数量比商品的库存数量多,这在电商领域是一个非常严重的问题。那么,我们如何解决“超卖”问题呢?
Lua脚本完美解决超卖问题
我们如何解决多台机器同时操作Redis出现的同步问题呢?一个比较好的方案就是使用Lua脚本。我们可以使用Lua脚本将Redis中扣减库存的操作封装成一个原子操作,这样就能够保证操作的原子性,从而解决高并发环境下的同步问题。
例如,我们可以编写如下的Lua脚本代码,来执行Redis中的库存扣减操作。
local resultFlag = "0"
local n = tonumber(ARGV[1])
local key = KEYS[1]
local goodsInfo = redis.call("HMGET",key,"totalCount","seckillCount")
local total = tonumber(goodsInfo[1])
local alloc = tonumber(goodsInfo[2])
if not total then
return resultFlag
end
if total >= alloc + n then
local ret = redis.call("HINCRBY",key,"seckillCount",n)
return tostring(ret)
end
return resultFlag
我们可以使用如下的Java代码来调用上述Lua脚本。
public int secKill(String id, int number) {
String key = getCacheKey(id);
Object seckillCount = redisTemplate.execute(script, Arrays.asList(key), String.valueOf(number));
return Integer.valueOf(seckillCount.toString());
}
这样,我们在执行秒杀活动时,就能够保证操作的原子性,从而有效的避免数据的同步问题,进而有效的解决了“超卖”问题。
- 点赞
- 收藏
- 关注作者
评论(0)