秒杀抢购可以说是在分布式环境下一个非常经典的案例。
和普通的电商流程不同,秒杀有如下特点:低廉价格、大幅推广、瞬时售空、一般是定时上架、时间短、瞬时并发量高。
里边有很多痛点比如:
1.高并发: 时间极短、 瞬间用户量大,而且用户会在开始前不断刷新页面,还会积累一大堆重复请求的问题,一瞬间的高QPS把系统或数据库直接打死,响应失败,导致与这个系统耦合的系统也GG,一挂挂一片。
2.链接暴露: 通过技术手段获取秒杀的url,然后在开始前通过这个url传要传递的参数来进行购买操作。
3.超卖: 你只有一百件商品,由于是高并发的问题,一起拿到了最后一件商品的信息,都认为还有,全卖出去了,最终卖了100+件,仓库里根本没这么多货。
4.恶意请求: 因为秒杀的价格比较低,有人会用脚本来秒杀,全给一个人买走了,他再转卖,或者同时以脚本操作多个账号一起秒杀。(就是常见的黄牛党)。
本文将使用jmeter工具模仿瞬时并发,基于redis来实现一个秒杀功能。没有实现前端页面。
一、系统分析
假设有一个需求:在某天的12点,开启秒杀,限量100件商品,每个用户会有唯一ID。
首先分析下, 秒杀系统为秒杀而设计,不同于一般的网购行为,参与秒杀活动的用户更关心的是如何能快速刷新商品页面,在秒杀开始的时候抢先进入下单页面并,而不是商品详情等用户体验细节,因此秒杀系统的页面设计应尽可能简单。
1.1 前端思路:
展示秒杀商品的页面, 页面上有一个秒杀活动开始的倒计时, 在准备阶段内用户会陆续打开这个秒杀的页面, 并且可能不停的刷新页面。
这里需要考虑两个问题:
第一个:资源静态化
秒杀页面的展示我们知道一个html页面还是比较大的,即使做了压缩,http头和内容的大小也可能高达数十K,加上其他的css, js,图片等资源,如果同时有几千万人参与一个商品的抢购,一般机房带宽也就只有1G~10G,网络带宽就极有可能成为瓶颈,所以这个页面上各类静态资源首先应分开存放,然后放到cdn节点上分散压力,由于CDN节点遍布全国各地,能缓冲掉绝大部分的压力。
第二个:时间同步
倒计时出于性能原因这个一般由js调用客户端本地时间,就有可能出现客户端时钟与服务器时钟不一致,另外服务器之间也是有可能出现时钟不一致。
客户端与服务器时钟不一致可以采用客户端定时和服务器同步时间,用于同步时间的接口由于不涉及到后端逻辑,只需要将当前web服务器的时间发送给客户端就可以了,这个接口可以只返回一小段json格式的数据,而且可以优化一下减少不必要cookie和其他http头的信息,所以数据量不会很大,一般来说网络不会成为瓶颈
第三个:操作控制
(1)产品层面,用户点击“购买”或者“下单”后,按钮置灰,禁止用户重复提交请求;
(2)js层面,限制用户在x秒之内只能提交一次请求;
前端层的请求拦截,只能拦住小白用户(不过这也是98%的用户了),有点基础的程序员根本不会吃这一套,写个循环直接调用的http请求,怎么办?
(1)同一个uid,限制访问频度,做页面缓存,x秒内到达站点层的请求,均返回同一页面
(2)同一个item的查询,例如手机车次,做页面缓存,x秒内到达站点层的请求,均返回同一页面
如此限流,又有99%的流量会被拦截在站点层。
至此前端可以为服务端减轻很大的压力。接下来看后端设计思路:
1.2 后端思路:
针对如下几点做出解决方案:
提前知道了url:
可以对url进行加密如 使用随机字符串进行MD5加密,活动中点击秒杀按钮才返回url,和MD5加密字符拼接成完整url:
#PostMapping("/kill/{userid}/{md5}/url")
可以在服务网关层加过滤条件如After:
routes:
- id: 1
# ...
- id: 2
uri: lb://kill-server
predicates:
- Path=/kill/exec
- After=2019-12-01T12:00:00.00+08:00[Asia/Shanghai] # 在2019-12-01 12:00:00.000之后才可以访问
高并发:
nginx做负载均衡(一个tomcat可能只能抗住几百的的并发,nginx还可以对一些恶意ip做限制)
写请求,可以将商品总数做到缓存或队列中,每次只透过有限的写请求去数据层,如果均成功再放下一批,如果库存不够则请求全部返回“已售完”
读请求,cache集群来抗。 提前把数据库里的东西提前加载到redis来 。
超卖问题:
悲观锁、乐观锁、redis均可以解决。
但是我个人更加倾向于使用redis解决,在大并发环境下对数据库的操作一定要慎之又慎,能cache的就不要访问库。redis是单线程串行执行的
,利用redis的单线程预减库存。
比如商品有100件。那么我在redis存储一个k,v。如count:100。每一个用户线程进来,count减1,等减到0的时候,全部拒绝剩下的请求。所以一定不会出现超卖的现象
恶意请求:
联合主键索引或者Redis Set数据结构均可解决。还是上面说的,更倾向于redis。
联合主键索引 :把用户id和商品id作为联合主键索引存储到数据库中,下次如果再想插入一样用户id和商品id的行,是会报错的。(主键索引的唯一性),事务中出现错误,支付操作自然也失败了。
Redis Set数据结构:要知道,set数据结构是不需重复的,我们可以巧妙地使用这点,建立一个<kill,userID>的set结构,点击抢购就对此userID进行add。每次点击前在判断下是否存在次ID即可。
还有诸如MQ削峰解耦、数据库读写分离等一些列应对并发操作的手段在此不一一列举。
二、实现细节
新建一个服务,此处限定开始时间可以直接使用网关After配置,见上面代码。
因为需要整合lua,所以配置DefaultRedisScriptConfig类, 在应用上下文中配置好,避免在执行的时候重复创建脚本的SHA降低性能。
@Configuration
public class DefaultRedisScriptConfig {
@Bean
public DefaultRedisScript<String> redisScript() {
DefaultRedisScript<String> redisScript = new DefaultRedisScript<>();
redisScript.setResultType(String.class);
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("luascript/seckill.wlua")));
return redisScript;
}
}
seckill.wlua文件位置:
seckill.wlua 文件具体代码如下:
local userid=KEYS[1];
local date=KEYS[2];
local kckey='Seckill:'..date..":kc";
local userKey='Seckill:'..date..":user";
local userExists=redis.call("sismember", userKey, userid);
if tonumber(userExists)==1 then
return 2;
end
local num = redis.call("get", kckey);
if tonumber(num)<=0 then
return 0;
else
redis.call("decr", kckey);
redis.call("sadd", userKey, userid);
end
return 1;
还有RedisTemplate:
@Configuration
public class RedisConfig {
/*
关于序列化。
spring-data-redis提供如下几种选择:
- GenericToStringSerializer: 可以将任何对象泛化为字符串并序列化
- Jackson2JsonRedisSerializer: 跟JacksonJsonRedisSerializer实际上是一样的
- JacksonJsonRedisSerializer: 序列化object对象为json字符串
- JdkSerializationRedisSerializer: 序列化java对象
- StringRedisSerializer: 简单的字符串序列化
*/
@Resource
private LettuceConnectionFactory lettuceConnectionFactory;
@Bean
public RedisTemplate<String, Serializable> redisTemplate() {
RedisTemplate<String, Serializable> template = new RedisTemplate<>();
template.setConnectionFactory(lettuceConnectionFactory);
RedisSerializer<?> stringSerializer = new StringRedisSerializer();
jackson2JsonRedisSerializer.setObjectMapper(om);
template.setKeySerializer(stringSerializer);
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(stringSerializer);
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}
Controller关键方法:
/**
* 秒杀入口
* @param map 传入参数
* @return String
*/
@PostMapping("/bf")
public String bf( @RequestBody Map<String, String> map ){
String key = "bf:count";
Long increment = redisTemplate.opsForValue().increment(key, 1);
log.info(Thread.currentThread().getName() + " - 访问计数:" + increment );
JSONObject jo = new JSONObject();
try {
String res = seckillService.doSeckill(map.get("id"));
jo.put("message", res);
jo.put("success", true);
} catch (Exception e) {
e.printStackTrace();
jo.put("message", "系统繁忙");
jo.put("success", false);
}
return jo.toJSONString();
}
Service层关键方法:
@Resource
private RedisTemplate redisTemplate;
@Resource
private DefaultRedisScript redisScript;
public String doSeckill( String userID ){
String date = DateUtil.format(new Date(),"yyyyMMdd");
Object res = redisTemplate.execute((RedisConnection connection) -> connection.eval(
redisScript.getScriptAsString().getBytes(),
ReturnType.INTEGER,
2,
userID.getBytes(),
date.getBytes()
));
long a = res == null ? -1 : (Long) res;
String message = "";
if( a == 1 ){
message = "抢购成功";
}else if( a== 0 ){
message = "商品已经被抢完啦";
}else {
message = "每个账号限购一次!请勿重复操作。";
}
log.info(message);
return message;
}
在单元测试保证每个方法都正常后,启动项目。
Jmiter配置 3秒2000线程 打过去。
这里post的请求参数定义一个随机uuid。
可以看到控制台疯狂输出:
注意:101是因为我提前在redis中放了一个测试id,所以第100次访问显示了101。
在计数超过100的时候都会显示抢完,此时在使用RedisDesktop观察数据:
用来存放已抢购userID的set的size为100。即100个用户。
再看商品,为0。抢完了。
验证是否只能抢一次,在已抢购userID的set中,随便复制一个id。
使用postMan来再次访问秒杀接口:
可以看到提示请勿重复操作。即实现了限制重复购买功能。
至此基于Boot+redis+lua实现的秒杀模块全部实现。 希望能对遇到相同问题的朋友有所帮助。
以上。