秒杀后盾实现
本文次要解说我的项目实战中秒杀如何解决上面问题:
1)实现秒杀异步下单,把握如何保障生产者&消费者音讯不失落
2)实现避免歹意刷单
3)实现避免雷同商品反复秒杀
4)实现秒杀下单接口暗藏
5)实现下单接口限流
1 秒杀异步下单
用户在下单的时候,须要基于JWT令牌信息进行登陆人信息认证,确定以后订单是属于谁的。
针对秒杀的非凡业务场景,仅仅依附对象缓存或者页面动态化等技术去解决服务端压力还是远远不够。
对于数据库压力还是很大,所以须要异步下单,异步是最好的解决办法,但会带来一些额定的程序上的
复杂性。
1.1 秒杀服务-下单实现
1)将tokenDecode工具类config放入秒杀服务并申明Bean
public static void main(String[] args){ SpringApplication.run(SeckillApplication,class,args);}@Beanpublic TokenDecode tokenDecode(){ return new TokenDecode();}
2)更新秒杀服务启动类,增加redis配置
/** * 设置 redisTemplate 的序列化设置 * @param redisConnectionFactory * @return */ @Bean public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) { // 1.创立 redisTemplate 模版 RedisTemplate<Object, Object> template = new RedisTemplate<>(); // 2.关联 redisConnectionFactory template.setConnectionFactory(redisConnectionFactory); // 3.创立 序列化类 GenericToStringSerializer genericToStringSerializer = new GenericToStringSerializer(Object.class); // 4.序列化类,对象映射设置 // 5.设置 value 的转化格局和 key 的转化格局 template.setValueSerializer(genericToStringSerializer); template.setKeySerializer(new StringRedisSerializer()); template.afterPropertiesSet(); return template; }
2)新建下单controller并申明办法
@RestController @CrossOrigin @RequestMapping("/seckillorder") public class SecKillOrderController { @Autowired private TokenDecode tokenDecode; @Autowired private SecKillOrderService secKillOrderService; /** * 秒杀下单 * @param time 以后时间段 * @param id 秒杀商品id * @return */ @RequestMapping("/add") //获取以后登陆人 String username = tokenDecode.getUserInfo().get("username"); boolean result = secKillOrderService.add(id,time,username); if (result){ return new Result(true, StatusCode.OK,"下单胜利"); }else{ return new Result(false,StatusCode.ERROR,"下单失败"); } } }
3) 新建service接口
public interface SecKillOrderService { /*** 秒杀下单 * @param id 商品id * @param time 时间段 * @param username 登陆人姓名 * @return */ boolean add(Long id, String time, String username); }
4)更改预加载秒杀商品
当预加载秒杀商品的时候,提前加载每一个商品的库存信息,后续减库存操作也会先预扣减缓存中的库存再异步扣减mysql数据。
预扣减库存会基于redis原子性操作实现
for (SeckillGoods seckillGoods : seckillGoodsList) { redisTemplate.boundHashOps(SECKILL_GOODS_KEY + redisExtName).put(seckillGoods.getId(),seckillGoods); //预加载库存信息 redisTemplate.OpsForValue(SECKILL_GOODS_STOCK_COUNT_KEY+seckillGoods.getId(),se ckillGoods.getStockCount()); }
6)秒杀下单业务层实现
业务逻辑:
获取秒杀商品数据与库存量数据,如果没有库存则抛出异样执行redis预扣减库存,并获取扣减之后的库存值如果扣减完的库存值<=0, 则删除redis中对应的商品信息与库存信息基于mq异步形式实现与mysql数据同步(最终一致性)
留神:库存数据从redis中取出,转换成String
@Service public class SecKillOrderServiceImpl implements SecKillOrderService { @Autowired private RedisTemplate redisTemplate; @Autowired private IdWorker idWorker; @Autowired private CustomMessageSender customMessageSender; /** * 秒杀下单 * @param id 商品id * @param time 时间段 * @param username 登陆人姓名 * @return */ @Override public boolean add(Long id, String time, String username) { //获取商品数据 SeckillGoods goods = (SeckillGoods) redisTemplate.boundHashOps("SeckillGoods_" + time).get(id); String redisStock = (String) redisTemplate.boundValueOps("StockCount_" + goods.getId()).get(); if(StringUtils.isEmpty(redisStock)){ return false; } int value=Integer.parseInt(redisStock); //如果没有库存,则间接抛出异样 if(goods==null || value<=0){ return false; } //redis预扣库存 Long stockCount = redisTemplate.boundValueOps("StockCount_" + id).decrement(); if (stockCount<=0){ //库存没了 //删除商品信息 redisTemplate.boundHashOps("SeckillGoods_" + time).delete(id); //删除对应的库存信息 redisTemplate.delete("StockCount_" + goods.getId()); } //有库存 //如果有库存,则创立秒杀商品订单 SeckillOrder seckillOrder = new SeckillOrder(); seckillOrder.setId(idWorker.nextId()); seckillOrder.setUserId(username); seckillOrder.setSellerId(goods.getSellerId()); seckillOrder.setCreateTime(new Date()); seckillOrder.setStatus("0"); //发送音讯 return true; } }
1.2 生产者保障音讯不失落
依照现有rabbitMQ的相干常识,生产者会发送音讯达到音讯服务器。然而在理论生产环境下,音讯生产者发送的音讯很有可能当达到了音讯服务器之后,因为音讯服务器的问题导致音讯失落,如宕机。因为音讯服务器默认会将音讯存储在内存中。一旦音讯服务器宕机,则音讯会产生失落。因而要保障生产者的音讯不失落,要开始长久化策略。
rabbitMQ长久化: 交换机长久化 队列长久化 音讯长久化
然而如果仅仅只是开启这两局部的长久化,也很有可能造成音讯失落。因为音讯服务器很有可能在长久化的过程中呈现宕机。因而须要通过数据保护机制来保障音讯肯定会胜利进行长久化,否则将始终进行音讯发送。
事务机制 事务机制采纳类数据库的事务机制进行数据保护,当音讯达到音讯服务器,首先会开启一个事务,接着进 行数据磁盘长久化,只有长久化胜利才会进行事务提交,向音讯生产者返回胜利告诉,音讯生产者一旦接管成 功告诉则不会再发送此条音讯。当出现异常,则返回失败告诉.音讯生产者一旦接管失败告诉,则持续发送该 条音讯。 事务机制尽管可能保障数据安全,然而此机制采纳的是同步机制,会产生零碎间音讯阻塞,影响整个零碎 的音讯吞吐量。从而导致整个零碎的性能降落,因而不倡议应用。 confirm机制 confirm模式须要基于channel进行设置, 一旦某条音讯被投递到队列之后,音讯队列就会发送一个确 认信息给生产者,如果队列与音讯是可长久化的, 那么确认音讯会等到音讯胜利写入到磁盘之后收回. confirm的性能高,次要得益于它是异步的.生产者在将第一条音讯收回之后期待确认音讯的同时也能够 持续发送后续的音讯.当确认音讯达到之后,就能够通过回调办法解决这条确认音讯. 如果MQ服务宕机了,则会 返回nack音讯. 生产者同样在回调办法中进行后续解决。
1.2.1 开启confifirm机制
1)更改秒杀服务配置文件
rabbitmq: host: 192.168.200.128 publisher-confirms: true #开启confirm机制
2)开启队列长久化
@Configuration public class RabbitMQConfig { //秒杀商品订单音讯 public static final String SECKILL_ORDER_KEY="seckill_order"; @Bean public Queue queue(){ //开启队列长久化 return new Queue(SECKILL_ORDER_KEY,true); } }
3)音讯长久化源码查看
4)加强rabbitTemplate
@Component public class CustomMessageSender implements RabbitTemplate.ConfirmCallback { static final Logger log = LoggerFactory.getLogger(CustomMessageSender.class); private static final String MESSAGE_CONFIRM="message_confirm"; @Autowired private RabbitTemplate rabbitTemplate; @Autowired private RedisTemplate redisTemplate; public CustomMessageSender(RabbitTemplate rabbitTemplate) { this.rabbitTemplate = rabbitTemplate; rabbitTemplate.setConfirmCallback(this); } @Override public void confirm(CorrelationData correlationData, boolean ack, String cause) { if (ack){ //返回胜利告诉 //删除redis中的相干数据 redisTemplate.delete(correlationData.getId()); redisTemplate.delete(MESSAGE_CONFIRM_+correlationData.getId()); }else{ //返回失败告诉 Map<String,String> map = (Map<String,String>)redisTemplate.opsForHash().entries(MESSAGE_CONFIRM_+correlationData.getId()); String exchange = map.get("exchange"); String routingKey = map.get("routingKey"); String sendMessage = map.get("sendMessage"); //从新发送 rabbitTemplate.convertAndSend(exchange,routingKey, JSON.toJSONString(sendMessage)); } } //自定义发送办法 public void sendMessage(String exchange,String routingKey,String message){ //设置音讯惟一标识并存入缓存 CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString()); redisTemplate.opsForValue().set(correlationData.getId(),message); Map<String, String> map = new HashMap<>(); map.put("exchange", exchange); map.put("routingKey", routingKey); map.put("sendMessage", message); redisTemplate.opsForHash().putAll(MESSAGE_CONFIRM_+correlationData.getId(),map) ; //携带惟一标识发送音讯 rabbitTemplate.convertAndSend(exchange,routingKey,message,correlationData); } }
5)发送音讯
更改下单业务层实现
@Autowired private CustomMessageSender customMessageSender;
1.3 秒杀下单服务更新库存
1.3.1 异步下单服务service_consume
1)增加依赖
<dependencies> <dependency> <groupId>com.changgou</groupId> <artifactId>changgou_common_db</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId> </dependency> <dependency> <groupId>com.changgou</groupId> <artifactId>changgou_service_order_api</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>com.changgou</groupId> <artifactId>changgou_service_seckill_api</artifactId> <version>1.0-SNAPSHOT</version> </dependency> <dependency> <groupId>com.changgou</groupId> <artifactId>changgou_service_goods_api</artifactId> <version>1.0-SNAPSHOT</version></dependency> <dependency> <groupId>org.springframework.amqp</groupId> <artifactId>spring-rabbit</artifactId> </dependency> </dependencies>
2)新建application.yml
server: port: 9022 spring: jackson: time-zone: GMT+8 application: name: sec-consume datasource: driver-class-name: com.mysql.jdbc.Driver url: jdbc:mysql://192.168.200.128:3306/changgou_seckill? useUnicode=true&characterEncoding=utf- 8&useSSL=false&allowMultiQueries=true&serverTimezone=GMT%2b8 username: root password: root main: allow-bean-definition-overriding: true #当遇到同样名字的时候,是否容许笼罩注册 redis: host: 192.168.200.128 rabbitmq: host: 192.168.200.128 eureka: client: service-url: defaultZone: http://127.0.0.1:6868/eureka instance: prefer-ip-address: true feign: hystrix: enabled: true client: config: default: #配置全局的feign的调用超时工夫 如果 有指定的服务配置 默认的配置不会失效 connectTimeout: 60000 # 指定的是 消费者 连贯服务提供者的连贯超时工夫 是否能连贯 单位是毫秒 readTimeout: 20000 # 指定的是调用服务提供者的 服务 的超时工夫() 单位是毫秒 #hystrix 配置 hystrix: command: default: execution: timeout: #如果enabled设置为false,则申请超时交给ribbon管制 enabled: true isolation: strategy: SEMAPHORE thread: # 熔断器超时工夫,默认:1000/毫秒 timeoutInMilliseconds: 20000
3)新建启动类
@SpringBootApplication @EnableDiscoveryClient @MapperScan(basePackages = {"com.changgou.consume.dao"}) public class OrderConsumerApplication { public static void main(String[] args) { SpringApplication.run(OrderConsumerApplication.class,args); } }
1.3.2 消费者手动ACK下单实现
依照现有RabbitMQ常识,能够得悉当音讯消费者胜利接管到音讯后,会进行生产并主动告诉音讯服务器将该条音讯删除。此种形式的实现应用的是消费者自动应答机制。然而此种形式十分的不平安。在生产环境下,当音讯消费者接管到音讯,很有可能在解决音讯的过程中出现意外状况从而导致音讯失落,因为如果应用自动应答机制是十分不平安。咱们须要确保消费者当把音讯胜利解决实现之后,音讯服务器才会将该条音讯删除。此时要实现这种成果的话,就须要将自动应答转换为手动应答,只有在音讯消费者将音讯解决完,才会告诉音讯服务器将该条音讯删除。
1)更改配置文件
rabbitmq: host: 192.168.200.128 listener: simple: acknowledge-mode: manual #手动
2)定义监听类
@Component public class ConsumeListener { @Autowired private SecKillOrderService secKillOrderService; @RabbitListener(queues = RabbitMQConfig.SECKILL_ORDER_KEY) public void receiveSecKillOrderMessage(Channel channel, Message message){ //转换音讯 SeckillOrder seckillOrder = JSON.parseObject(message.getBody(), SeckillOrder.class); //同步mysql订单 int rows = secKillOrderService.createOrder(seckillOrder); if (rows>0){ //返回胜利告诉 try { channel.basicAck(message.getMessageProperties().getDeliveryTag(),false); } catch (IOException e) { e.printStackTrace(); } }else{ //返回失败告诉 try { //第一个boolean true所有消费者都会回绝这个音讯,false代表只有以后消费者拒 绝 //第二个boolean true以后音讯会进入到死信队列,false从新回到原有队列中,默认回到头部 channel.basicNack(message.getMessageProperties().getDeliveryTag(),false,false); } catch (IOException e) { e.printStackTrace(); } } } }
3)定义业务层接口与实现类
public interface ConsumeService { int handleCreateOrder(SeckillOrder order); }
@Servicepublic class SecKillOrderServiceImpl implements SecKillOrderService { @Autowired private SeckillGoodsMapper seckillGoodsMapper; @Autowired private SeckillOrderMapper seckillOrderMapper; /** * 增加订单 * @param seckillOrder * @return */ @Override @Transactional public int createOrder(SeckillOrder seckillOrder) { int result =seckillGoodsMapper.updateStockCount(seckillOrder.getSeckillId()); if (result<=0){ return result; } result =seckillOrderMapper.insertSelective(seckillOrder); if (result<=0){ return result; }return 1;
数据库字段unsigned介绍 unsigned-----无符号,润饰int 、char ALTER TABLE tb_seckill_goods MODIFY COLUMN stock_count int(11) UNSIGNED DEFAULT NULL COMMENT '残余库存数';
1.5 流量削峰
在秒杀这种高并发的场景下,每秒都有可能产生几万甚至十几万条音讯,如果没有对音讯处理量进行任何限度的话,很有可能因为过多的音讯沉积从而导致消费者宕机的状况。因而官网倡议对每一个音讯消费者都设置解决音讯总数(音讯抓取总数)。
音讯抓取总数的值,设置过大或者过小都不好,过小的话,会导致整个零碎音讯吞吐能力降落,造成性能节约。过大的话,则很有可能导致音讯过多,导致整个零碎OOM。因而官网倡议每一个消费者将该值设置在100-300之间。
1)更新消费者。
//设置预抓取总数 channel.basicQos(300);
1.6 秒杀渲染服务-下单实现
1)定义feign接口
@FeignClient(name="seckill") public interface SecKillOrderFeign { /** * 秒杀下单 * @param time 以后时间段 * @param id 秒杀商品id * @return */ @RequestMapping("/seckillorder/add") public Result add(@RequestParam("time") String time, @RequestParam("id") Long id); }
2)定义controller
@Controller @CrossOrigin @RequestMapping("/wseckillorder")public class SecKillOrderController { @Autowired private SecKillOrderFeign secKillOrderFeign; /** * 秒杀下单 * @param time 以后时间段 * @param id 秒杀商品id * @return */ @RequestMapping("/add") @ResponseBody public Result add(String time,Long id){ Result result = secKillOrderFeign.add(time, id); return result; } }
2 避免歹意刷单解决
在生产场景下,很有可能会存在某些用户歹意刷单的状况呈现。这样的操作对于零碎而言,会导致业务出错、脏数据、后端拜访压力大等问题的呈现。
个别要解决这个问题的话,须要前端进行管制,同时后端也须要进行管制。后端实现能够通过Redisincrde 原子性递增来进行解决。
2.1 更新秒杀服务下单
2.2 防重办法实现
//避免反复提交 private String preventRepeatCommit(String username,Long id) { String redisKey = "seckill_user_" + username+"_id_"+id; long count = redisTemplate.opsForValue().increment(redisKey, 1); if (count == 1){ //设置有效期五分钟 redisTemplate.expire(redisKey, 5, TimeUnit.MINUTES); return "success"; } if (count>1){ return "fail"; } return "fail"; }
3 避免雷同商品反复秒杀
3.1 批改下单业务层实现
3.2 dao层新增查询方法
public interface SeckillOrderMapper extends Mapper<SeckillOrder> { /** * 查问秒杀订单信息 * @param username * @param id * @return */ @Select("select * from tb_seckill_order where user_id=#{username} and seckill_id=#{id}") SeckillOrder getSecKillOrderByUserNameAndGoodsId(String username, Long id); }
4 秒杀下单接口暗藏
以后尽管能够确保用户只有在登录的状况下才能够进行秒杀下单,然而无奈办法有一些歹意的用户在登录了之后,猜想秒杀下单的接口地址进行歹意刷单。所以须要对秒杀接口地址进行暗藏。
在用户每一次点击抢购的时候,都首先去生成一个随机数并存入redis,接着用户携带着这个随机数去拜访秒杀下单,下单接口首先会从redis中获取该随机数进行匹配,如果匹配胜利,则进行后续下单操作,如果匹配不胜利,则认定为非法拜访。
4.1 将随机数工具类放入common工程中
public class RandomUtil { public static String getRandomString() { int length = 15; String base = "abcdefghijklmnopqrstuvwxyz0123456789"; Random random = new Random(); StringBuffer sb = new StringBuffer(); for (int i = 0; i < length; i++) { int number = random.nextInt(base.length()); sb.append(base.charAt(number)); } return sb.toString(); }public static void main(String[] args) { String randomString = RandomUtil.getRandomString();}
4.2秒杀渲染服务定义随机数接口
/** * 接口加密 * 生成随机数存入redis,10秒有效期 */@GetMapping("/getToken") @ResponseBodypublic String getToken(){ String randomString = RandomUtil.getRandomString(); String cookieValue = this.readCookie(); redisTemplate.boundValueOps("randomcode_"+cookieValue).set(randomString,10, TimeUnit.SECONDS); return randomString; }//读取cookie private String readCookie(){ HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String cookieValue = CookieUtil.readCookie(request, "uid").get("uid"); return cookieValue; }
4.3 js批改
批改js下单办法
//秒杀下单 add:function(id){ app.msg ='正在下单'; //获取随机数 axios.get("/api/wseckillorder/getToken").then(function (response) { var random=response.data; axios.get("/api/wseckillorder/add? time="+moment(app.dateMenus[0]).format("YYYYMMDDHH")+"&id="+id+"&random="+random ).then(function (response) { if (response.data.flag){ app.msg='抢单胜利,行将进入领取!'; }else{app.msg='抢单失败'; } }) }) }
4.4 秒杀渲染服务更改
批改秒杀渲染服务下单接口
/** * 秒杀下单 * @param time 以后时间段 * @param id 秒杀商品id * @return */ @RequestMapping("/add") @ResponseBody public Result add(String time,Long id,String random){ //校验密文无效 String randomcode = (String) redisTemplate.boundValueOps("randomcode").get(); if (StringUtils.isEmpty(randomcode) || !random.equals(randomcode)){ return new Result(false, StatusCode.ERROR,"有效拜访"); } Result result = secKillOrderFeign.add(time, id); return result; }
5 秒杀下单接口限流
因为秒杀的非凡业务场景,生产场景下,还有可能要对秒杀下单接口进行拜访流量管制,避免过多的申请进入到后端服务器。对于限流的实现形式,咱们之前曾经接触过通过nginx限流,网关限流。然而他们都是对一个大的服务进行拜访限流,如果当初只是要对某一个服务中的接口办法进行限流呢?这里举荐应用google提供的guava工具包中的RateLimiter进行实现,其外部是基于令牌桶算法进行限流计算
1)增加依赖
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>28.0-jre</version> </dependency>
2)自定义限流注解
@Documented@Target({ElementType.METHOD, ElementType.FIELD, ElementType.TYPE}) @Retention(RetentionPolicy.RUNTIME)public @interface AccessLimit {}
3)自定义切面类
@Component @Scope @Aspect public class AccessLimitAop { @Autowired private HttpServletResponse httpServletResponse; private RateLimiter rateLimiter = RateLimiter.create(20.0); @Pointcut("@annotation(com.changgou.webSecKill.aspect.AccessLimit)") public void limit(){} @Around("limit()") public Object around(ProceedingJoinPoint proceedingJoinPoint){ boolean flag = rateLimiter.tryAcquire(); Object obj = null; try{ if (flag){ obj=proceedingJoinPoint.proceed(); }else{ String errorMessage = JSON.toJSONString(new Result(false,StatusCode.ERROR,"fail")); outMessage(httpServletResponse,errorMessage); } }catch (Throwable throwable) { throwable.printStackTrace(); }return obj; }private void outMessage(HttpServletResponse response, String errorMessage) { ServletOutputStream outputStream = null; try { response.setContentType("application/json;charset=UTF-8"); outputStream = response.getOutputStream(); outputStream.write(errorMessage.getBytes("UTF-8")); } catch (IOException e) { e.printStackTrace(); }finally { try {outputStream.close(); } catch (IOException e) { e.printStackTrace(); } }
4)应用自定义限流注解
欢送观看并写出本人的见解!独特探讨!
最初,最近很多小伙伴找我要Linux学习路线图,于是我依据本人的教训,利用业余时间熬夜肝了一个月,整顿了一份电子书。无论你是面试还是自我晋升,置信都会对你有帮忙!
收费送给大家,只求大家金指给我点个赞!
电子书 | Linux开发学习路线图
也心愿有小伙伴能退出我,把这份电子书做得更完满!
有播种?心愿老铁们来个三连击,给更多的人看到这篇文章
举荐浏览:
- 干货 | 程序员进阶架构师必备资源免费送
- 神器 | 反对搜寻的资源网站