秒杀服务
0、前言
0.1 秒杀的难点
友好的用户体验
用户不能接受破窗的体验,例如:系统超时、系统错误的提示,或者直接 404 页面
瞬时高并发流量的挑战
木桶短板理论,整个系统的瓶颈往往都在 DB,如何设计出高并发、高可用系统
0.2 漏斗型实现
漏斗型业务,指的是,用户的请求,从客户端到 db 层,层层递减,递减的程度视业务而定。例如当 10w 人去抢 1 个物品时,db 层的请求在个位数量级,这就是比较理想的模型。如下图所示
0.3 不同层次的设计理念
产品设计
轻重逻辑分离,以秒杀为例,将抢到和到账分开;
抢到,是比较轻的操作,库存扣成功后,就可以成功了
到账,是比较重的操作,需要涉及到到事务操作
用户分流,以整点秒杀活动为例,在 1 分钟内,陆续对用户放开入口,将所有用户请求打散在 60s 内,请求就可以降一个数量级
页面简化,在秒杀开始的时候,需要简化页面展示,该时刻只保留和秒杀相关的功能。例如,秒杀开始的时候,页面可以不展示推荐的商品。
客户端
重试策略非常关键,如果用户秒杀失败了,频繁重试,会加剧后端的雪崩。如何重试呢?根据后端返回码的约定,有两种方法:
不允许重试错误,此时 ui 和文案都需要有一个提示。同时不允许重试
可重试错误,需要策略重试,例如二进制退避法。同时文案和 ui 需要提示。
ui 和文案,秒杀开始前后,用户的所有异常都需要有精心设计的 ui 和文案提示。例如:【当前活动太火爆,请稍后再重试】【你的货物堵在路上,请稍后查看】等
前端随机丢弃请求可以作为降级方案,当用户流量远远大于系统容量时,人工下发随机丢弃标记,用户本地客户端开始随机丢弃请求。
接入层
所有请求需要鉴权,校验合法身份
如果是长链接的服务,鉴权粒度可以在 session 级别;如果是短链接业务,需要应对这种高并发流量,例如 cache 等
根据后端系统容量,需要一个全局的限流功能,通常有两种做法:
设置好 N 后,动态获取机器部署情况 M,然后下发单机限流值 N/M。要求请求均匀访问,部署机器统一。
维护全局 key,以时间戳建 key。有热 key 问题,可以通过增加更细粒度的 key 或者定时更新 key 的方法。
对于单用户/单 ip 需要频控,主要是防黑产和恶意用户。如果秒杀是有条件的,例如需要完成 xxx 任务,解锁资格,对于获得资格的步骤,可以进行安全扫描,识别出黑产和恶意用户。
逻辑层
逻辑层首先应该进入校验逻辑,例如参数的合法性,是否有资格,如果失败的用户,快速返回,避免请求洞穿到 db。
异步补单,对于已经扣除秒杀资格的用户,如果发货失败后,通常的两种做法是:
事务回滚,回滚本次行为,提示用户重试。这个代价特别大,而且用户重试和前面的重试策略结合的话,用户体验也不大流畅。
异步重做,记录本次用户的 log,提示用户【稍后查看,正在发货中】,后台在峰值过后,启动异步补单。需要服务支持幂等
对于发货的库存,需要处理热 key。通常的做法是,维护多个 key,每个用户固定去某个查询库存。对于大量人抢红包的场景,可以提前分配
存储层
对于业务模型而言,对于 db 的要求需要保证几个原则:
可靠性
主备:主备能互相切换,一般要求在同城跨机房
异地容灾:当一地异常,数据能恢复,异地能选主
数据需要持久化到磁盘,或者更冷的设备
一致性
对于秒杀而言,需要严格的一致性,一般要求主备严格的一致。
一、秒杀需求
实现一个秒杀的业务,可以是秒杀热门、特卖的商品、也可以是秒杀稀缺物品、也可以是秒杀一个资格、也可以是秒杀一个红包等等。
秒杀是一种常见的营销手段,商品以极低的价格,在特定的时间点开售,引发大量的用户抢购,制造轰动效应。活动时间短,并发流量极高,对网站的其他业务形成冲击。
二、秒杀分析
2.1 难点分析
秒杀页面静态化--Freemarker
将商品的描述、参数、成交记录、图像、评价等全部写入到一个静态页面,用户请求不需要通过访问后端服务器,不需要经过数据库,直接在前台客户端生成,这样可以最大可能的减少服务器的压力。
接口限流--RateLimter实现令牌桶
接口限流的策略有很多,我们这里采用令牌桶算法,guava提供了RateLimter
redis预减库存--Redis
很多请求进来,都需要后台查询库存,这是一个频繁读的场景。可以使用redis来预减库存,在秒杀开始前可以在redis设值
异步下单---RabbitMQ
为了提升下单的效率,并且防止下单服务的失败。需要将下单这一操作进行异步处理。最常采用的办法是使用队列,队列最显著的三个优点:异步、削峰、解耦
秒杀接口动态化---动态加密+Redis有效期
为了避免有程序访问经验的人通过下单页面url直接访问后台接口来秒杀货品,我们需要将秒杀的url实现动态化,即使是开发整个系统的人都无法在秒杀开始前知道秒杀的url。
2.2 秒杀相关接口
秒杀活动:创建 审核 查询-列表 查询倒计时
秒杀商品:创建 上架-静态化 列表 详情 查询下单接口-动态化
秒杀订单:下单 查询 超时
三、秒杀设计
数据:
T_SKILLACTIVITY[秒杀活动表]
T_SKILLGOODS[秒杀商品表]
T_SKILLORDER[秒杀订单表]
T_SKILLORDERLOG[秒杀订单流水表]
T_SKILLOG[秒杀记录表]
CREATE TABLE t_skillactivity(
id INT NOT NULL AUTO_INCREMENT COMMENT '主键' ,
title VARCHAR(255) COMMENT '名称' ,
info VARCHAR(255) COMMENT '描述' ,
picurl VARCHAR(500) COMMENT '活动图片' ,
stime DATETIME COMMENT '开始时间' ,
etime DATETIME COMMENT '结束时间' ,
flag INT COMMENT '状态' ,
ctime DATETIME COMMENT '创建时间' ,
maxcount INT COMMENT '最大购买量' ,
PRIMARY KEY (id)
) COMMENT = '17.秒杀活动表';
CREATE TABLE t_skillgoods(
id INT NOT NULL AUTO_INCREMENT COMMENT '主键' ,
title VARCHAR(90) COMMENT '名称' ,
picurl VARCHAR(500) COMMENT '图片地址' ,
info VARCHAR(900) COMMENT '描述信息' ,
price DECIMAL(24,6) COMMENT '原价' ,
currprice DECIMAL(24,6) COMMENT '现价' ,
stock INT COMMENT '库存' ,
flag INT COMMENT '状态' ,
item_id INT COMMENT '商品类型' ,
ctime DATETIME COMMENT '创建时间' ,
said int comment '秒杀活动id',
htmlurl varchar(100) comment '静态化页面路径',
PRIMARY KEY (id)
) COMMENT = '18.秒杀商品表';
CREATE TABLE t_skillorder(
id INT NOT NULL AUTO_INCREMENT COMMENT '主键' ,
no VARCHAR(32) COMMENT '订单号' ,
sgid INT COMMENT '商品id' ,
price DECIMAL(24,6) COMMENT '价格' ,
num INT COMMENT '数量' ,
flag INT COMMENT '状态' ,
ctime DATETIME COMMENT '创建时间' ,
utime DATETIME COMMENT '更新时间' ,
uaid INT COMMENT '收货地址' ,
uid INT COMMENT '用户id' ,
PRIMARY KEY (id)
) COMMENT = '19.秒杀订单表';
CREATE TABLE t_skillorderlog(
id INT NOT NULL AUTO_INCREMENT COMMENT '主键' ,
oid INT COMMENT '秒杀订单id' ,
type INT COMMENT '状态' ,
info VARCHAR(255) COMMENT '备注信息' ,
ctime DATETIME COMMENT '更新时间' ,
PRIMARY KEY (id)
) COMMENT = '20.秒杀订单流水表';
CREATE TABLE t_skilllog(
id INT NOT NULL AUTO_INCREMENT COMMENT '主键' ,
uid INT COMMENT '用户id' ,
sgid INT COMMENT '秒杀商品ID' ,
status VARCHAR(1) COMMENT '秒杀结果 1.成功 2.失败' ,
ctime DATETIME COMMENT '创建时间' ,
PRIMARY KEY (id)
) COMMENT = '21.秒杀记录表';
安全:
1.限流
目的:防止服务器宕机
令牌桶算法:单位时间内容产生指定数量的令牌,请求进来获取令牌。如果获取到,进行下一步。如果获取不到,结束请求。
第三方技术实现
2.接口防刷
目的:别人恶意请求接口
访问频率进行限制:ip和用户id
3.秒杀接口隐藏
目的:防止暴露秒杀接口
通过一定加密算法,实现秒杀接口动态化
4.超卖
目的:防止超卖
锁(是否需要使用分布式锁-就看秒杀服务是否为集群部署)
5.传输协议
https协议进行数据传输
四、秒杀编码
4.1 秒杀活动
代码:
@Service
@RequiredArgsConstructor
public class SkillactivityServiceImpl implements SkillactivityService {
private final SkillactivityDao dao;
/**
* 新增秒杀活动
*
* @param dto dto 秒杀活动传输对象
* @return {@link Response}
*/
@Override
public Response save(SkillActivityAddDto dto) {
Skillactivity skillactivity = new Skillactivity();
// 对象拷贝
BeanUtils.copyProperties(dto, skillactivity);
if (dao.insert(skillactivity) > 0) {
return Response.ok();
} else {
return Response.fail();
}
}
//内部 -
@Override
public Response change(SkillActivityAuditDto dto) {
if (dao.updateFlag(dto) > 0) {
//审核成功-缓存活动
if (dto.getFlag() == SystemConfig.ACTIVITY_SUCCESS) {
//缓存2个key
Skillactivity skillactivity = dao.selectById(dto.getId());
Date date = new Date();
long s = (skillactivity.getStime().getTime() - date.getTime()) / 1000;
long e = (skillactivity.getEtime().getTime() - date.getTime()) / 1000;
//用来解决活动是否开始:未开始的秒杀活动
RedissionUtil.setStr(RedisKeyConfig.SKILL_ACTIVITY_NOSTART + dto.getId(), 1, s);
//用来缓存秒杀活动信息:进行中的秒杀活动
RedissionUtil.setStr(RedisKeyConfig.SKILL_ACTIVITY + dto.getId(), skillactivity, e);
} else if (dto.getFlag() == SystemConfig.ACTIVITY_DEL) {
//删除了
RedissionUtil.delKey(RedisKeyConfig.SKILL_ACTIVITY_NOSTART + dto.getId(), RedisKeyConfig.SKILL_ACTIVITY + dto.getId());
}
return Response.ok();
} else {
return Response.fail();
}
}
//用户-只能看审核通过活动 flag:0.查询全部秒杀活动 1.未开始活动 2.进行中活动 3.结束的活动
@Override
public Response queryList(int flag) {
QueryWrapper<Skillactivity> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("flag", SystemConfig.ACTIVITY_SUCCESS);
if (flag > 0) {
//查询某些状态
switch (flag) {
case 1://未开始活动
// stime>new Date()
queryWrapper.gt("stime", new Date());
break;
case 2://2.进行中活动
queryWrapper.lt("stime", new Date()).gt("etime", new Date());
break;
case 3://3.结束的活动
// etime < new Date()
queryWrapper.lt("etime", new Date());
break;
}
}
queryWrapper.orderByDesc("id");
return Response.ok(dao.selectList(queryWrapper));
}
//秒杀活动开始的倒计时
@Override
public Response queryTime(int id) {
//活动开始的剩余时间
if (RedissionUtil.checkKey(RedisKeyConfig.SKILL_ACTIVITY_NOSTART + id)) {
return Response.ok(RedissionUtil.ttl(RedisKeyConfig.SKILL_ACTIVITY_NOSTART + id));
} else {
if (RedissionUtil.checkKey(RedisKeyConfig.SKILL_ACTIVITY + id)) {
return Response.fail("秒杀活动进行中");
}
}
return Response.fail("秒杀活动不存在");
}
}
4.2 秒杀商品
代码:
@Service
@RequiredArgsConstructor
public class SkillgoodsServiceImpl implements SkillgoodsService {
// spring 的 bean 既然是单例的 只有一个实例 怎么保证线程安全?
private final SkillgoodsDao dao;
@Override
@Transactional
public Response save(SkillGoodsAddDto dto) {
Skillgoods skillgoods = new Skillgoods();
BeanUtils.copyProperties(dto, skillgoods);
if (dao.insert(skillgoods) > 0) {
return Response.ok();
} else {
return Response.fail();
}
}
//内部人员使用
@Override
@Transactional
public Response up(SkillGoodsDto dto) {
//1.验证是否位数商品上架
if (dto.getFlag() == SystemConfig.GOODS_UP) {
//2.修改状态和静态路径
//如果是商品上架,生成静态页面
if (dao.updateFlagUrl(dto.getFlag(),
SystemConfig.GOODS_DETAIL_PRE + dto.getId() + ".html", dto.getId()) > 0) {
//动态页面静态化-FreeMarker
//3.查询详细信息
SkillGoodsDetailDto detailDto = dao.selectByGid(dto.getId());
//4.通过FreeMarker 生成静态页面 直接生成(推荐) 、MQ 异步生成
FreeMarkerUtil.createHtml(detailDto);
//5.实现商品缓存
// hash数据结构 key:秒杀活动前缀+ID field:秒杀商品ID value:商品库存数
String key = RedisKeyConfig.SKILL_GOODS + detailDto.getSaid();
if (RedissionUtil.checkKey(key)) {
//直接缓存本商品
RedissionUtil.setHash(key, detailDto.getSgid() + "", detailDto.getStock());
} else {
//缓存商品
RedissionUtil.setHash(key, detailDto.getSgid() + "", detailDto.getStock());
//设置有效期
RedissionUtil.expire(RedisKeyConfig.SKILL_GOODS + detailDto.getSaid(), RedissionUtil.ttl(RedisKeyConfig.SKILL_ACTIVITY + detailDto.getSaid()));
}
//6.返回
return Response.ok();
} else {
return Response.fail();
}
} else {
if (dao.updateFlag(dto) > 0) {
return Response.ok();
} else {
return Response.fail();
}
}
}
@Override
public Response queryList(int said) {
QueryWrapper<Skillgoods> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("said", said);
queryWrapper.orderByDesc("id");
return Response.ok(dao.selectList(queryWrapper));
}
}
4.3 秒杀订单
@Service
public class SkillorderServiceImpl implements SkillorderService {
@Resource
private SkillorderDao dao;
@Resource
SkillgoodsDao skillgoodsDao;
@Resource
private SkilllogDao skilllogDao;
@Resource
private SkillorderlogDao skillorderlogDao;
@Resource
private RabbitTemplate rabbitTemplate;
/**
* Review 评审
* 1.操作直接落到Mysql
* 2.锁,无法保证超卖
* 3.Redis
* 4.MQ
* 5.幂等性的问题:操作多次,结果一样,新增接口 需要解决幂等性的问题
*解决方案:
* 1.前端 点击之后,立即把按钮不可使用
* 2.锁
* 3.Redis 性能
* */
//秒杀
@Override
@Transactional //事务
public R save(SkillOrderAddDto dto, int uid) {
//秒杀实现--下单
//1.查询秒杀商品的商品信息
// Lock lock=new ReentrantLock();//通过Lock 解决超卖的问题
// lock.lock();
RLock rLock=RedissonUtils.getLock(RedisKeyConfig.SKILL_ACTIVITY_LOCK+dto.getSgid());
try {
//分布式锁 设置超时时间 防止意外情况的锁无法释放问题
if(rLock.tryLock(6, TimeUnit.SECONDS)){
SkillGoodsDetailDto detailDto=skillgoodsDao.selectByGid(dto.getSgid());
if(detailDto!=null){
//2.验证秒杀活动没有结束
Date date=new Date();
if(detailDto.getStime().getTime()<date.getTime() && detailDto.getEtime().getTime()>date.getTime()){
//3.验证购买的数量是否超出约定的上限
if(detailDto.getMaxcount()>=dto.getNum()) {
//4.验证用户是否已经购买过
Skillorder skillorder = dao.selectOne(new QueryWrapper<Skillorder>().eq("uid", uid).eq("sgid", dto.getSgid()));
if (skillorder == null) {
//用户没有购买过,可以购买
//5.验证库存
if (detailDto.getStock() >= dto.getNum()) {
//库存够,可以下单
//6.生成订单信息
Skillorder order = new Skillorder(SnowFlowUtil.getInstance().nextId() + "",
dto.getSgid(), detailDto.getCurrprice(), dto.getNum(), dto.getUaid(), uid);
if(dao.insert(order)>0){
//7.扣减库存
skillgoodsDao.updateStock(dto.getSgid(),-dto.getNum());
//8.秒杀成功-记录
skillorderlogDao.insert(new Skillorderlog(order.getId(), SystemConfig.ORDER_ADD,"秒杀订单生成!"));
skilllogDao.insert(new Skilllog(uid, dto.getSgid(), "1"));//记录秒杀结果
//9.返回
return RUtil.ok();
}else {
skilllogDao.insert(new Skilllog(uid, dto.getSgid(), "2"));//记录秒杀结果
return RUtil.fail("下单失败!");
}
}else {
return RUtil.fail("亲,秒杀商品已卖完!");
}
} else {
//买过,本次不允许
return RUtil.fail("亲,你已经买过了!");
}
}else {
return RUtil.fail("亲,超出规定的购买上限!");
}
}else {
return RUtil.fail("亲,秒杀活动已结束!");
}
}else {
return RUtil.fail("亲,秒杀商品无效!");
}
}
rLock.unlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
return RUtil.fail("亲,秒杀已结束!");
}
@Override
public R save2(SkillOrderAddDto dto, int uid, String sign) {
//1.加锁 分布式锁
RLock rLock=RedissonUtils.getLock(RedisKeyConfig.SKILL_ACTIVITY_LOCK+dto.getSgid());
try {
if(rLock.tryLock(6,TimeUnit.SECONDS)){
//校验库存
String key=RedisKeyConfig.SKILL_GOODS+dto.getSaid();
if(RedissonUtils.existsField(key,dto.getSgid()+"")){
int n= (int) RedissonUtils.getHash(key,dto.getSgid()+"");
if(n>=dto.getNum()){
//扣减库存
if(skillgoodsDao.updateStock(dto.getSgid(),-dto.getNum())>0){
RedissonUtils.setHash(key,dto.getSgid()+"",n-dto.getNum());
//生成订单信息
Skillorder order = new Skillorder(SnowFlowUtil.getInstance().nextId() + "",
dto.getSgid(), dto.getPrice(), dto.getNum(), dto.getUaid(), uid);
//发送MQ消息
rabbitTemplate.convertAndSend(new MqMsgBo(SnowFlowUtil.getInstance().nextId(),
RabbitMQConstConfig.MQTYPE_SKILLORDERADD,order));
return RUtil.ok();
}
}else {
return RUtil.fail("亲,已售完!");
}
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return RUtil.fail("亲,秒杀已结束!");
}
}
五、秒杀测试
六、Freemarker
页面模板技术:JSP,Thymeleaf,Freemarker
页面+占位符+动态数据=渲染最终的静态页面
6.1 是什么
FreeMarker 是一款 模板引擎: 即一种基于模板和要改变的数据, 并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。 它不是面向最终用户的,而是一个Java类库,是一款程序员可以嵌入他们所开发产品的组件
代码生成框架就会使用Freemaker
6.2 作用
1.基于模板生成静态页面-HTML网页
2.基于模板生成配置文件
3.基于模板生成Java源文件
等等
通过FreeMarker实现动态页面静态化
6.3 怎么用
1.依赖jar
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>
2.编写模板文件
xxx.ftl
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>秒杀商品详情页</title>
</head>
<body>
<div>
<!-- 1.秒杀活动-->
<div>
<h2>${dto.title}</h2>
<h3>开始时间:${dto.stime?string('yyyy-MM-dd HH:mm:ss')}---结束时间:${dto.etime?string('yyyy-MM-dd HH:mm:ss')}</h3>
</div>
<!-- 2.秒杀商品信息-->
<h1>${dto.gtitle}</h1>
<img src="${dto.picurl}">
<h5>原价:${dto.price},秒杀价格:${dto.currprice}</h5>
<h6>库存:${dto.stock}</h6>
<div>
</div>
<!-- 3.下单-->
<div>
<button onclick="sendOrder()">立即抢购</button>
</div>
</div>
<script>
function sendOrder(){
//1.请求接口--获取秒杀真正接口
//ajax
//2.再请求秒杀接口
//ajax
}
</script>
</body>
</html>
3.编写代码实现页面生成
读取模板、设置变量、输出文件
/**
* 生成秒杀商品静态页面
*/
public static void createHtml(SkillGoodsDetailDto dto){
// 第一步:创建一个Configuration对象,直接new一个对象。构造方法的参数就是freemarker对于的版本号。
Configuration configuration = new Configuration(Configuration.getVersion());
try{
// 第二步:设置模板文件所在的路径
configuration.setDirectoryForTemplateLoading(new File(dto.getClass().getClassLoader().getResource("templ").getPath()));
// 第三步:设置模板文件使用的字符集。一般就是utf-8.
configuration.setDefaultEncoding("utf-8");
// 第四步:加载一个模板,创建一个模板对象。
Template template = configuration.getTemplate("goodsdetail.ftl");
// 第五步:创建一个模板使用的数据集,可以是pojo也可以是map。一般是Map。
Map dataModel = new HashMap<>();
//向数据集中添加数据
dataModel.put("dto", dto);
// 第六步:创建一个Writer对象,一般创建一FileWriter对象,指定生成的文件名。
Writer out = new FileWriter(new File(dto.getClass().getClassLoader().getResource("static").getPath(),SystemConfig.GOODS_DETAIL_PRE+dto.getSgid()+".html"));
// 第七步:调用模板对象的process方法输出文件。
template.process(dataModel, out);
// 第八步:关闭流。
out.close();
}catch (Exception e){
}
}
4.访问测试
七、限流算法
7.1令牌桶算法
令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。
典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。
7.2 实现
选择谷歌的guava工具包,内部RateLimter类提供了令牌桶算法实现令牌发放
实现步骤:
1.依赖jar
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
2.编写代码实现令牌发放
public class TokenLimiterFilter implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
RateLimiter rateLimiter = RateLimiter.create(SystemConfig.SKILL_TOKENS);
//获取令牌就放行,获取不到就拦截
if(rateLimiter.tryAcquire()){
return true;
}else {
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(JSON.toJSONString(RUtil.ok("亲,秒杀已售罄!")));
return false;
}
}
}