RabbitMQ软件
一个流行的消息中间件,实现高级队列协议,为分布式应用程序提供可靠,异步消息传递机制,可以在多个进程,多个主机间传递消息,核心概念:生产者,消费者和队列
生产者:将消息发布到队列
消费者:从队列获取消息并处理
PS:这个软件安装在Linux而且需要Erlang语言环境,再安装RabiitMQ
配置RabbitMQ
配置防火墙,开放15627端口
firewall-cmd --zone=public --add-port=15672/tcp --permanent
//重启防火墙
firewall-cmd --reload
修改RabbitMQ登录配置
cd /etc/rabbitmq/ //mq安装目录
vi rabbitmq.config //修改配置
加入 [{rabbit,[{loopback_users,[]}]}].
/sbin/service rabbittmq-server stop //重启rabbit
/sbin/service rabbittmq-server start
进入RabbitMQ网页
192.168.198.135:15672
账号密码均为guest
现在在SpringBoot中集成RabbitMQ
P |||||| C
P:消息发送者
C:消息接收者
中间表示队列
pom.xml引入rabbitMQ需要的依赖
org.springframework.boot
spring-boot-starter-amqp
在application.yml配置
rabbitmq:
host:192.168.198.135 //ip地址
username: guest
password: guest
virtual-host: / //要操作的虚拟主机
port: 5672 //端口 15672是RabbitMQ网页的端口
listener:
simple:
concurrency: 10 //消费者最小数量
max-concurrency:10 // 最大
perfetch: 1 //限制消费者每次只能处理一条消息,处理完才能继续下一条
auto-startup:true //默认启动容器
default-requeue-rejected: true //消息被拒绝后是否重新进入队列
template:
retry:
enabled:true //消息处理失败是否重新尝试默认false
initial-interval: 1000ms //初始化重试时间间隔,即第一次处理消息失败后1s后重试
max-attempts: 3 //最多重试3次
max-interval: 10000ms //重试最大时间间隔10s
motiplier: 1 //重试时间间隔乘数2,第一次等待1s,第二次就是1*2s
创建RabbitMQConfig类(可以创建队列,交换机)
@Configuration
public class RabbitMQConfig{
private static final String QUEUE = "queue"; //定义队列名
@Bean
public Queue queue(){ //创建队列
return new Queue(QUEUE,true); //true表示队列持久化
}
}
简单模拟下
创建消息生产者
@Service public class MQSender{ @Resource private RabbitTemplate rabbitTemplate; //装配它操作RabbitMQ //方法:发送消息 public void send(Object msg){ rabbitTemplate.convertAndSend("queue",msg); //将消息加入队列 } }
创建消息消费者
@Service public class MQReceiver{ @Resource private RabbitTemplate rabbitTemplate; //方法:接收消息 @RabbitListener(queues="queue") //监听那些队列 public void receive(Object msg){ log.info("接收消息",msg); } }
控制层控制
@Controller RabbitMQHandler{ @Resource private MQSender mqSender; @RequestMapping("/mq") @ResoponseBody public void mq(){ mqSender.send("hello"); } }
RabbitMQ使用模式
Fanout模式
广播模式,就是把交换机(Exchange)里消息发送给所有绑定该交换机的队列,忽略路由
消费者
生产者 交换机
消费者
例:生产者将消息发送到交换机,交换机发送到两个绑定了他的队列
修改RabbitMQConfig
RabbitMQConfig{ private static final String QUEUE1 = "queue1"; //两个队列名称 private static final String QUEUE2 = "queue2"; private static final String EXCHANGE = "exchange"; //交换机名称 @Bean public Queue queue1(){ return new Queue(QUEUE1,true); } @Bean public Queue queue2(){ return new Queue(QUEUE2,true); } @Bean public FanoutExchange exchange(){ //创建交换机 return new FanoutExchange(EXCHANGE); } //然后将两个队列绑定到交换机 @Bean public Binding binding01(){ return BindingBuilder.bind(queue1()).to(EXCHANGE); } @Bean public Binding binding02(){ return BindingBuilder.bind(queue2()).to(EXCHANGE); } }
修改MQSender
MQSender{ public void sendFanout(Object msg){ rabbitTemplate.convertAndSend("exchange","",msg) //消息传到交换机 空格是忽略路由 } }
修改MQRecevier
MQRecevier{ @RabbitListener(queues="queue1") //监听队列1 public void receive(Object msg){ log.info("接收消息",msg); } @RabbitListener(queues="queue2") //监听队列2 public void receive(Object msg){ log.info("接收消息",msg); } }
修改Controller
RabbitMQHandler{ @RequestMapping("/mq/findout") @ResoponseBody public void findout(){ //现在就是发送到交换机 mqSender.send("hello"); } }
Direct模式
路由模式,在使用交换机的同时,生产者指定路由发送数据,消费者绑定路由接收数据。生产者向交换机发送数据时,会声明发送给交换机下那个路由,只有当消费者队列绑定了交换机且声明了路由,才会收到数据
||||| queue3 queue.red路由
P X info/error/ ||||| queue4 queue.green路由
生产者 队列
交换机 消费者
这就是路由
例:交换机在发送消息时指定不同路由发送到不同消费者
修改RabbitMQConfig
private static final String QUEUE3 = "queue3"; //两个队列名称 private static final String QUEUE4 = "queue4"; private static final String EXCHANGE_DIRECT = "exchange_direct"; //交换机名称 private static final String ROUNTING1 = "queue.red"; //定义路由,内容自己写 private static final String ROUNTING2 = "queue.green"; ... return new Queue(QUEUE3,true); //同样创建两个队列 return new Queue(QUEUE4,true); return BindingBuilder.bind(queue3()).to(EXCHANGE_DIRECT).wiht(ROUNTING1); //关联路由 return BindingBuilder.bind(queue4()).to(EXCHANGE_DIRECT).with(ROUNTING2);
修改MQSender
MQSender{ //方法:发送到交换机并指定路由 public void sendDirect1(Object msg){ rabbitTemplate.convertAndSend("exchange","queue.red",msg); //此时就指定路由 } public void sendDirect2(Object msg){ rabbitTemplate.convertAndSend("exchange","queue.green",msg); //此时就指定路由 } }
MQRecevier{ @RabbitListener(queues="queue3") //监听队列1 public void queue_direct1(Object msg){ log.info("接收消息",msg); } @RabbitListener(queues="queue4") //监听队列1 public void queue_direct2(Object msg){ log.info("接收消息",msg); } }
修改RabbitMQHandler
@RequestMapping("/mq/direct03") @ResoponseBody public void direct03(){ //现在就是发送到交换机 mqSender.sendDirect1("hello03"); } @RequestMapping("/mq/direct04") @ResoponseBody public void direct04(){ //现在就是发送到交换机 mqSender.sendDirect2("hello04"); }
Topic模式
direct模式可能会造成路由RoutingKey太多,实际上往往按某个规则进行路由匹配,Topic就是direct模式的一种拓展/叠加,模糊的路由匹配模式
*:可以(只能)匹配一个单词
#:可以匹配多个单词(或零个)
例如 *.orange.* Q1 C1 P X *.*>rabbit Q2 C2 来个quick.orange.rabbit--HEllo 就会同时发送到Q1,Q2 lazy.orange.elephant 就只会发送到C1 lazy.orange.elephant能匹配lazy.#
例:发送red只有Q1接收到,发送greenQ1,Q2都能接收到
新建RabbitMQTopicConfig(因为之前那个代码太多)
private static final String QUEUE1 = "queue_topic1"; //两个队列名称 private static final String QUEUE2 = "queue_topic2"; private static final String EXCHANGE = "TopicExchanget"; //交换机名称 private static final String ROUNTING1 = "#.queue.#"; //定义路由,内容自己写 private static final String ROUNTING2 = "*.queue.#"; ... return new Queue(QUEUE1,true); //同样创建两个队列 return new Queue(QUEUE2,true); return BindingBuilder.bind(queue_topic1).to(EXCHANGE).wiht(ROUNTING1); //关联路由 return BindingBuilder.bind(queue_topic2).to(EXCHANGE).with(ROUNTING2);
修改MQSender
public void sendTotic3(Object msg){ rabbitTemplate.convertAndSend ("topicExchange","queue.red.message",msg); //此时就指定路由 只能匹配到Q1 } public void sendTopic4(Object msg){ rabbitTemplate.convertAndSend ("topicExchange","green.queue.green.message",msg); //此时就指定路由 匹配到Q1,Q2 }
修改MQReceiver
@RabbitListener(queues="queue_topic1") //监听队列1 public void queue_topic1(Object msg){ log.info("接收消息",msg); } @RabbitListener(queues="queue_topic2") //监听队列1 public void queue_topic2(Object msg){ log.info("接收消息",msg); }
修改MQHandler
@RequestMapping("/mq/topic1") @ResoponseBody public void topic1(){ //现在就是发送到交换机 mqSender.sendTopic3("hello red"); } @RequestMapping("/mq/topic2") @ResoponseBody public void topic2(){ //现在就是发送到交换机 mqSender.sendTopic4("hello green"); }
Headers模式
使用较少,比较少见且复杂,不关心路由key是否匹配,只关心header的key-value对是否匹配
现在解决之前的问题
当秒杀系统开始时候,如果一大堆线程都来请求,那么对数据库压力很大,故需要Redis分担,在过滤环节就预减库存,这样调用seckill方法的线程变少了,对数据库解压
修改SeckillController
public class SeckillController implements InitilaizingBean{ ... //该方法是在SeckillController类所有属性都初始化后自动执行,就可以将秒杀商品数量加载到Redis @Override public void afterPropertiesSet() throws Exception{ //查询所有的秒杀商品 List<GoodsVo> list = goodsService.findGoodsVo(); //遍历List,将秒杀商品库存量放到Redis if(CollectionUtils.isEmpty(list)){ //判断是否为空 return; } list.forEach(goodsVo -> { //秒杀商品库存量对应key:seckillGoods:商品id redisTemplate.opsForValue(). set("seckillGoods:"+goodsVo.getId(),goodsVo.getStockCount()); }); } }
继而修改SeckillController的doSeckill方法
doSeckill(...){ ... //库存预减,如果在Redis中预减库存,发现商品没了,就直接返回 // 从而减少执行orderService.seckill()请求,防止线程堆积 //derement具有原子性,当执行decrement方法是一个一个进行的不是一下冲进许多线程 Long decrement = redisTemplate.opsForValue(). decrement("seckillGoods:"+goodsId); if(derement < 0){ //说明商品没库存了 redisTemplate.opsForValue. increment("seckillGoods:"+goodsId);//恢复看起舒服 return "secKillFail"; //返回错误页面 } }
继续优化
在预减Redis库存时,可以判断库存量是否为0.是则不再减1,免得0和-1一直循环浪费内存,就是直接在本机jvm操作了,快于在Redis操作
SeckillController{ //定义map记录秒杀商品是否还有库存 private HashMap<Long,Boolean> entryStockMap = new HashMap(); @Override public void afterPropertiesSet() throws Exception{ List<GoodsVo> list = goodsService.findGoodsVo(); if(CollectionUtils.isEmpty(list)){ return; } list.forEach(goodsVo -> { redisTemplate.opsForValue(). set("seckillGoods:"+goodsVo.getId(),goodsVo.getStockCount()); //初始化map,false表示还有库存,true表示没有库存 entryStockMap.put(goodsVo.getId(),false); }); } doSeckill(...){ ... //对Map判断,如果已经无了,直接返回,无需再Redis预减 if(entryStockMap.get(goodsId)){ return "secKillFail"; //返回错误信息 } Long decrement = redisTemplate.opsForValue(). decrement("seckillGoods:"+goodsId); if(derement < 0){ //这里表示秒杀商品数量已经无了 entryStockMap.put(goodsId,true); redisTemplate.opsForValue. increment("seckillGoods:"+goodsId); return "secKillFail"; } } }
加入消息队列,实现秒杀的异步请求
前面秒杀,没有实现异步机制,是完成下订单后再返回,当有大并发请求下订单操作时,数据库来不及响应,容易造成线程堆积,可通过消息队列实现秒杀异步请求
新建一个秒杀消息SeckillMessage
//秒杀消息对象 @Data @NoArgsConstructor @AllArgsConstructor public class SeckillMessage{ private User user; private Long goodsId; }
创建RabbitMQSeckillConfig
@Configuration public class RabbitMQSeckillConfig{ //定义消息队列和交换机名 private static final String QUEUE = "seckillQueue"; private static final String EXCHANGE = "seckillExchange"; //创建队列 @Bean public Queue queue_skill(){ return new Queue(QUEUE); } //创建交换机 @Bean public TopicExchange topicExchange_seckill(){ return new TopicExchange(EXCHANGE); } //将队列绑定到交换机,并指定路由 @Bean public Binding bingding_seckill(){ return BingdingBuilder.bind(queue_seckill()). to(topicExchange_seckill)).with("seckill.#"); } }
创建消息消费者和消息生产者
MQSenderMessage
@Service MQSenderMessage{ @Resource private rabbitTemplate; //方法:发送秒杀消息 public void sendSeckillMessage(String message){ rabbitTemplate.convertAndSend ("seckillExchange","seckill.message",message); } }
MQReceiverMessage
@Service MQReceiverMessage{ @Resoucre goodsService; orderService; //接收消息,并完成下单 @RabbitListener(queues="seckillQueue") public void queue(String message){ //这里从队列取出的是String,但需要SeckillMessage(获取参数来秒杀) JSONUtil.toBean(message,SeckillMessage.class).var; User user = seckillMessage.getUser(); //得到用户 Long goodsId = seckillMessage.getGoodsId(); //秒杀的商品id goodService.findGoodsVoByGoodsId(goodsId); //通过商品id得GoodsVo //进行下单操作 orderService.seckill(user,goodsVo); } }
再次修改SeckillController
@Resource MQSenderMessage; //装配消息生产者 doSeckill{ //抢购,向消息队列发送秒杀请求,实现秒杀异步请求 //我们发送描述消息后立即返回结果(临时)如:排队中 new SeckillMessage(user,goodid).var 将seckillMessage转为字符串发送出去 mqSenderMessage.sendSeckillMessage(JSONUtil.toJsonStr(seckillMessage)); model.addAttribute("errmsg","排队中"); return "seckillFail"; }
前端再根据后端返回信息(有兴趣可以写,毕竟前端)
秒杀安全
前面我们处理高并发,都是按照正常程序逻辑处理的,即用户正常抢购,还要考虑抢购安全性,当前抢购接口是固定的,如果泄露会有安全隐患,比如还未开启抢购或结束有人用脚本发起抢购
故要隐藏抢购接口
用户抢购时候先生成唯一一个抢购路径,返回给客户端,客户端抢购时会携带这个抢购路径,服务端做校验,成功才继续走下一步,否则直接返回
先在ResponseBeanEnum新增错误信息
REQUEST_ILLEGAL(500502,"请求非法"), SESSION_ERROR(500503,"用户信息有误") SEK_KILL_WAIT(500504,"排除中");
在OrderService中加入方法
interface { //方法:生成秒杀路径(唯一) String createPat(User user,Long goodsId); //方法:对秒杀路径进行校验 boolean cherckPath(User user,Long goodsId,String path); }
在OrderServiceImpl实现
String createPat(User user,Long goodsId){ String path = MD5Util.md5(UUIDUtil.uuid()); //生成一个随机唯一路径 RedisTemplate.opsForValue().set ("seckillPath:"+user.getId() +":"+goodsId,path,60.TimeUnitSECONDS); //保存到Redis,并设置过期时间60s return Path; } boolean cherckPath(User user,Long goodsId,String path){ if(user != null || goodId <0 || StringUtils.hasText(path)){ //校验 return false; } //取出用户路径再校验 String redisPath = (String) redisTemplate.opsForValue(). get("seckillPath:"+user.getId() +":"+goodsId,path); return path.equals(redisPath); }
@RequestMaping("/path") @ResponseBody public RespBean getPath(User user,LOng goodsId){ //获取秒杀路径 if(user == null || goodId <0 ){ //校验 return RespBean.error(RespBeanEnum.SESSION_ERROR); } String path = orderService.createPath(user,goodsId); return RespBean.success(path); }
修改SeckillController的doSeckill方法
@RequestMapping("/{path}/doSeckill") //直接带入path进行验证 @ResponseBody public RespBean doSeckill(User user,Long goodsId,@PathVariable String path){ if(user == null){ reutrn RespBean.error(RespBeanEnum.SESSION_ERROR); } boolean b = orderService.checkPath(user,goodsId,path); //这里校验路径 if(!b){ //校验失败 return RespBean.error(RespBeanEnum.REQUST_ILLEGAL); } ...//将之前的返回页面 改为 返回错误信息(RespoBeanEnum) }
改进前端页面
验证码防脚本攻击
在一些抢购活动中,可以通过验证码的方式,防止脚本攻击,如12306
使用验证码happy Captcha
网站: https://gitee.com/ramostear/Happy-Captcha
验证码的代码
在SeckillController中加入
@RequestMapping("/captcha") public void happyCaptcha(request,response,user){ ...//生成验证码的代码,官网有 //ps:该验证码默认存到session中了,key是happy-captcha redisTemplate.opsForValue().set("captcha:"+user.getId()+":"+goodsId, (String) request.getSession().getAttribute("happy-captcha"),30,Tim..); //从session中取出验证码放入到Redis }
在OrderService加入验证方法
boolean checkCaptcha(User user,Long goodsId,String captcha);
在impl中实现
boolean checkCaptcha(User user,Long goodsId,String captcha){ if(user == null || goodsId <0 || !StringUtils.hasText(captcha)){ return false; } //从Redis取出验证码 String redisCaptcha= (String) redisTemplate.opsForValue(). get("captcha:"+user.getId()+":"+goodsId); return captcha.equals(redisCaptch); }
//因为在获取秒杀路径(上面讲的)的时候就是要秒杀了,所以可以将验证码方法加在那儿
在SeckillController中
getPath(...String captcha){ if(user == null || goodsId <0){ ... } //增加一个业务逻辑-校验用户输入的验证码是否正确 boolean check = orderService.checkCaptcha(user,goodsId,captcha); if(!check){ //验证码校验失败 return RespBean.error(RespBeanEnum.CAPTCHA_ERROR); } //验证码验证成功则继续往下走验证秒杀路径 String path = ... }
改进前端页面
秒杀接口限流-防刷
即一直点击 抢购 提示信息"访问过于频繁,请重新访问"
思路分析
因为秒杀时要先调用秒杀路径(getPath),所以对此方法限流即可
修改SeckillController的getPath方法
getPath(...){ //增加Redis计数器,完成对用户的限流 如 5s 内访问次数超过5次,则是刷接口 String uri = request.getRequestURI(); //这里就是localhost:8080/sekill/path的/path ValueOperation valueOperations = redisTemplate.opsForValue(); Integer count = (Integer) valueOperations.get(uri+":"+user.getId()); if(count == null){ //说明没有这个key //初始化key且值为1,过期时间5秒 valueOperations.set(uri+":"+user.getId(),1,5,TimeUnit...); }else if(count < 5){ valueOperations.increment(uri+":"+user.getId()); //有且小于5就加1 }else{ return RepBean.error(RespBeanEnum.ACCESS_LIMIT_REACHED); //刷接口就报错 } }
但这限流通用性不强,得改进,可将改方法封装成注解,哪里需要就来个注解
自定义注解@AccessLimit
@Retention(RetentionPolicy.RUNTIME) //这个注解指定了被修饰的注解在运行时保留 @Target(ElementType.METHOD) //这个注解指定了被修饰的注解可以应用在方法上 public @interface AccessLimit{ int second(); //时间范围 int maxCount(); //访问最大值 boolean needLogin() default true //是否登录 }
然后可放到getPath()使用
@RequestMapping("/path") @AccessLimit(second = 5,maxCount = 5,needLogin = true) getPath(...){} //使用注解防刷可提高通用性和灵活性
单光这样只是单纯一个注解,本身没有任何作用
新增UserContext类似工具类
UserContext{ //每个线程都有自己的ThreadLocal,把共享数据放到这里,保证线程安全 private static ThreadLocal<User> userHolder = new ThreadLocal(); pulic static void setUser(User user){ userHolder.set(user); } pulic static User getUser(){ return userHolder.get(); } }
最后还要个自定义拦截器AccessLimitInterceptor
@Compoent AccessLimitInterceptor implements HandlerInterceptor { //先装配组件 userService,redisTemplate //这方法要得到User对象并放入到ThreadLocal,去处理@Accesslimit @Override public boolean preHandle(request,response){ if(handler instanceof HandlerMethod){ User user = getUser(request,response); UserContext.setUser(user); //存入到Threadlocal,上面写了 //现在处理@Accesslimit HandlerMethod hm = (HandlerMethod) handler; //handler转为HandlerMethod AccessLimit accessLimit = hm.getMethodAnnotation(AccessLimit.class); //获取到目标方法的注解 if(accessLimit == null){ //如果目标方法没有@A..说明该接口没处理限流 return true; } int second = accessLimit.second(); //获取注解的值 int maxCount = accessLimit.maxCount(); boolean needLogin = accessLimit.needLogin(); if(needLogin){ //说明用户必须得登录才能访问目标方法 if(user == null){ return false; //流程走到此不走了 } } //这里就是之前getPath里的方法了 String uri = request.getRequestURI(); //这里就是localhost:8080/sekill/path的/path String key = uri+":"+userId(); ValueOperation valueOperations = redisTemplate.opsForValue(); Integer count = (Integer) valueOperations.get(key); if(count == null){ //说明没有这个key //初始化key且值为1,过期时间5秒 valueOperations.set(key,1,second,TimeUnit...); }else if(count < maxCount){ valueOperations.increment(uri+":"+user.getId()); //有且小于5就加1 }else{ return false //刷接口就报错 } } } //单独写方法得到user对象的userTicket(此东西放在Cookie),方便上面用 private User getUser(request,response){ String ticket = CookieUtil.getCookieValue(request,"userTicket"); if(!StringUtils.hasText(ticket)){ //说明没有用户登录直接返回null return null; } return userService.getUserByCookie(ticket,request,response); } }
装配到Webconfig
WebConfig{ @Resource private AccessLimitInterceptor accessLimitInterceptor; @Override public void addInterceptors(InterceptorRegistry registry){ registry/addInterceptor(accessLimitInterceptor); //注册拦截器才生效 } }
Redis分布式锁(Redis章节讲过)
本项目的Redis的decrement方法具有原子性和隔离性并且Mysql的update方法也具有锁行(一个一个执行)功能,所以有效控制抢购不会超卖,但如果项目复杂;假如需要进行Redis的set操作甚至修改DB,文件那么这时候就需要Redis分布式锁扩大隔离性范围
假如没有decrement方法
SeckillController{ //得到一个uuid,作为锁的值 String uuid = UUID.randomUUID().toString(); boolean lock = redisTemplate.opsForValue().setIfAbsent("lock",uuid,3,Time..); if(lock){ //获取锁成功 //进行减1的业务操作 Long decrement = redisTemplate.opsForValue().decrement(...); ... //释放锁redis+lua脚本(下面有写) redistTemplate.execute(redisScript,Arrays.asList("lock"),uuid); } else{ //获取失败 model.addAttribute("errmsg",RespBeanEnum.SEC_KILL_RETRY.getMessage()); return "secKillFail"; //返回错误页面 } }
lock.lua脚本文件
if redis.call('get',KEYS[1]) == ARGV[1] then //比较传进来的UUID是否一致 return redis.call('del',KEYS[1]) else return 0 end
RedisConfig
//增加执行脚本 @Bean public DefaultRedisScript<long> script(){ DefaultRedisScript<long> redisScript = new DefaultRedisScript(); //设置要执行的lua脚本位置,把lock.lua文件放在resources目录下 redisScript.setLocation(new ClassPathResource("lock.lua")); redisScript.setResultType(Long.class); return redisScript; }