18.微信扫码支付
导航:根据微信支付开发文档调用支付api,下单生成支付二维码,完成支付回调逻辑echosite,推送支付通知。
微信支付入门:native文档:https://pay.weixin,qq.com/wiki/doc/api/native.php?character-6_1
18.1微信支付申请
1.注册公众号(服务号) 2.认证公众号 3.提交资料申请微信支付 4.开户登录平台验证 5.签署在线协议
18.2微信支付开发文档与SDK
在线支付开发文档:https://pay.weixin,qq.com/wiki/doc/api/index.html
微信支付接口调用流程:
按API要求组装参数,以XML方式发送(POST)给微信支付接口(URL),微信支付接口也是以XML方式给予响应,程序根据返回的结果(包括支付URL)生成二维码或判断订单状态。
各种支付方式实现:https://www.jianshu.com/p/e4289df5b54f
18.3微信支付二维码
需求:用户提交订单后,跳转微信支付页面,扫码支付。
流程:商品详情页--购物车--订单--支付方式--微信支付页--支付成功
实现思路:前端页面向后端传递订单号,后端根据订单号查询订单,检查是否为当前用户的未支付订单,是则根据订单号和金额生成支付url返回前端,前端生成支付二维码。
代码实现:
18.3.1提交订单跳转支付页
1.更新mall_service_order 下单方法返回orderId
2.在mall_web_order中添加跳转toPayPage方法,带上orderId和通过Feign获取的订单金额
18.3.2支付微服务权限集成
需求:跳转支付页渲染二维码
1.service二级目录下,新建微服务mall_service_pay
2.导包+配置文件+配置类
3.启动类添加WXPay对象
18.3.3支付微服务下单
1.mall_service_pay服务创建WxPayService实现生成微信支付二维码方法
扩展:BigDecimal使用:Java之BigDecimal详解 - HuaToDevelop - 博客园
public class WxPayServiceImpl implements WxPayService {
@Autowired
WXPay wxPay;
@Override
public Map nativePay(String orderId, Integer money) {
try {
//根据官方文档,请求接口
Map<String, String> reqData = new HashMap<>();
reqData.put("body", "商品名称");
reqData.put("out_trade_no", "000001");
//金钱计算 BigDecimal
BigDecimal yuan = new BigDecimal(0.01);
BigDecimal beishu = new BigDecimal(100);
BigDecimal fen = yuan.multiply(beishu);
fen = fen.setScale(0, BigDecimal.ROUND_UP); //向上取整
reqData.put("total_fee", String.valueOf(fen)); //商品价格,单位分
reqData.put("spbill_create_ip", "192.168.1.1");
reqData.put("notify_url", "http://www.baidu.com");
reqData.put("trade_type", "NATIVE");
Map<String, String> resultMap = wxPay.unifiedOrder(reqData);
System.out.println(resultMap);
return resultMap;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
18.3.4支付渲染页面微服务
需求:支付web远程调用nativePay生产的二维码。
支付方式 pay.html 微信支付页weixinpay.html
1.新增mall_service_pay_api模块添加Feign接口。
2.mall_web_order添加mall_service_pay_api依赖,启动类添加pay的feign
3.mall_web_orderi新增PayController
@Controller
@RequestMapping("/wxpay")
public class PayController {
@Autowired
OrderFeign orderFeign;
@Autowired
PayFeign payFeign;
//跳转到微信支付页面 wxpay.html
@GetMapping
public String wxxpay(@RequestParam("orderId")String orderId, Model model){
//1.查询订单的金额
Order order = orderFeign.findById(orderId).getData();
if(order == null){
return "fail";
}
if(!order.getPayStatus().equalsIgnoreCase("0")){
return "fail";
}
//2.基于payFeign接口,申请支付二维码
Result<Map> mapResult = payFeign.nativePay(orderId, order.getPayMoney());
if(mapResult.getData() == null){
return "fail";
}
Map map = mapResult.getData();
//3.封装结果到model
map.put("orderId", orderId);
map.put("payMoney", order.getPayMoney());
model.addAllAttributes(map);
return "wxpay";
}
}
4.将静态页面wpay.html拷贝到templates文件夹下作为模板。
5.mall_gateway_web项目的application.yml加订单渲染路径/api/wxpay/**,mall_gateway_web的UrlFilter添加/api/wxpay/
18.4支付回调逻辑处理
需求:完成支付,修改订单状态为已支付,并记录订单日志。
1.接受微信支付平台的回调信息<XML>
2.收到通知后,调用查询接口查询订单
3.如果支付结果为成功,则调用修改订单状态和记录订单日志的方法。
代码实现:
18.4.1内网穿透工具
natapp:https://natapp.cn/
18.4.2下载配置文件和客户端
详见百度
18.4.3接收回调信息
1.修改支付微服务配置文件
wxpay.notify_url:http://公网地址/wxpay/notify #回调地址
2.修改WxPayServiceImpl:@Value("${wxpay.notify_url}")
3.修改WxPayServiceImpl的nativePay方法 map.put("notify_url",notifyUrl);
18.4.4处理回调通知(重点)
1.资源文件夹ConvertUtils放到common工程的utils包下
public class ConvertUtils {
/**
* 输入流转换为xml字符串
*/
public static String converToString(InputStream inputStream) throws IOException {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
int len = 0;
while((len = inputStream.read(buffer))!= -1){
outputStream.write(buffer,0,len);
}
outputStream.close();
inputStream.close();
String result = new String(outputStream.toByteArray(), "utf-8");
return result;
}
}
2.微信支付平台发送给回调地址的是二进制流,我们需要提取二进制流转换为字符串,这个字符串就是xml格式
18.4.5收到微信通知后的逻辑
支付服务:查询订单验证通知。查询订单接口见官网。
1.WxPayService新增方法queryOrder(String orderId);
2.WxPayServiceImpl实现
@Override
public Map orderQuery(String orderId) {
try {
Map<String, String> reqData = new HashMap<>();
reqData.put("out_trade_no",orderId);
Map<String, String> map = wxPay.orderQuery(reqData);
return map;
}catch (Exception e){
e.printStackTrace();
return null;
}
}
3.修改notify方法
//查询订单xml返回状态是否成功
Map<String, String> map = WXPayUtil.xmlToMap(xml);
if ("SUCCESS".equalsIgnoreCase(map.get("return_code")) && "SUCCESS".equalsIgnoreCase(map.get("result_code"))) {
String orderId = map.get("out_trade_no");
String transactionId = map.get("transaction_id"); //交易流水号
Map<String, String> messageMap = new HashMap();
messageMap.put("orderId", orderId);
messageMap.put("transaction_id", transactionId);
//成功后往mq发消息
rabbitTemplate.convertAndSend("", RabbitMQConfig.ORDER_PAY, JSON.toJSONString(messageMap));
} else {
System.out.println("出错了return_msg" + map.get("return_msg"));
}
订单服务:修改订单状态
1.rabbitMQ配置类定义支付服务队列 ORDER_PAY
2.com.mall.order.listener包下创建OrderPayListener
3.orderService接口新增updatePayStatus方法
18.5推送支付通知
需求:用户完成扫码支付后,跳转到支付成功页面。
18.5.1服务端推送方案
将支付结果通知前端页面(服务器端推送),主要有三种实现方案:
1.Ajax短轮询setInterval
Ajax轮询主要通过页面端的JS定时异步刷新任务来实现数据的加载。需要后端调用支付接口实现根据订单号查询支付状态的方法。
缺点:实时效果差,对服务端压力大。
2.长轮询
也是通过Ajax,长轮询的服务端会在没有数据阻塞请求直到有新的数据产生或者请求超时才返回,之后客户端再重新建立连接获取数据。同样后端提供方法,区别是循环在后端。
缺点:长轮询服务端会长时间地占用资源,如果消息频繁发送的话会给服务端带来压力。
3.WebSocket双向通信
WebSocket是HTML5中的新协议,能实现浏览器与服务器之间双全工通信。如果浏览器和服务端都支持WebSocket协议的话,该方式实现的消息推送无疑是最高效简洁的。
18.5.2 RabbbitMQ Web STOMP插件
借助于RabbitMQ的Web STOMP插件,实现浏览器与服务端的全双工通信。
STOPM协议:简单文本定向消息协议。专为消息中间件设计。允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。
安插件装:百度
访问http:127.0.0.1:15670
18.5.3消息推送测试
在rabbitMQ中新建订阅发布模式(fanout)的paynotify交换机,前端监听住此交换机即可。
注意:新建一个专门用于接收订单消息的用户。
18.5.4代码实现
思路:后端在收到回调通知后发送订单号给mq(paynotify交换机),前端通过stomp连接到mq订阅paynotify交换机的消息,判断接收的订单号是不是当前页面的订单号,如果是继续跳转
1.修改notifyLogic方法,在“SUCCESS",equals(result.get("result_code"))后添加
2.mall_web_order的PayController中新增跳转支付成功接口
@GetMapping("/toPaySuccess")
public String toPaySuccess(String payMoney, Model model) {
model.addAttribute("payMoney",payMoney);
return "paysuccess";
}
代码:https://gitee.com/xuyu294636185/mall_parent.git
19订单处理
导航:通过rabbitmq的延迟消息完成超时订单处理,批量发货,自动收货
19.1超时未支付订单处理(重点)
需求:超时未支付订单,我们需要进行处理:先调用微信支付api,查询该订单的支付状态。如果未支付调用关闭订单的api,并修改订单状态为已关闭,并回滚库存数。如果该订单已经支付,则作补偿操作(修改订单状态和记录)。
思路:如何获取超时订单:使用延迟消息队列(死信队列)来实现。
延迟i消息队列:消息的生产者发送的消息并不会立刻被消费,而是在设定时间之后消费,我们可以在创建订单时发送一个延迟消息,消息为订单号,系统会在限定时间之后取出这个消息,然后查询订单的支付状态,根据结果做出相应的处理。
19.1.1rabbitmq延迟消息
RabbitMQ的两个概念:消息的TTL和死信Exchange。
消息的TTL(Time To Live):消息存活时间,MQ可以对队列和消息分别设置TTL,对队列设置就是队列没有消费者连着的保留时间,也可以对每一个单独的消息做单独的设置,超过了这个时间,我们认为这个消息就是死了,称之为死信。
我们创建一个队列queue.temp,在Arguments中添加x-message-ttl为5000ms,那每一个进入的消息5s会消失。
死信交换机DeadLetterExchange:一个消息在满足如下条件会进入死信交换机(一个交换机可以对应多个队列):
1.一个消息被Consumer拒收,并且reject方法的参数requeue是false,也就是不会再放入队列被其他消费者消费。
2.TTL到了,消息过期了。
3.队列的长度限制满了,排在前面的消息会被丢弃或者扔在死信交换机上
实现:
1.创建死信交换机exchange.timeout(fanout)
2.创建队列queue.timeout
3.建立死信交换器exchangetimeout与队列queuetimeout的绑定
4.创建队列queue.create,Arguments添加x-message-ttl = 10000;x-dead-letter-exchange:exchange.timeout
5.往queuecreate队列添加消息,观察
19.1.2代码实现
微信支付-关闭、查询订单
1.WxPayController新增closeOrder、queryOrder方法并实现
2.mall_service_pay_api新增WxPayFeign closeOrder、queryOrder方法
商品服务回滚库存
1.在mall_service_goods的controller中是实现回滚库存(resumeStock)方法
2.在mall_service_goods_api新增Feign resumeStock方法
订单关闭逻辑
需求:如果未支付,查询微信订单,确实未支付,调用关闭本地订单(修改订单表订单状态,订单日志,恢复商品表库存)和微信订单的逻辑。确实支付进行状态补偿。
1.mall_service_order新增依赖mall_service_pay_api
2.mall_service_order servce实现关闭订单closeOrder
延迟消息处理
从消息队列queue.ordertimeout中提取消息
1.修改orderServiceImpl的add方法,实现mq发送消息 rabbitTemplate.convertAndSend("","queue.ordercreate",orderId);
2.mall_service_order新建监听类监听OrderTimeout队列并调用关闭订单方法
19.2订单批量发货
19.2.1批量发货业务逻辑
1.OrderController新增batchSend方法
@Override
@Transactional
public void batchSend(List<Order> orderList) {
//循环1:判断物流公司和物流单号不能为空
for (Order order : orderList) {
if (order.getId() == null) {
throw new RuntimeException("订单号不能为空!");
}
if (order.getShippingName() == null || order.getShippingCode() == null) {
throw new RuntimeException("物流公司和物流号不能为空!");
}
}
//循环2:判断订单状态
for (Order order : orderList) {
Order orderQuery = orderMapper.selectByPrimaryKey(order.getId());
if(!orderQuery.getOrderStatus().equalsIgnoreCase("1") || !orderQuery.getConsignStatus().equalsIgnoreCase("0")){
throw new RuntimeException("订单状态有误!!");
}
}
//循环3 发货
for (Order order : orderList) {
order.setOrderStatus("2");
order.setConsignStatus("1");
order.setUpdateTime(new Date());
order.setConsignTime(new Date());
orderMapper.updateByPrimaryKeySelective(order);
//往日志订单表插入数据
// OrderLog orderLog = new OrderLog();
// orderLog.setId(idWorker.nextId() + "");
// orderLog.setOperater("admin");
// orderLog.setOperate_time(new Date());
// orderLog.setOrder_id(order.getId());
// orderLog.setOrder_status("2");
// orderLog.setPay_status("1");
// orderLog.setConsign_status("1");
// orderLog.setRemarks("批量发货");
// orderLogMapper.insertSelective(orderLog);
}
}
19.2.2对接第三方物流
获取物流信息需要对接第三方物流系统,常用的有菜鸟物流:http://www.kdniao.com,可以使用其提供的一下接口
1.预约取件API
2.即时查询API
19.3确认收获与自动收货
19.3.1需求分析
需求:用户点击确认收货之后,会修改订单状态为已完成。
1.OrderController新增take方法
19.3.2自动收货处理
需求:用户15天(可在订单表配置)没有确认收获,系统将自动收货,使用定时任务springTask实现。
逻辑:每天凌晨2点,查询order发货15天,自动收货,每天凌晨1点,删除生产环境,删除log。
Cron表达式:秒 分 时 日 月 星期 年
?:表示不指定值。eg:每月10号触发,不关心周,则在周设置?》0 0 0 10 * ?
-:表示区间。eg:10,11,12点触发》0 1-12 * * * * ?
,:表示多个值。eg:周一,周三,周五执行》 0 0 0 0 0 MON,WED,FRI
/ :用于递增触发。eg:从5秒开始,每15秒递增》5/15 * * * * * ?
L:表示最后。eg:在周字段上设置6L,表示本月最后一个星期五》0 15 10 ? * 6L
W:表示离指定日期的最近那个工作日、eg:在日字段上设置15W,表示离每月15号最近的工作日触发。》
#:序号(表示每月的第几个周几)。eg:在周字段给上设置6#3表示每月的第三个周六,注意#5正好第五周没有周六,则不会触发该配置(用在父亲节或母亲节)》0 15 10 ? * 6#3
发送消息:
1.在rabbitMQ创建order_tack队列
2.创建mall_task。导入依赖
3.创建配置文件
4.创建启动类 并开启任务注解
5.创建OrderTask实现发送消息定时任务。
接受消息:
1.mall_service_order编写消息监听类
2.orderService新增autoTack()方法
@Override
public void autoTack() {
//1.从配置表中获取15天的值
OrderConfig orderConfig = orderConfigMapper.selectByPrimaryKey("1");
Integer takeTimeout = orderConfig.getTake_timeout();
//2.推算拿几号之前发货的订单
LocalDate now = LocalDate.now();
LocalDate date = now.minusDays(takeTimeout);
//3.查询发货超过15天的订单
Example example = new Example(Order.class);
Example.Criteria criteria = example.createCriteria();
criteria.andEqualTo("orderStatus","2");
criteria.andLessThan("consignTime",date);
List<Order> orderList = orderMapper.selectByExample(example);
//4.循环 把这些订单 收货
for (Order order : orderList) {
take(order.getId(), "system");
}
}
.代码:https://gitee.com/xuyu294636185/mall_parent.git
20.秒杀
导航:秒杀业务分析,秒杀缓存方案,Spring定时任务
20.1秒杀业务分析
秒杀商品通常有两个限制:库存限制,时间限制
需求:
1.秒杀频道首页列出秒杀商品
2.点击立即抢购实现秒秒杀下单,下单时扣减库存。当库存为0或不在活动期范围内时无法秒杀。
3.秒杀下单成功,直接跳转到支付页面(微信扫码),支付成功,跳转到成功页,填写收货地址等信息。完成订单。
4.用户秒杀下单5min内未支付,取消预订单,调用微信支付的关闭订单接口,恢复库存。
20.2秒杀需求分析
秒杀核心是运用缓存减少数据库瞬间的访问压力,读取商品详情并缓存,当用户点击抢购时减少缓存中的库存数量,当库存为0或活动结束时,同步到数据库。产生的秒杀订单也不会立刻写进数据库中,而是先写到缓存,当用户付款成功后再写入数据库。
20.3秒杀商品存入缓存(重点)
秒杀商品从B端存入MySql,设置定时任务,每隔一段时间就从MySql中将符合条件的数据从MySql中查询出来并存入缓存中,redis以Hash类型进行数据存储。
Key设计:
*秒杀商品列表和秒杀商品详情都是从Redis中取出来的,所以我们首先要将符合参与秒杀的商品定时查询出来,并放入redis中
*数据存储类型先择Hash类型
*秒杀分页列表可通过redisTemplate.boundHashOps(key).values()获取结果数据。
*秒杀商品详情,可通过redisTemplate.boundHashOps(key).get(key)获取详情
20.3.1秒杀服务搭建
1.查询活动没结束的所有秒杀商品
①状态必须为审核通过 status = 1
②商品库存个数>0
③活动没有结束 endTime>=now()
④在Redis中没有该商品的缓存
⑤执行查询获取对应的结果集
2.将活动没有结束的秒杀商品入库
1.搭建mall_service_seckill,添加依赖,添加启动类
2.mall_service_seckill_api创建,添加依赖,添加com.mall.seckill.feign , com.mall.seckill.pojo
3.添加公钥copy认证服务的公钥
4.添加Oauth配置类config包ResourceServerConfig类
5.更改网关路径过滤类,添加秒杀工程过滤信息
public class UrlUtil {
/**
* 那些路径是需要令牌的
*/
public static String filterPath = "api/wseckillorder,/api/seckill";
public static boolean hasAuthorize(String url){
String[] split = filterPath.replace("**","").split(",");
for (String value : split) {
if(url.startsWith(value)){
return true; //代表当前访问地址需要传递令牌
}
}
return false; //不需要
}
}
6.更改网关配置文件,添加请求路由转发
#秒杀微服务
- id: mall_seckill_route
uri: lb://seckill
predicates:
- Path=/api/seckill/**
filters:
- StripPrefix=1
20.3.1时间操作
1.秒杀商品时间段分析
秒杀商品存在开始和结束时间,在时间段内则可以购买当前商品。
缓存数据加载思路:定义定时任务,每天凌晨会进行当天所有时间段的秒杀商品预加载。并且在B端进行限制,添加秒杀商品的话,只能添加当前日期+1的时间限制。eg:当前日期为8/05,则添加秒杀商品时,开始时间必须为6日的某一个时间段,否则不能添加。
2.秒杀商品时间段计算
将DataUtls.java添加到公共服务中。
20.3.2当前业务整体流程分析
1.查询所有符合条件的秒杀商品
①获取时间段集合并循环遍历出每一个时间段
②获取每一个时间段的名称,用于后续redis中key的设置
③状态必须为审核通过 status = 1
④商品库存个数>0
⑤秒杀商品开始时间>=当前时间段
⑥秒杀商品结束<当前时间段+2小时
⑦排除之前已经加载到redis缓存中的商品数据
⑧执行查询获取对应的结果集
2.将秒杀商品存入缓存
代码实现
启动类添加定时任务注解
时间菜单构建:将商品数据查询来存入Redis,但页面显示时,只显示正在秒杀以及往后几小时的数据,所以我们要计算出秒杀时间菜单。
定义定时任务类:
秒杀包新建task包,并新建任务类SeckillGoodsPushTask
@Component
public class SeckillGoodsPushTask {
@Autowired
private SeckillGoodsMapper seckillGoodsMapper;
@Autowired
private RedisTemplate redisTemplate;
//redis key开头
public static final String SECKILL_GOODS_KEY = "seckill_goods_";
@Scheduled(cron = "0 0/5 * * * * *")
public void loadSeckillGoodsToRedis() {
//1获取时间段集合并循环遍历出每一个时间段
List<Date> dateMenus = DateUtil.getDateMenus();
for (Date dateMenu : dateMenus) {
//2获取每一个时间段的名称,用于后续redis中key的设置
String redisExt = DateUtil.date2Str(dateMenu); //202203122
String redisKey = SECKILL_GOODS_KEY + redisExt;
Example example = new Example(SeckillGoods.class);
Example.Criteria criteria = example.createCriteria();
//3状态必须为审核通过 status = 1
criteria.andEqualTo("status","1");
//4商品库存个数>0
criteria.andGreaterThan("stockCount",0);
//5秒杀商品开始时间>=当前时间段
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
criteria.andGreaterThan("startTime", simpleDateFormat.format(dateMenu));
//6秒杀商品结束<当前时间段+2小时
criteria.andLessThan("endTime", simpleDateFormat.format(DateUtil.addDateHour(dateMenu,2)));
//7排除之前已经加载到redis缓存中的商品数据
Set keys = redisTemplate.boundHashOps(redisKey).keys();
if(keys != null && keys.size()>0){
criteria.andNotIn("id", keys);
}
//8执行查询获取对应的结果集
List<SeckillGoods> seckillGoodsList = seckillGoodsMapper.selectByExample(example);
//将秒杀商品存入缓存
for (SeckillGoods seckillGoods : seckillGoodsList) {
redisTemplate.boundHashOps(redisKey).put(seckillGoods.getId(),seckillGoods);
}
}
}
}
20.4秒杀商品首页
秒杀商品首页会显示处于秒杀中以及秒杀未开始的商品。
20.4.1首页实现
需求:首页需显示不同时间段的秒杀商品信息。①加载时间菜单 ②加载时间菜单下秒杀商品信息
1.加载时间菜单分析
每2小时切换一次活动,所以将24小时切为12个菜单,每个菜单都是2小时段,当选中某个时间段,该时间段作为第一个时间菜单。
2.加载对应秒杀商品分析
将第一个菜单的时间段作为key,在redis中查询秒杀商品集合。
20.4.2秒杀渲染服务
1.创建mall_web_seckill用于秒杀页面渲染,添加依赖,主启动类
2.添加页面静态文件
3.对接网关
#秒杀渲染微服务
- id: mall_seckill_web_route
uri: lb://seckill-web
predicates:
- Path=/api/wseckillgoods/**
filters:
- StripPrefix=1
4.前端页面构建
20.4.3加载秒杀商品实现
需求:用户在切换不同的时间段的时候,需要按照用户选择的时间去显示相对应时间段的秒杀商品。
秒杀服务-查询秒杀商品列表
1.mall_service_seckill实现查询秒杀商品list方法
2.暴露出list 的 Feign接口。
3.查询秒杀商品接口放行,更新秒杀微服务ResourceServerConfig类,对查询方法放行。
* Http安全配置,对每个到达系统的http请求连接进行校验
*/
@Override
public void configure(HttpSecurity http) throws Exception {
//所有请求必须认证通过
http.authorizeRequests()
//下边的路径放行
.antMatchers("/seckillGoods/list/**")
.permitAll()
.anyRequest()
.authenticated(); //其他地址需要认证授权
}
20.4.4秒杀渲染服务-查询秒杀商品列表
oauth2:导报,公钥,配置类
调用Feign:导报,api,EnableFeignClients,Feign拦截器
1.更新mall_web_seckill启动类
2.更新mall_web_seckill的SeckillGoodsController实现list的远程调用方法
3.更新seckill-indesc.html添加按照时间查询方法
20.5抢购按钮
需求:当前业务为用户秒杀商品为sku,所以当用户点击立即抢购按钮的时候,则直接进行下单操作。
网关添加抢购路径
#秒杀渲染微服务
- id: mall_seckill_web_route
uri: lb://seckill-web
predicates:
- Path=/api/wseckillgoods/**,/api/wseckillorder/**
filters:
- StripPrefix=1
mall_service_web创建SeckillOrderController实现add方法
21.秒杀后端
测试:功能测试 压力测试 1万/s 20万/s
导航:
1.实现秒杀异步下单,掌握如何保证生产者与消费者消息不丢失
2.实现防止恶意刷单
3.实现防止相同商品重复秒杀
4.实现秒杀下单接口隐藏
5.实现下单接口限流
20.1秒杀异步下单(重点-难点)
用户在下单时候,需要基于JWT令牌信息进行登陆人信息认证,确定当前订单属于。
问题:为什么要异步下单?
针对秒杀的场景,仅仅依靠缓存或者页面静态化等技术去解决服务端压力还是不够的,对于数据库压力还是很大,所以需要异步下单,异步是最好的解决办法,但会带来程序上的复杂性。
流程:异步service_seckill接受下单消息-》MQ--》service_consume完成剩余操作。
20.1.1秒杀服务-下单实现
通过seckill_web调用seckill的redis减库存
①将tokenDecode工具类从order工程放入到秒杀服务并声明Bean,mall_service_seckill服务
启动类添加:TokenDecode()方法
②新建下单controller
public class SecKillOrderController {
/**
* 秒杀下单
*/
@RequestMapping("/add")
public Result add(@RequestParam("time") String time, @RequestParam("id") Long id) {
//获取当前登录用户
String username = tokenDecode.getUserInfo().get("username");
boolean result = secKillOrderService.add(id, time, username);
//根据次下单成功或失败返回
if (result) {
return new Result(true, StatusCode.OK, "success");
} else {
return new Result(false, StatusCode.ERROR, "下单失败");
}
}
}
③新建service接口
④更改预加载秒杀商品
当预加载秒杀商品的时候,提前加载每一个商品的库存信息,后续减库存操作也会先预减缓存中的库存再异步扣减mysql数据。
预扣减库存会基于redis原子性操作实现。
//将秒杀商品存入缓存
for (SeckillGoods seckillGoods : seckillGoodsList) {
//放入商品信息
redisTemplate.boundHashOps(redisKey).put(seckillGoods.getId(),seckillGoods);
//放入秒杀商品库存信息
redisTemplate.boundValueOps(SECKILL_GOODS_STOCK_COUNT_KEY+seckillGoods.getId()).set(seckillGoods.getStockCount());
}
⑤秒杀下单业务层实现
package com.mall.seckill.service.impl;
import com.alibaba.fastjson.JSON;
import com.mall.seckill.config.ConfirmMessageSender;
import com.mall.seckill.config.RabbitMQConfig;
import com.mall.seckill.pojo.SeckillGoods;
import com.mall.seckill.pojo.SeckillOrder;
import com.mall.seckill.service.SecKillOrderService;
import com.mall.util.IdWorker;
import org.apache.commons.lang.StringUtils;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class SecKillOrderServiceImpl implements SecKillOrderService {
@Autowired
RedisTemplate redisTemplate;
@Autowired
IdWorker idWorker;
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
ConfirmMessageSender confirmMessageSender;
//redis key开头
public static final String SECKILL_GOODS_KEY = "seckill_goods_";
//秒杀商品库存key头
public static final String SECKILL_GOODS_STOCK_COUNT_KEY = "seckill_goods_stock_count_";
/**
* 秒杀下单
*/
@Override
public boolean add(Long id, String time, String uesrname) {
//1.redis获取商品数据以及数量,如果没有抛出异常
SeckillGoods seckillGoods = (SeckillGoods) redisTemplate.boundHashOps(SECKILL_GOODS_KEY + time).get("id");
if (seckillGoods == null) {
return false;
}
//redisTemplate使用的是String序列化
String redisStock = (String) redisTemplate.boundValueOps(SECKILL_GOODS_STOCK_COUNT_KEY + id).get();
if (StringUtils.isEmpty(redisStock)) {
return false;
}
int stock = Integer.parseInt(redisStock);
if (stock <= 0) {
return false;
}
//2.预扣减库存,如果扣成0,删掉商品信息和库存
Long decrement = redisTemplate.opsForValue().decrement(SECKILL_GOODS_STOCK_COUNT_KEY + id);
if (decrement <= 0) {
//删商品信息
redisTemplate.boundHashOps(SECKILL_GOODS_KEY + time).delete(id);
//删库存信息
redisTemplate.delete(SECKILL_GOODS_STOCK_COUNT_KEY + id);
}
//3.生成秒杀订单
SeckillOrder seckillOrder = new SeckillOrder();
seckillOrder.setId(idWorker.nextId());
seckillOrder.setSeckillId(id);
seckillOrder.setMoney(seckillGoods.getCostPrice());
seckillOrder.setUserId(uesrname);
seckillOrder.setSeckillId(Long.parseLong(seckillGoods.getSellerId()));
seckillOrder.setCreateTime(new Date());
seckillOrder.setStatus("0");
//4.订单数据往mq发
// rabbitTemplate.convertAndSend("","","");
confirmMessageSender.send("", RabbitMQConfig.SECKILL_ORDER_QUEUE, JSON.toJSONString(seckillOrder)); //在发送消息之前将消息保存在数据库中
return false;
}
}
20.1.2生产者保证消息不丢失(重点)
问题:如何保证消息不丢失
①:持久化 ②生产者confirm机制 ③消费者手动ack
1.rabbitMQ工作流程
生产者:tcp--》channel--》exchange
消费者:tcp--》channel--》queue 一个消费者监听一个队列
绑定关系:交换机类型--绑定到队列--routingkey
2.持久化
产生:生产者发送消息到消息服务器,消息到达消息服务器后,由于网络或宕机问题导致消息丢失。
解决:持久化策略:1.交换机持久化(设置durable) 2.队列持久化 3.消息持久化
3.rabbitmq数据保护机制
消息可能在持久化的过程中宕机。因此需要数据保护机制来保证消息一定会成功持久化,否则消息将一直进行发送。
Rabbit MQ数据保护机制
1.事务机制
消息到达消息服务器,开启事务,进行数据磁盘持久化,只有成功才会进行事务提交,向消息生产者返回通知。失败则继续发送消息。
缺点:同步机制,会系统间消息阻塞,影响整个系统消息吞吐,导致性能下降。不建议
2.confirm机制
基于channel设置,一旦消息投递到队列,队列就会发送等待确认消息给生产者,如果队列和消息是可持久化的,那么确认消息会等到消息成功写入磁盘后发出。
有点:异步机制,生产者发一条消息后等待确认消息同时可以继续发送后续消息,当确认消息到达后,通过回调函数处理这条确认消息,如果宕机,则返回nack消息。
拓展:https://www.cnblogs.com/vipstone/p/9350075.html
先把消息存储到数据库里,如果有成功消息返回则删除消息,如果失败则重试并报警。
开启confirm机制
1.mall_service_seckill服务更改配置文件:
rabbitmq:
host: localhost
publisher-confirms: true
2.开启队列持久化(rabbitMQ使用:导报、配置文件、配置类)
@Configuration
public class RabbitMQConfig {
//秒杀商品订单消息
public static final String SECKILL_ORDER_QUEUE = "seckill_order";
//声明队列,开启持久化
@Bean
public Queue queue() {
return new Queue(SECKILL_ORDER_QUEUE, true);
}
}
3.消息持久化
源码默认设置MessageProperties为持久化
4.增强rabbitmqTemplate
package com.mall.seckill.config;
import com.alibaba.fastjson.JSON;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* 消息待发器
*/
@Component
public class ConfirmMessageSender implements RabbitTemplate.ConfirmCallback { //实现消息到达后回调
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
RedisTemplate redisTemplate;
//reids存入的开头的KEY
public static final String MESSAGE_CONFIRM_KEY = "message_confirm_";
//规定:必须有个构造器
public ConfirmMessageSender(RabbitTemplate rabbitTemplate) {
//本类和容器中的rabbitTemplate建立联系
this.rabbitTemplate = rabbitTemplate;
//使用rabbitTemplate发的消息,必须有回调,回调给本类
rabbitTemplate.setConfirmCallback(this);
}
//回调方法,异步告知这条消息是否发送成功
/**
* @param correlationData 消息唯一标识
* @param ack 是否成功
* @param cause 原因
*/
@Override
public void confirm(CorrelationData correlationData, boolean ack, String cause) {
if (ack) {
//发送成功,删除存储空间的数据
redisTemplate.delete(correlationData.getId());
redisTemplate.delete(MESSAGE_CONFIRM_KEY + correlationData.getId());
} else {
//发送失败,进行重发
Map<String, String> map = redisTemplate.boundHashOps(MESSAGE_CONFIRM_KEY + correlationData.getId()).entries();
String exchange = map.get("exchange");
String routingKey = map.get("routingKey");
String message = map.get("message");
//重发
rabbitTemplate.convertAndSend(exchange,routingKey,message);
//预警 调用第三方接口:发短信,发邮件给负责人,记录错误日志
}
}
//自定义发送方法
public void send(String exchange, String routingKey, String message) {
//设置唯一的消息id
CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
//运维能够快速的看到哪个msg有问题了
redisTemplate.boundValueOps(correlationData.getId()).set(message);
//保存消息到redis或数据库中
HashMap<String, String> map = new HashMap<>();
map.put("exchange", exchange);
map.put("routingKey", routingKey);
map.put("message", message);
String mapStr = JSON.toJSONString(map);
redisTemplate.boundHashOps(MESSAGE_CONFIRM_KEY+correlationData.getId()).putAll(map);
// redisTemplate.boundValueOps(MESSAGE_CONFIRM_KEY + correlationData.getId()).set(mapStr);
//真正的发送
rabbitTemplate.convertAndSend(exchange, routingKey, message, correlationData);
}
}
20.1.3秒杀下单服务-更新数据库
mall_service新建异步下单服务mall_service_consume模块
导包,配置文件,新建启动类。。。
1.消费者手动ACK下单实现-(面试重点)
按照现有逻辑,当消费者消费成功消息后,会进行消费并自动通知消息服务器将该条消息删除,此种方式的实现使用消费者自动应答机制,但是非常不安全。
在生产环境下,当消费者收到消息,很可能在处理消息过程中出现意外从而导致消息丢失,因为如果使用自动应答机制是非常不安全的。
我们需要确保消费者当把消息成功处理之后,消息服务器才会将该条消息删除。此时要将自动应答转换为手动应答,只有在消息消费者将消息处理完,才会通知消息服务器将该消息删除。
①更改配置文件
rabbitmq:
host: localhost
listener:
simple:
acknowledge-mode: manual #手动
②添加RabbitMQ类
@Configuration
public class RabbitMQConfig {
//秒杀商品订单消息
public static final String SECKILL_ORDER_QUEUE = "seckill_order";
@Bean
public Queue queue() {
return new Queue(SECKILL_ORDER_QUEUE, true);
}
}
③实现监听消息并处理入库逻辑
@Component
public class SecKillOrderListener {
@Autowired
SeckillOrderService seckillOrderService;
@RabbitListener(queues = RabbitMQConfig.SECKILL_ORDER_QUEUE)
public void receiveMsg(Message message, Channel channel) {
String msg = new String(message.getBody());
message.getBody();
System.out.println("接收到了秒杀订单" + msg);
//1.监听order
SeckillOrder seckillOrder = JSON.parseObject(msg, SeckillOrder.class);
//2.先做业务逻辑
int result = seckillOrderService.createOrder(seckillOrder);
if (result >= 0) {
//2.1 没问题告诉mq收到消息,可删
try {
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); //参数:消息标识,别的队列是否也拒收此消息
} catch (IOException e) {
e.printStackTrace();
//log.error();
}
} else {
//2.2 有问题告诉mq没收到消息,重回队列
try {
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true); //参数:消息标识,别的队列是否也拒收此消息,重回队列
} catch (IOException e) {
e.printStackTrace();
//log.error();
}
}
}
}
④定义业务接口和实现类:
@Override
@Transactional
public int createOrder(SeckillOrder seckillOrder) {
//1.更改库存
int i = seckillGoodsMapper.updateStockCount(seckillOrder.getSeckillId());
if (i <= 0) {
return i; //改库存有问题,重回消息队列
}
//2.添加订单
int result = seckillOrderMapper.insertSelective(seckillOrder);
if(result <= 0){
return result;
}
return 1;
}
20.1.4流量削峰
在秒杀场景下,每秒可能产生上万消息,如果没有对消息进行限制,很有可能因为过多消息堆积从而导致消费者宕机。因此建议对每个一消费者设置处理消息总数,防止数据库挂掉(消息抓取总数)。
消息抓取数值,过小会使吞吐下降,过大会使系统OOM,官网建议消费者设置在100-300间
更新消费者:mall_service_consume服务SeckillOrderListener监听器。
channel.basicQos(100);
20.1.5秒杀渲染服务
1.mal;_service_seckill_api服务定义feign接口
2.mall_service_seckill服务定义controller
20.1.6秒杀商品真实数量获取
从redis中获取当前真实的库存量:mall_service_seckill.SeckillGoodsServiceImpl中添加从秒杀任务redis中获取当前真实的库存量,并更新。
20.2防止恶意刷单解决
生产环境中会存在用户恶意刷单行为,对系统会产生脏数据,后端访问压力大等问题。
一般解决此问题需前端机型控制,后端通过reidsincrde原子性递增解决。
20.2.1更新秒杀服务下单
mall_service_seckill服务的SeckillOrderServiceImpl类新增恶意刷单检测。
在redis中存入用户信息,5min内不能重复操作。
20.2.2防重方法实现
private String preventRepeatCommit(String username, Long id) {
String key = "seckill_user_" + username + "_id_" + id;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
//用户第一次抢购,redis记录一下
redisTemplate.expire(key, 5, TimeUnit.MINUTES);
return "success";
} else {
return "fail";
}
}
20.3防止相同商品重复秒杀
需求:一个userId只能买一个秒杀商品。
解决:查询秒杀订单表是否有userName和秒杀商品id的下单记录。
20.3.1修改下单业务实现
mall_service_seckill服务的SeckillOrderServiceImpl类添加防止重复购买
20.4秒杀下单接口隐藏(重点)
背景:用户只有在登录下才可下单,单无法防止恶意用户在登录之后猜测秒杀下单接口地址进行恶意刷单,所以需要对接口进行隐藏。
需求:在用户每一次点击抢购时,都先去生成一个随机数返回前端并存入redis,接着用户携带这个随机数去访问秒杀下单,下单接口会从redis获取随机数进行匹配,成功则进行下单操作,不成功记录非法操作。
这样可以防止恶意大量调取后端秒杀下单接口。
20.4.1将随机数工具类放入common工程中
20.4.2秒杀渲染服务定义随机数接口
1.mall_web_seckill服务 util包下放入CookieUtil.java工具类。
2.mall_web_seckill服务SeckillOrderController类添加getToken方法。
/**
* 获取随机数
*/
@GetMapping("/getRandomToken")
public String getToken(){
//1.生成随机数
String randomString = RandomUtil.getRandomString();
//2.存入redis jti-random
String jti = this.getToken();
redisTemplate.boundValueOps("randomCode_" + jti).set(randomString, 10, TimeUnit.SECONDS);
return randomString;
}
/**
* 获取request中的jti
*/
public String readCookie(){
//获取request
HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.getRequestAttributes())).getRequest();
String jti = CookieUtil.readCookie(request, "uid").get("uid");
return jti;
}
3.js修改
4.秒杀渲染服务更改
@GetMapping("/add")
public Result add(@RequestParam("time") String time, @RequestParam("id") String id, @RequestParam("random") String random) {
//校验随机数是否正确
String jti = readCookie();
String redisRandom = (String) redisTemplate.boundValueOps("randomcode_" + jti).get();
if(StringUtils.isEmpty(redisRandom)){
return new Result(false, StatusCode.ERROR,"下单失败");
}
if(!redisRandom.equals(random)){
return new Result(false, StatusCode.ERROR,"下单失败");
}
String formatStr = DateUtil.formatStr(time);
Result result = seckillorderFeign.add(formatStr, Long.parseLong(id));
return result;
}
20.5秒杀下单接口限流-难点
要对下单接口进行访问流量控制,防止过多请求进入到后端服务器。之前有nginx限流,网关限流。但是他们都是对一个大的服务进行访问限流,如果只对某一个接口方法进行限流,推荐使用google提供的guava工具包中的RateLimiter实现。其内部是基于令牌桶算法进行限流计算。
限流技术:guava
自定义注解:@interface基础的元注解信息
aop注解方式切面编程:前置增强,后置增强,环绕增强,最终增强。
springmvc自定HttperServletResponse response
流,首先定义出来,最后关流。
mall_web_seckill服务:
1.添加guava依赖
2.自定义限流注解开发
/**
* 限流注解
* 只是声明,没起作用,需要编写切面类
*/
@Retention(RetentionPolicy.RUNTIME) //注解在什么时候起作用
@Target(ElementType.METHOD) //注解运行在什么上
@Documented //用于生成javadoc,一般没人用
public @interface AccessLimit {
}
/**
* 方法限流切面类
* 让注解生效
*/
@Component //由spring处理
@Scope //作用域
@Aspect //表明这是一个切面类
public class AccessLimitAop {
@Autowired
private HttpServletResponse response;
//设置令牌生成速率
private RateLimiter rateLimiter = RateLimiter.create(2.0); //每秒钟生成2个令牌放入桶中
@Pointcut("@annotation(com.mall.seckill.web.aspect.AccessLimit)") //切点,表明对那个注解起作用
public void limit() {
}
@Around("limit()") //增强方法,环绕增强
public Object around(ProceedingJoinPoint proceedingJoinPoint) {
//限流逻辑
boolean acquire = rateLimiter.tryAcquire(); //从桶中拿令牌
Object object = null;
if (acquire) {
//拿到令牌,通过
try {
//切面往后走
object = proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
} else {
//拿不到令牌,返回ResultJSON串
Result<Object> result = new Result<>(false, StatusCode.ACCESSLIMIT, "限流了,请稍后再试。");
String msg = JSON.toJSONString(result);
//将信息写回前端
writeMsg(response, msg);
}
return object;
}
//给用户写回数据
public void writeMsg(HttpServletResponse response, String msg) {
ServletOutputStream outputStream = null;
try {
response.setContentType("application/json;charset=utf-8"); //设置响应头,标明返回json串
outputStream = response.getOutputStream(); //获取输出流
outputStream.write(msg.getBytes(StandardCharsets.UTF_8)); //设置输出字节流
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
outputStream.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}