首先我们先看一下设计秒杀系统时,我们应该考虑的问题。
解决方案:
一.页面静态化结合CDN内容分发
前端把能提前放入cdn服务器的东西都放进去,反正把所有能提升效率的步骤都做一下,减少真正秒杀时候服务器的压力。
秒杀活动的页面内容,大多数都是固定不变的。我们可以对秒杀页面做静态化处理,减少访问服务端的压力。在秒杀活动开始前,提前生成包含秒杀商品信息、倒计时等固定内容的静态 HTML 页面。这些页面可以根据秒杀活动的特点,例如开始时间、结束时间等来预先生成。
示例如下:
// 生成秒杀页面的HTML
public String generateSeckillPage(long goodsId) {
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
// 其他固定信息的获取
// 生成HTML页面,包含秒杀商品信息、倒计时等
String htmlContent = "<html><head>...</head><body>...</body></html>";
// 将HTML页面保存到静态文件,或者存储到数据库中
return htmlContent;
}
参与秒杀的用户可能分布在全国各地,地域不同,网速不同。为了让用户可以快速的进入到秒杀活动的页面,可以使用 CDN(Content Delivery Network,内容分发网络)让用户可以就近获取所需内容。将生成的静态 HTML 页面通过 CDN 分发,使用户可以就近获取所需内容。CDN 的作用是将页面的静态资源分发到全球不同的节点,用户访问时可以从离他们最近的节点获取内容,减少网络延迟,提高访问速度。
用户访问秒杀活动页面时,直接请求 CDN 上的静态 HTML 页面,不再需要服务端动态生成页面内容。这样可以减轻服务端的压力,提高页面加载速度,提升用户体验。
二.按钮置灰控制
秒杀活动开始前,按钮一般需要置灰。只有时间到了,才能变得可以点击。 这防止,秒杀用户在时间快到的前几秒,疯狂请求服务器,然后秒杀时间点 还没到,服务器就自己挂了。
三. 服务单一职责
我们都知道微服务设计思想,也就是各个功能模块拆分,功能那个类似放 一起,再用分布式部署方式进行部署。
如用户登录相关,就设计个用户服务,订单相关就搞个订单服务等等。那么,秒杀相关业务逻辑也可以放到一起, 搞个秒杀服务,单独给它搞个秒杀数据库。” 服务单一职责有个好处:如果秒杀 没抗住高并发压力,秒杀库崩了,服务挂了,也不会影响到系统其他服。
四.秒杀地址加盐(url动态化)
链接如果明文暴露的话,会有人获取到请求 Url,提前秒杀了。因此,需要给 秒杀链接加盐。可以让URL 动态化,如通过 MD5 加密算法加密随机字符串来生成,秒杀链接的URL。
时间校验: 通过获取商品的起始时间和结束时间,判断当前时间与秒杀活动的状态(未开始、进行中、已结束),从而判断用户是否可以参与秒杀。
GoodsVo goods = goodsService.getGoodsVoByGoodsId(goodsId);
long startAt = goods.getStartDate().getTime();
long endAt = goods.getEndDate().getTime();
long now = System.currentTimeMillis();
int miaoshaStatus = 0;
int remainSeconds = 0;
if(now < startAt ) {//秒杀还没开始,倒计时
miaoshaStatus = 0;
remainSeconds = (int)((startAt - now )/1000);
}else if(now > endAt){//秒杀已经结束
miaoshaStatus = 2;
remainSeconds = -1;
}else {//秒杀进行中
miaoshaStatus = 1;
remainSeconds = 0;
}
但仅仅是这样的话还是不够的,如果我们的秒杀的真实地址被人知道了,就可以写一个脚本不断的获取北京时间,在秒杀开始的毫秒级别时就可以请求。而且机器能在短时间内发送大量的请求,绝对会对我们的秒杀活动造成巨大的影响。
那怎么办呢?
我们可以把我们的秒杀地址动态化,也就是通过MD5之类的加密算法去处理我们的秒杀地址,存入 redis缓存中,根据前端请求的url获取path。 判断与缓存中的字符串是否一致,一致就认为请求是正常的。这就是秒杀链接加盐,用这样的方法来阻止恶意用户直接请求我们的秒杀地址。
@RequestMapping(value="/path", method=RequestMethod.GET)
@ResponseBody
public Result<String> getMiaoshaPath( MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@RequestParam(value="verifyCode", defaultValue="0")int verifyCode) {
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
boolean check = miaoshaService.checkVerifyCode(user, goodsId, verifyCode);
if(!check) {
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
String path =miaoshaService.createMiaoshaPath(user, goodsId);
return Result.success(path);
}
public String createMiaoshaPath(MiaoshaUser user, long goodsId) {
if(user == null || goodsId <=0) {
return null;
}
String str = MD5Util.md5(UUIDUtil.uuid()+"123456");
redisService.set(MiaoshaKey.getMiaoshaPath, ""+user.getId() + "_"+ goodsId, str);
return str;
}
实现原理总结如下:
- 用户通过前端请求获取秒杀地址的path。
- 后端根据用户信息、商品ID等生成动态的秒杀地址。
- 将生成的地址通过加盐的加密算法处理,存入Redis缓存中,建立用户ID和商品ID的关联。
- 用户提交秒杀请求时,携带path参数,后端根据前端请求的URL获取path,再与Redis缓存中的地址进行比对,判断请求的合法性。
通过这种方法,成功防止了用户通过直接请求秒杀地址进行恶意操作,保障了秒杀活动的公正性和安全性。
五.接口限流进行防刷
一般有两种方式限流:nginx 限流和redis 限流。
为了防止某个用户请求过于频繁,我们可以对同一用户限流;
为了防止黄牛模拟几个用户请求,我们可以对某个 IP 进行限流;
为了防止有人使用代理,每次请求都更换 IP 请求,我们可以对接口进行限流。
1. Nginx 提供了基于令牌桶算法的限流机制,可以有效控制请求的流量。通过配置 ngx_http_limit_req_module
模块,可以设定每个 IP 或每个 URI 的请求频率上限。
http {
limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
server {
location /api {
limit_req zone=one burst=5;
# 其他配置
}
}
}
上述配置中,limit_req_zone
定义了一个名为 one
的限流区域,每个 IP 的请求速率为 1 次/秒,limit_req
用于限制每秒的请求数,burst
指定了可以超出速率的请求数
2.0为了防止瞬时过大流量压垮系统,还可以使用阿里Sentinel、Hystrix 组件进行限流。
六. 超卖问题解决思路
只要是一个秒杀系统,就必然会存在超卖问题。不同用户在读请求的时候,发现商品库存足够,然后同时发起请求,进行秒杀操作,减库存,导致库存减为负数。
1.0使用乐观锁解决
如果要使用乐观锁,那么我们就要给个商品库存一个版本号version字段,在每次我们读取库存的时候把版本号也读取出来,当这一个线程去执行扣减库存的操作的时候,去判断数据库当前的版本号是否是刚刚读取出来的版本号,如果不是则秒杀失败。
/**
* 查询商品库存
* @param id 商品id
* @return
*/
@Select("SELECT * FROM goods WHERE id = #{id}")
Goods getStock(@Param("id") int id);
/**
* 乐观锁方案扣减库存
* @param id 商品id
* @param version 版本号
* @return
*/
@Update("UPDATE goods SET stock = stock - 1, version = version + 1 WHERE id = #{id} AND stock > 0 AND version = #{version}")
int decreaseStockForVersion(@Param("id") int id, @Param("version") int version);
service层:
/**
* 扣减库存
* @param gid 商品id
* @param uid 用户id
* @return SUCCESS 1 FAILURE 0
*/
@Transactional
public int sellGoods(int gid, int uid) {
// 获取库存
Goods goods = goodsDao.getStock(gid);
if (goods.getStock() > 0) {
// 乐观锁更新库存
int update = goodsDao.decreaseStockForVersion(gid, goods.getVersion());
// 更新失败,说明其他线程已经修改过数据,本次扣减库存失败,可以重试一定次数或者返回
if (update == 0) {
return 0;
}
// 库存扣减成功,生成订单
Order order = new Order();
order.setUid(uid);
order.setGid(gid);
int result = orderDao.insertOrder(order);
return result;
}
// 失败返回
return 0;
}
2.0使用redis分布式锁
用 Redis SET EX PX NX + 校验唯一随机值,再删除释放锁。
if (jedis.set(key_resource id, uni request id,"NX","Ex",100s) == 1)[ //加锁
try {
do something //业务处理
catch(){
}
finally {
//判断是不是当前线程加的锁,是才释放if (uni_request id.equals(jedis.get(key_resource_ id))) {
jedis.del(lockKey);
}
}
}
七.MQ异步处理
如果瞬间流量特别大,可以使用消息队列削峰,异步处理。用户请求过来的时 候,先放到消息队列,再拿出来消费。
八.限流&降级&熔断
限流,就是限制请求,防止过大的请求压垮服务器;
降级,就是秒杀服务有问题了,就降级处理,不要影响别的服务;
熔断,服务有问题就熔断,一般熔断降级是一起出现。