谷粒商城高级篇下

编程入门 行业动态 更新时间:2024-10-07 22:20:56

<a href=https://www.elefans.com/category/jswz/34/1767485.html style=谷粒商城高级篇下"/>

谷粒商城高级篇下

文章目录

  • 七、购物车(redis实现)
    • 1.游客购物车(京东取消了)
    • 2.用户购物车
    • 3.环境搭建
    • 4.购物车数据结构与VO
    • 5.拦截器
      • ThreadLocal共享登录用户信息
    • 6.接口API
      • 6.1.添加商品到购物车
        • Hash数据类型操作对象
        • 接口防刷
      • 6.2.购物车列表
      • 6.3.更改购物车商品选中状态
      • 6.4.更改商品数量
      • 6.5.删除购物车商品
      • 6.6.购物车列表页选中商品
  • 八、订单模块
    • 1.环境搭建
      • 1.1.整合环境
      • 1.2.整合springsession
      • 1.3.整合线程池
      • 1.4.application.yml
    • 2.订单服务拆析
      • 2.1.构成
      • 2.2.状态
      • 2.3.订单流程
    • 3.登录拦截器
    • 4.结算页(由购物车页跳转)
      • bug1_feign丢失登录状态
      • bug2_异步丢失登录状态
      • 计算运费
    • 5.幂等性处理
      • 5.1.token机制
      • 5.2.各种锁机制
      • 5.3.各种唯一约束
      • 5.4.防重表
      • 5.5.全局唯一id
    • 6.结算页
    • 7.提交订单+幂等性处理
      • 队列业务规则截图
      • 7.1.第一版(无事务)
        • 7.1.1.生成订单
        • 7.1.2锁定库存
      • 7.2.第二版(柔性事务)
        • 7.2.1.实现方案
        • 7.2.2.实现步骤
          • order模块队列
          • ware模块队列
        • 7.2.3.解锁场景
        • 7.2.4.锁定库存
        • 7.2.5.生成订单
          • bug_解锁订单晚于解锁库存执行
        • 7.2.6.解锁订单
        • 7.2.7.解锁库存
          • bug_ware远程调用订单被登录拦截
      • 7.3.消息丢失、消息重复、消息积压
        • 优化方案
    • 8.支付
      • 8.1.member模块
        • 8.1.1.同步回调
        • 8.1.2.异步回调
      • 8.2.内网穿透联调BUG
        • 8.2.1.内网穿透
    • 9.收单
  • 九、秒杀模块
    • 1.后台接口
      • 1.1.【新增】秒杀场次
      • 1.2.【查询】指定场次关联的商品列表
      • 1.3.【新增】秒杀场次关联商品
    • 2.新增秒杀模块
    • 3.【定时上架】秒杀场次+商品
    • 4.【查询】当前可参与的秒杀商品列表
    • 5.【查询】商品详情页展示秒杀信息
    • 6.秒杀抢购
      • 6.1.高并发需关注的问题
      • 6.2.【秒杀】队列削峰
      • 6.3.TODO释放信号量
      • 6.4.TODO释放库存
  • 十、熔断、限流、链路追踪
    • 1.整合步骤
      • 1.1.定义资源
      • 1.2.定义规则
      • 1.3.检验规则是否生效
    • 2.整合sentinel
  • 总结
    • 后台请求和前台请求路由
    • 单元测试
    • 组件间调用R类型问题
    • feign调用源码
    • 查询结果使用包装类型
    • RedirectAttributes
    • 添加新模块步骤

七、购物车(redis实现)

1.游客购物车(京东取消了)

1.未登录状态下加入购物车的商品
2.关闭浏览器后再打开,商品仍然存在
3.采用redis【很好的高并发性能,强于MongoDB】4.使用user-key【相当于UUID,存在于cookie中】成为临时用户
【如果没有user-key,第一次访问购物车时,会自动分配一个user-key(临时用户身份)】逻辑:1)第一次使用购物车功能,创建user-key(分配临时用户身份)2)访问购物车时,判断当前是否登录状态(session是否存在用户信息)登录状态则获取用户购物车信息3)未登录状态,则获取临时用户身份,获取游客购物车

2.用户购物车

1.会将游客状态下的购物车,整合到登录用户名下的购物车
2.游客购物车被清空(此时退出登录游客购物车已被清空)
3.采用redis因为要获取用户登录状态,所以需要整合springsession

3.环境搭建

1.搭建模块
name:gulimall-cart
group:com.atguigu
Artifact:gulimall-cart
Package Name:com.atguigu.gulimall.cart2.上传静态资源到nginx上
/mydata/nginx/html/static/cart3.配置网关
- id: gulimall_cart_routeuri: lb://gulimall-cartpredicates:- Host=cart.gulimall4.配置DNS
# gulimall
192.168.56.10 gulimall
192.168.56.10 search.gulimall
192.168.56.10 item.gulimall
192.168.56.10 auth.gulimall
192.168.56.10 cart.gulimall5.新增依赖
<!--公共模块-->
<dependency><groupId>com.atguigu.gulimall</groupId><artifactId>gulimall-common</artifactId><version>0.0.1-SNAPSHOT</version><exclusions><exclusion><groupId>com.baomidou</groupId><artifactId>mybatis-plus-boot-starter</artifactId></exclusion></exclusions>
</dependency>
<!--redis-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>6.添加redis配置
spring.redis.host=192.168.56.10
spring.redis.port=63797.启动类增加注解
@EnableRedisHttpSession
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallCartApplication {public static void main(String[] args) {SpringApplication.run(GulimallCartApplication.class, args);}}8.springsession配置类
/*** springsession配置类* @Author: wanzenghui* @Date: 2021/11/30 22:21*/
@Configuration
public class GulimallSessionConfig {@Beanpublic CookieSerializer cookieSerializer() {DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();cookieSerializer.setDomainName("gulimall");// 放大作用域cookieSerializer.setCookieName("GULISESSION");cookieSerializer.setCookieMaxAge(60 * 60 * 24 * 7);// 指定cookie有效期7天,会话级关闭浏览器后cookie即失效return cookieSerializer;}@Beanpublic RedisSerializer<Object> springSessionDefaultRedisSerializer() {// 指定session序列化到redis的序列化器return new GenericJackson2JsonRedisSerializer();}
}

4.购物车数据结构与VO

购物车商品列表截图:

数据结构:

Map<String k1, Map<String k2, CartItemInfo>>key:用户标示登录态:gulimall:cart:userId非登录态:gulimall:cart:userKeyvalue:存储一个Hash结构的值,其中该hash结构的key是SkuId,hash结构的value是商品信息,以json字符串格式存储

购物车VO

/*** 购物车VO* 需要计算的属性需要重写get方法,保证每次获取属性都会进行计算*/
public class CartVO {private List<CartItemVO> items; // 购物项集合private Integer countNum;       // 商品件数(汇总购物车内商品总件数)private Integer countType;      // 商品数量(汇总购物车内商品总个数)private BigDecimal totalAmount; // 商品总价private BigDecimal reduce = new BigDecimal("0.00");// 减免价格public List<CartItemVO> getItems() {return items;}public void setItems(List<CartItemVO> items) {this.items = items;}public Integer getCountNum() {int count = 0;if (items != null && items.size() > 0) {for (CartItemVO item : items) {count += item.getCount();}}return count;}public Integer getCountType() {return CollectionUtils.isEmpty(items) ? 0 : items.size();}public BigDecimal getTotalAmount() {BigDecimal amount = new BigDecimal("0");// 1、计算购物项总价if (!CollectionUtils.isEmpty(items)) {for (CartItemVO cartItem : items) {if (cartItem.getCheck()) {amount = amount.add(cartItem.getTotalPrice());}}}// 2、计算优惠后的价格return amount.subtract(getReduce());}public BigDecimal getReduce() {return reduce;}public void setReduce(BigDecimal reduce) {this.reduce = reduce;}
}/*** 购物项VO(购物车内每一项商品内容)*/
public class CartItemVO {private Long skuId;                     // skuIdprivate Boolean check = true;           // 是否选中private String title;                   // 标题private String image;                   // 图片private List<String> skuAttrValues;     // 销售属性private BigDecimal price;               // 单价private Integer count;                  // 商品件数private BigDecimal totalPrice;          // 总价public Long getSkuId() {return skuId;}public void setSkuId(Long skuId) {this.skuId = skuId;}public Boolean getCheck() {return check;}public void setCheck(Boolean check) {this.check = check;}public String getTitle() {return title;}public void setTitle(String title) {this.title = title;}public String getImage() {return image;}public void setImage(String image) {this.image = image;}public List<String> getSkuAttrValues() {return skuAttrValues;}public void setSkuAttrValues(List<String> skuAttrValues) {this.skuAttrValues = skuAttrValues;}public BigDecimal getPrice() {return price;}public void setPrice(BigDecimal price) {this.price = price;}public Integer getCount() {return count;}public void setCount(Integer count) {this.count = count;}/*** 计算当前购物项总价*/public BigDecimal getTotalPrice() {return this.price.multiply(new BigDecimal("" + this.count));}public void setTotalPrice(BigDecimal totalPrice) {this.totalPrice = totalPrice;}
}

5.拦截器

业务逻辑:1)第一次使用购物车功能,创建user-key(分配临时用户身份)2)访问购物车时,判断当前是否登录状态(session是否存在用户信息)登录状态则获取用户购物车信息3)未登录状态,则获取临时用户身份,获取游客购物车拦截器功能:过滤器(URL拦截)=》拦截器(URL拦截)=》切面(方法拦截)1.preHandle1)获取用户登录信息userId,封装到ThreadLocal中,controller可以拿到2)用户未登录,分配userKey封装到ThreadLocal中,controller可以拿到2.postHandle1)判断客户端是否存在游客用户标识不存在则创建cookie,命令客户端保存游客信息user-key

ThreadLocal共享登录用户信息

public class CartInterceptor implements HandlerInterceptor {public static ThreadLocal<UserInfoTO> threadLocal = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 获取会话信息,获取登录用户信息HttpSession session = request.getSession();MemberResponseVO attribute = (MemberResponseVO) session.getAttribute(AuthConstant.LOGIN_USER);// 判断是否登录,并封装User对象给controller使用UserInfoTO user = new UserInfoTO();if (attribute != null) {// 登录状态,封装用户ID,供controller使用user.setUserId(attribute.getId());}// 获取当前请求游客用户标识user-keyCookie[] cookies = request.getCookies();if (ArrayUtils.isNotEmpty(cookies)) {for (Cookie cookie : cookies) {if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) {// 获取user-key值封装到user,供controller使用user.setUserKey(cookie.getValue());user.setTempUser(true);// 不需要重新分配break;}}}// 判断当前是否存在游客用户标识if (StringUtils.isBlank(user.getUserKey())) {// 无游客标识,分配游客标识user.setUserKey(UUID.randomUUID().toString());}// 封装用户信息(登录状态userId非空,游客状态userId空)threadLocal.set(user);return true;}@Overridepublic void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {UserInfoTO user = threadLocal.get();if (user != null && !user.isTempUser()) {// 需要为客户端分配游客信息Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, user.getUserKey());cookie.setDomain("gulimall");// 作用域cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);// 过期时间response.addCookie(cookie);}}
}

6.接口API

6.1.添加商品到购物车

商品详情页(gulimall-product),点击添加购物车, 跳转(gulimall-cart)success.html
/*** 添加商品到购物车* @param skuId 商品ID* @param num   商品数量* @param attributes    重定向数据域*/
@GetMapping(value = "/addCartItem")
public String addCartItem(@RequestParam("skuId") Long skuId,@RequestParam("num") Integer num,RedirectAttributes attributes) throws ExecutionException, InterruptedException {// 添加sku商品到购物车cartService.addToCart(skuId, num);attributes.addAttribute("skuId", skuId);// 会在url后面拼接参数// 请求重定向给addToCartSuccessPage.html,防刷return "redirect:.html";
}
/*** 添加sku商品到购物车*/
@Override
public CartItemVO addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {// 获取购物车redis操作对象BoundHashOperations<String, Object, Object> operations = getCartOps();// 获取商品String cartItemJSONString = (String) operations.get(skuId.toString());if (StringUtils.isEmpty(cartItemJSONString)) {// 购物车不存在此商品,需要将当前商品添加到购物车中CartItemVO cartItem = new CartItemVO();CompletableFuture<Void> getSkuInfoFuture = CompletableFuture.runAsync(() -> {// 远程查询当前商品信息R r = productFeignService.getInfo(skuId);SkuInfoVO skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVO>() {});cartItem.setSkuId(skuInfo.getSkuId());// 商品IDcartItem.setTitle(skuInfo.getSkuTitle());// 商品标题cartItem.setImage(skuInfo.getSkuDefaultImg());// 商品默认图片cartItem.setPrice(skuInfo.getPrice());// 商品单价cartItem.setCount(num);// 商品件数cartItem.setCheck(true);// 是否选中}, executor);CompletableFuture<Void> getSkuAttrValuesFuture = CompletableFuture.runAsync(() -> {// 远程查询attrName:attrValue信息List<String> skuSaleAttrValues = productFeignService.getSkuSaleAttrValues(skuId);cartItem.setSkuAttrValues(skuSaleAttrValues);}, executor);CompletableFuture.allOf(getSkuInfoFuture, getSkuAttrValuesFuture).get();operations.put(skuId.toString(), JSON.toJSONString(cartItem));return cartItem;} else {// 当前购物车已存在此商品,修改当前商品数量CartItemVO cartItem = JSON.parseObject(cartItemJSONString, CartItemVO.class);cartItem.setCount(cartItem.getCount() + num);operations.put(skuId.toString(), JSON.toJSONString(cartItem));return cartItem;}
}
Hash数据类型操作对象
/*** 根据用户信息获取购物车redis操作对象*/
private BoundHashOperations<String, Object, Object> getCartOps() {// 获取用户登录信息UserInfoTO userInfo = CartInterceptor.threadLocal.get();String cartKey = "";if (userInfo.getUserId() != null) {// 登录态,使用用户购物车cartKey = CartConstant.CART_PREFIX + userInfo.getUserId();} else {// 非登录态,使用游客购物车cartKey = CartConstant.CART_PREFIX + userInfo.getUserKey();}// 绑定购物车的key操作RedisBoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);return operations;
}
接口防刷

如果刷新cart.gulimall/addToCart?skuId=7&num=1该页面,会导致购物车中此商品的数量无限新增
解决方案:/addToCart请求使用重定向给/addToCartSuccessPage.html由/addToCartSuccessPage.html这个请求跳转"商品已成功加入购物车页面"(浏览器url请求已更改),达到防刷的目的
/*** 商品添加购物车成功页(防刷)*/
@GetMapping(value = "/addToCartSuccessPage.html")
public String addToCartSuccessPage(@RequestParam("skuId") Long skuId, Model model) {//重定向到成功页面。再次查询购物车数据即可CartItemVO cartItemVo = cartService.getCartItem(skuId);model.addAttribute("cartItem",cartItemVo);return "success";
}/*** 根据skuId获取购物车商品信息*/
@Override
public CartItemVO getCartItem(Long skuId) {// 获取购物车redis操作对象BoundHashOperations<String, Object, Object> cartOps = getCartOps();String cartItemJSONString = (String) cartOps.get(skuId.toString());CartItemVO cartItemVo = JSON.parseObject(cartItemJSONString, CartItemVO.class);return cartItemVo;
}

达到防刷目的的重定向请求:

6.2.购物车列表

/*** 购物车列表页* 1.拦截器封装用户信息* 1)已登录状态:封装userId+userKey到ThreadLocal中* 2)未登录状态:* 2-1)已分配游客标识,封装userKey到ThreadLocal中* 2-2)未分配游客标识,命令客户端保存cookie(user-key),并封装userKey到ThreadLocal中* 2.根据用户标识获取购物车信息* 1)已登录状态* 使用userId作为key获取购物车* 使用userKey作为key获取游客购物车,如果非空则与用户购物车合并* 2)未登录状态* 使用userKey作为key获取游客购物车* 3.返回cartList列表页*/
@GetMapping("/cart.html")
public String cartListPage(Model model) throws ExecutionException, InterruptedException {CartVO cartVO = cartService.getCart();model.addAttribute("cart", cartVO);return "cartList";
}
/*** 购物车列表*/
@Override
public CartVO getCart() throws ExecutionException, InterruptedException {CartVO cart = new CartVO();// 获取用户登录信息UserInfoTO userInfo = CartInterceptor.threadLocal.get();// 获取游客购物车List<CartItemVO> touristItems = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserKey());if (userInfo.getUserId() != null) {// 登录状态if (!CollectionUtils.isEmpty(touristItems)) {// 游客购物车非空,需要整合到用户购物车for (CartItemVO item : touristItems) {// 将商品逐个放到用户购物车addToCart(item.getSkuId(), item.getCount());}// 清楚游客购物车clearCart(CartConstant.CART_PREFIX + userInfo.getUserKey());}// 获取用户购物车(已经合并后的购物车)List<CartItemVO> items = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserId());cart.setItems(items);} else {// 未登录状态,返回游客购物车cart.setItems(touristItems);}return cart;
}/*** 根据购物车的key获取*/
private List<CartItemVO> getCartItems(String cartKey) {BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);List<Object> values = operations.values();if (!CollectionUtils.isEmpty(values)) {// 购物车非空,反序列化成商品并封装成集合返回return values.stream().map(jsonString -> JSONObject.parseObject((String) jsonString, CartItemVO.class)).collect(Collectors.toList());}return null;
}/*** 清空购物车*/
@Override
public void clearCart(String cartKey) {redisTemplate.delete(cartKey);
}

6.3.更改购物车商品选中状态

/*** 更改购物车商品选中状态*/
@GetMapping(value = "/checkItem")
public String checkItem(@RequestParam(value = "skuId") Long skuId,@RequestParam(value = "checked") Integer check) {cartService.checkItem(skuId, check);return "redirect:.html";
}/*** 更改购物车商品选中状态*/
@Override
public void checkItem(Long skuId, Integer check) {// 查询购物车商品信息CartItemVO cartItem = getCartItem(skuId);// 修改商品选中状态cartItem.setCheck(ObjectConstant.BooleanIntEnum.YES.getCode().equals(check) ? true : false);// 更新到redis中BoundHashOperations<String, Object, Object> cartOps = getCartOps();cartOps.put(skuId.toString(), JSONObject.toJSONStringWithDateFormat(cartItem, DateUtils.DATATIMEF_TIME_STR));
}

6.4.更改商品数量

/*** 改变商品数量*/
@GetMapping(value = "/countItem")
public String countItem(@RequestParam(value = "skuId") Long skuId,@RequestParam(value = "num") Integer num) {cartService.changeItemCount(skuId,num);return "redirect:.html";
}/*** 改变商品数量*/
@Override
public void changeItemCount(Long skuId, Integer num) {// 查询购物车商品信息CartItemVO cartItem = getCartItem(skuId);// 修改商品数量cartItem.setCount(num);// 更新到redis中BoundHashOperations<String, Object, Object> cartOps = getCartOps();cartOps.put(skuId.toString(), JSONObject.toJSONStringWithDateFormat(cartItem, DateUtils.DATATIMEF_TIME_STR));
}

6.5.删除购物车商品

/*** 删除商品信息*/
@GetMapping(value = "/deleteItem")
public String deleteItem(@RequestParam("skuId") Integer skuId) {cartService.deleteIdCartInfo(skuId);return "redirect:.html";
}/*** 删除购物项*/
@Override
public void deleteIdCartInfo(Integer skuId) {BoundHashOperations<String, Object, Object> operations = getCartOps();operations.delete(skuId.toString());
}

6.6.购物车列表页选中商品

注意:1、全局异常处理的原理2、需求解析:购物车列表页选中指定商品获取商品价格信息
/*** 获取当前用户的购物车所有商品项* 订单服务调用:【购物车列表页面点击确认订单时】* 从redis中获取所有选中的商品项* 并且要获取最新的商品价格信息,替换redis中的价格信息*/
@GetMapping(value = "/currentUserCartItems")
@ResponseBody
public List<CartItemVO> getCurrentCartItems() {List<CartItemVO> cartItemVoList = cartService.getUserCartItems();return cartItemVoList;
}/*** 获取购物车,最新价格*/
@Override
public List<CartItemVO> getUserCartItems() {List<CartItemVO> cartItemVoList = new ArrayList<>();// 获取当前用户登录的信息UserInfoTO userInfo = CartInterceptor.threadLocal.get();if (userInfo.getUserId() == null) {// 未登录return null;} else {// 已登录,获取用户购物车List<CartItemVO> items = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserId());if (CollectionUtils.isEmpty(items)) {throw new CartExceptionHandler();}// 筛选所有选中的skuMap<Long, CartItemVO> itemMap = items.stream().filter(item -> item.getCheck()).collect(Collectors.toMap(CartItemVO::getSkuId, val -> val));// 调用远程获取最新价格Map<Long, BigDecimal> priceMap = productFeignService.getPrice(itemMap.keySet());// 封装价格返回items = itemMap.entrySet().stream().map(entry -> {CartItemVO item = entry.getValue();item.setPrice(priceMap.get(entry.getKey()));return item;}).collect(Collectors.toList());return items;}
}

八、订单模块

1.环境搭建

1.1.整合环境

等待付款(订单详情页):

订单页(订单确认页):

结算页:

收银页:

1.拷贝静态资源到nginx中,html页面的请求资源修改为/static/order/xxx开头1)等待付款(订单详情页)静态资源拷贝=》/mydata/nginx/html/static/order/detailhtml文件拷贝至order模块,更名为detail.html2)订单页(订单列表、订单确认收货页)静态资源拷贝=》/mydata/nginx/html/static/order/listhtml文件拷贝至order模块,更名为list.html3)结算页(订单提交页)静态资源拷贝=》/mydata/nginx/html/static/order/confirmhtml文件拷贝至order模块,更名为confirm.html4)收银页(收银台、选择支付方式)静态资源拷贝=》/mydata/nginx/html/static/order/payhtml文件拷贝至order模块,更名为pay.html2.本地DNS解析配置域名
# gulimall
192.168.56.10 gulimall
192.168.56.10 search.gulimall
192.168.56.10 item.gulimall
192.168.56.10 auth.gulimall
192.168.56.10 cart.gulimall
192.168.56.10 order.gulimall3.配置好nginx转发规则(前面配置其他模块的时候已经配置)静态资源请求:/usr/share/nginx/html(已经映射上了/mydata/nginx/html)动态资源请求:转发至192.168.56.1:88(网关)4.配置网关转发- id: gulimall_order_routeuri: lb://gulimall-orderpredicates:- Host=order.gulimall5.引入thymeleaf依赖,并在开发期间禁用缓存<!--thymeleaf模板引擎--><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-thymeleaf</artifactId></dependency>6.启动类
// 开启rabbit
@EnableRabbit
// 开启SpringSession
@EnableRedisHttpSession
// 开启服务注册功能
@EnableDiscoveryClient
@MapperScan("com.atguigu.gulimall.order.dao")
@SpringBootApplication
public class GulimallOrderApplication {public static void main(String[] args) {SpringApplication.run(GulimallOrderApplication.class, args);}}
7.配置:
server:port: 9000spring:application:name: gulimall-ordercloud:nacos:discovery:server-addr: 127.0.0.1:8848# 开发期间禁用缓存thymeleaf:cache: false

1.2.整合springsession

1.在各服务添加springsession依赖(服务自治)【auth、product、search、member、order、】
<!--redis-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--整合springsession,实现session共享-->
<dependency><groupId>org.springframework.session</groupId><artifactId>spring-session-data-redis</artifactId>
</dependency>2.属性配置
server:servlet:session:timeout: 30m
spring:redis:host: 192.168.56.10port: 6379session:store-type: redis3.启动类添加配置
@EnableRedisHttpSession6.添加以下配置,放大作用域 + 指定redis序列化器【否则使用默认的jdk序列化器】
/*** springsession配置类*/
@Configuration
public class GulimallSessionConfig {@Beanpublic CookieSerializer cookieSerializer() {DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();cookieSerializer.setDomainName("gulimall");// 放大作用域cookieSerializer.setCookieName("GULISESSION");cookieSerializer.setCookieMaxAge(60 * 60 * 24 * 7);// 指定cookie有效期7天,会话级关闭浏览器后cookie即失效return cookieSerializer;}@Beanpublic RedisSerializer<Object> springSessionDefaultRedisSerializer() {// 指定session序列化到redis的序列化器return new GenericJackson2JsonRedisSerializer();}
}7.修改product模块gulimall首页,去除session中的loginUser
<li><a th:if="${session.loginUser != null}">欢迎, [[${session.loginUser.nickname}]]</a><a th:if="${session.loginUser == null}" href=".html">你好,请登录</a>
</li>8.测试
=》进入auth.gulimall并社交登录
=》进入gulimall查看cookie作用域是否修改成功
=》查看redis,session是否存储成功
=》查看gulimall首页nickname是否取到值

1.3.整合线程池

@EnableConfigurationProperties(MyThreadConfig.ThreadPoolConfigProperties.class)
@Configuration
public class MyThreadConfig {@Beanpublic ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool) {return new ThreadPoolExecutor(pool.getCoreSize(),pool.getMaxSize(),pool.getKeepAliveTime(),TimeUnit.SECONDS,new LinkedBlockingDeque<>(100000),Executors.defaultThreadFactory(),new ThreadPoolExecutor.CallerRunsPolicy());}@ConfigurationProperties(prefix = "gulimall.thread")@Datapublic class ThreadPoolConfigProperties {private Integer coreSize;private Integer maxSize;private Integer keepAliveTime;}
}
gulimall:thread:core-size: 20max-size: 200keep-alive-time: 10

1.4.application.yml

server:port: 9000servlet:session:timeout: 30mspring:application:name: gulimall-ordercloud:nacos:discovery:server-addr: 127.0.0.1:8848datasource:username: rootpassword: rooturl: jdbc:mysql://192.168.56.10:3306/gulimall_oms?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghaidriver-class-name: com.mysql.cj.jdbc.Driverjackson:date-format: yyyy-MM-dd HH:mm:sstime-zone: GMT+8redis:host: 192.168.56.10port: 6379session:store-type: redisrabbitmq:host: 192.168.56.10port: 5672# 虚拟主机virtual-host: /# 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】publisher-confirm-type: correlated# 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】publisher-returns: true# 消息在没有被队列接收时是否强行退回template:mandatory: true# 消费者手动确认模式,关闭自动确认,否则会消息丢失listener:simple:acknowledge-mode: manual# 开发期间禁用缓存thymeleaf:cache: falsemybatis-plus:# 扫描依赖的jar包下的所有mapper.xmlmapper-locations: classpath:/mapper/**/*.xmlglobal-config:db-config:id-type: autogulimall:thread:core-size: 20max-size: 200keep-alive-time: 10logging:level:com.atguigu.gulimall: debug

2.订单服务拆析

2.1.构成

电商系统涉及到3流,分别是信息流,资金流,物流,而订单系统作为中枢将三者有机的集合起来。三流:1、信息流:商品信息、优惠信息2、资金流:退款、付款3、物流:发送、退货订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。

2.2.状态

1、代付款用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超时后将自动取消订单,订单变更关闭状态。2、已付款/待发货用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调拨,配货,分拣,出库等操作。3、待收货/已发货仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物品物流状态4、已完成用尸确认收员后,订单交易完成。后续支付侧进行结算,如果订单存在问题进入售后状态5、已取消付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。6.售后中用户在付款后申请退款,或商家发货后用户申请退换货。售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。

2.3.订单流程

	订单流程是指从订单产生到完成整个流转的过程,从而行程了一套标准流程规则。而不同的产品类型或业务类型在系统中的流程会千差万别,比如上面提到的线上实物订单和虚拟订单的流程,线上实物订单与o20订单等,所以需要根据不同的类型进行构建订单流程。不管类型如何订单都包括正向流程和逆向流程,对应的场景就是购买商品和退换货流程,正向流程就是一个正常的网购步骤,订单生成->支付订单->卖家发货-→>确认收货->交易成功。而每个步骤的背后,订单是如何在多系统之间交互流转的,可概括如下图1、订单创建与支付
1)、订单创建前需要预览订单,选择收货信息等
(2)、订单创建需要锁定库存,库存有才可创建,否则不能创建
(3)、订单创建后超时未支付需要解锁库存
(4)、支付成功后,需要进行拆单,根据商品打包方式,所在仓库,物流等进行拆单
(5)、支付的每笔流水都需要记录,以待查账
(6)、订单创建,支付成功等状态都需要给MQ发送消息,方便其他系统感知订阅2、逆向流程
(1)、修改订单,用户没有提交订单,可以对订单一些信息进行修改,比如配送信息,
优惠信息,及其他一些订单可修改范围的内容,此时只需对数据进行变更即可。
(2)、订单取消,用户主动取消订单和用户超时未支付,两种情况下订单都会取消订
单,而超时情况是系统自动关闭订单,所以在订单支付的响应机制上面要做支付的

3.登录拦截器

订单模块需要用户登录后操作步骤:1.添加拦截器2.添加配置使拦截器生效
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {@Autowiredprivate LoginUserInterceptor loginUserInterceptor;/*** 配置拦截器生效*/@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");// 访问任何订单请求需要拦截校验登录}
}@Component
public class LoginUserInterceptor implements HandlerInterceptor {public static ThreadLocal<MemberResponseVO> threadLocal = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// TODO 待解释String uri = request.getRequestURI();AntPathMatcher antPathMatcher = new AntPathMatcher();boolean match = antPathMatcher.match("/order/order/status/**", uri);boolean match1 = antPathMatcher.match("/payed/notify", uri);if (match || match1) {return true;}// 获取登录用户信息MemberResponseVO attribute = (MemberResponseVO) request.getSession().getAttribute(AuthConstant.LOGIN_USER);if (attribute != null) {// 已登录,放行// 封装用户信息到threadLocalthreadLocal.set(attribute);return true;} else {// 未登录,跳转登录页面response.setContentType("text/html;charset=UTF-8");PrintWriter out = response.getWriter();out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='.html'</script>");return false;}}
}

4.结算页(由购物车页跳转)

购物车商品列表页,点击去结算跳转结算页:根据页面所选商品查询商品相关信息返回(金额、优惠等等)

购物车页:

结算页:

bug1_feign丢失登录状态

原因:浏览器请求时会带上Cookie: GULISESSION默认使用feign调用时,会根据拦截器构造请求参数RequestTemplate,而此时请求头没有带上Cookie,导致springsession无法获取用户信息解决:拦截器构造请求头
/*** feign配置类**/
@Configuration
public class GuliFeignConfig {/*** 注入拦截器* feign调用时根据拦截器构造请求头,封装cookie解决远程调用时无法获取springsession*/@Bean("requestInterceptor")public RequestInterceptor requestInterceptor() {// 创建拦截器return new RequestInterceptor() {@Overridepublic void apply(RequestTemplate template) {System.out.println("feign远程调用,拦截器封装请求头...RequestInterceptor.apply");// 1、使用RequestContextHolder拿到原生请求的请求头信(上下文环境保持器)// 从ThreadLocal中获取请求头(要保证feign调用与controller请求处在同一线程环境)ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if (requestAttributes != null) {HttpServletRequest request = requestAttributes.getRequest();// 获取controller请求对象if (request != null) {//2、同步请求头的数据(cookie)String cookie = request.getHeader("Cookie");// 获取Cookietemplate.header("Cookie", cookie);// 同步Cookie}}}};}
}

bug2_异步丢失登录状态

原因:使用异步编排时,非同一线程无法取到RequestContextHolder(上下文环境保持器)
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();// 获取controller请求对象
空指针异常解决:获取主线程ServletRequestAttributes,给每个异步线程复制一份
/*** 获取结算页(confirm.html)VO数据*/
@Override
public OrderConfirmVO OrderConfirmVO() throws ExecutionException, InterruptedException {OrderConfirmVO result = new OrderConfirmVO();// 获取当前用户MemberResponseVO member = LoginUserInterceptor.loginUser.get();// 获取当前线程上下文环境器ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {// 1.查询封装当前用户收货列表// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributesRequestContextHolder.setRequestAttributes(requestAttributes);List<MemberAddressVO> address = memberFeignService.getAddress(member.getId());result.setMemberAddressVos(address);}, executor);CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {// 2.查询购物车所有选中的商品// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributesRequestContextHolder.setRequestAttributes(requestAttributes);// 请求头应该放入GULIMALLSESSION(feign请求会根据requestInterceptors构建请求头)List<OrderItemVO> items = cartFeignService.getCurrentCartItems();result.setItems(items);}, executor);// 3.查询用户积分Integer integration = member.getIntegration();// 积分result.setIntegration(integration);// 4.金额数据自动计算// 5.TODO 防重令牌// 阻塞等待所有异步任务返回CompletableFuture.allOf(addressFuture, cartFuture).get();return result;
}

计算运费

/*** 获取运费* @param addrId 会员收货地址ID*/
@Override
public FareVO getFare(Long addrId) {FareVO fareVo = new FareVO();//收获地址的详细信息R addrInfo = memberFeignService.info(addrId);MemberAddressVO memberAddressVo = addrInfo.getData("memberReceiveAddress", new TypeReference<MemberAddressVO>() {});if (memberAddressVo != null) {String phone = memberAddressVo.getPhone();//截取用户手机号码最后一位作为我们的运费计算//1558022051String fare = phone.substring(phone.length() - 1);BigDecimal bigDecimal = new BigDecimal(fare);fareVo.setFare(bigDecimal);fareVo.setAddress(memberAddressVo);return fareVo;}return null;
}

5.幂等性处理

哪些情况需要防止:用户多次点击按钮用户页面回退再次提交微服务互相调用,由于网络问题,导致请求失败。feign触发重试机制其他业务情况例如update tab1 set col1=col1+1 where col2 = 2,每次执行结果不一样天然幂等性:1.查询接口2.更新接口update tab1 set col1=1 where col2=23.delete from user where userId = 14.insert user(userId, name) values(1, 'wan'),其中userId为主键

5.1.token机制

	服务器存储了一个令牌,页面请求时要带上令牌,服务器接收请求后会匹配令牌,匹配成功则删除令牌(再次提交则匹配失败,服务器已删除令牌。但是F5刷新的话就不一样了,会有新的token产生)注意:1.删除令牌要在执行业务代码之前2.获取redis令牌、令牌匹配、令牌删除要保证原子性(lua脚本)

5.2.各种锁机制

1.数据库悲观锁使用select* from xxx where id = 1 for update;查询的时候锁定该条数据注意:悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。id字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会非常麻烦。2.数据库乐观锁【带上版本号】这种方法适合在更新的场景中
update t_goods set count = count-1,version =version + 1 where good_id=2 and version = 1根据version版本,也就是在操作库存前先获取当前商品的version版本号,然后操作的时候带上此version号。第一次操作库存时,得到version为1,调用库存服务version变成了2﹔但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订单服务传的version还是1,再执行上面的sal语句时,就不会执行﹔因为version已经变为2了,where条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。乐观锁主要使用于处理读多写少的问题3.分布式锁:例如集群下多个定时器处理相同的数据,可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过(double check)

5.3.各种唯一约束

1.数据库唯一约束 order_sn字段【数据库层面】2.redis set防重【百度网盘妙传功能】
需要处理的数据 计算MD5放入redis的set,每次处理数据,先看MD5是否存在,存在就不处理

5.4.防重表

数据库创建防重表,插入成功才可以操作【不采用,DB慢】使用订单号orderNo作为去重表唯一索引,然后将数据插入去重表+业务操作 放在同一事物中,如果插入失败事物回滚导致业务操作也同时回滚,(如果业务操作失败也会导致插入去重表回滚)保证了数据一致性

5.5.全局唯一id

调用接口时,生成一个唯一ID,redis将数据保存到集合中(去重),存在即处理过情景1:feign调用生成一个请求唯一ID,A调用B时带上唯一ID,B处理feign请求时判断此唯一ID是否已处理(feign重试时会带上相同ID)情景2:页面请求可以使用nginx设置每一个请求的唯一id,proxy_set_header X-Request-ld $request_id; 【链路追踪】但是没办法保证请求幂等性,因为每次请求nginx都会生成一个新的ID

6.结算页

流程图:

结算页数据:获取当时购物车选中商品并计算价格
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {@AutowiredMemberFeignService memberFeignService;@AutowiredCartFeignService cartFeignService;@AutowiredWmsFeignService wmsFeignService;@AutowiredThreadPoolExecutor executor;@AutowiredStringRedisTemplate redisTemplate;/*** 获取结算页(confirm.html)VO数据*/@Overridepublic OrderConfirmVO OrderConfirmVO() throws ExecutionException, InterruptedException {OrderConfirmVO result = new OrderConfirmVO();// 获取当前用户MemberResponseVO member = LoginUserInterceptor.loginUser.get();// 获取当前线程上下文环境器ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {// 1.查询封装当前用户收货列表// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributesRequestContextHolder.setRequestAttributes(requestAttributes);List<MemberAddressVO> address = memberFeignService.getAddress(member.getId());result.setMemberAddressVos(address);}, executor);CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {// 2.查询购物车所有选中的商品// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributesRequestContextHolder.setRequestAttributes(requestAttributes);// 请求头应该放入GULIMALLSESSION(feign请求会根据requestInterceptors构建请求头)List<OrderItemVO> items = cartFeignService.getCurrentCartItems();result.setItems(items);}, executor).thenRunAsync(() -> {// 3.批量查询库存(有货/无货)List<Long> skuIds = result.getItems().stream().map(item -> item.getSkuId()).collect(Collectors.toList());R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);List<SkuHasStockTO> skuHasStocks = skuHasStock.getData(new TypeReference<List<SkuHasStockTO>>() {});Map<Long, Boolean> stocks = skuHasStocks.stream().collect(Collectors.toMap(key -> key.getSkuId(), val -> val.getHasStock()));result.setStocks(stocks);});// 4.查询用户积分Integer integration = member.getIntegration();// 积分result.setIntegration(integration);// 5.金额数据自动计算// 6.防重令牌String token = tokenUtil.createToken();result.setUniqueToken(token);// 阻塞等待所有异步任务返回CompletableFuture.allOf(addressFuture, cartFuture).get();return result;}
}

7.提交订单+幂等性处理

队列业务规则截图

7.1.第一版(无事务)

7.1.1.生成订单
/*** @Author: wanzenghui* @Date: 2021/12/20 21:59*/
@Controller
public class OrderWebController {@Autowiredprivate OrderService orderService;/*** 创建订单* 创建成功,跳转订单支付页* 创建失败,跳转结算页* 无需提交要购买的商品,提交订单时会实时查询最新的购物车商品选中数据提交*/@TokenVerify@PostMapping(value = "/submitOrder")public String submitOrder(OrderSubmitVO vo, Model model, RedirectAttributes attributes) {try {SubmitOrderResponseVO orderVO = orderService.submitOrder(vo);// 创建订单成功,跳转收银台model.addAttribute("submitOrderResp", orderVO);// 封装VO订单数据,供页面解析[订单号、应付金额]return "pay";} catch (Exception e) {// 下单失败回到订单结算页if (e instanceof VerifyPriceException) {String message = ((VerifyPriceException) e).getMessage();attributes.addFlashAttribute("msg", "下单失败" + message);} else if (e instanceof NoStockException) {String message = ((NoStockException) e).getMessage();attributes.addFlashAttribute("msg", "下单失败" + message);}return "redirect:";}}
}
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {// 提交订单共享提交数据private ThreadLocal<OrderSubmitVO> confirmVoThreadLocal = new ThreadLocal<>();@AutowiredMemberFeignService memberFeignService;@AutowiredCartFeignService cartFeignService;@AutowiredWmsFeignService wmsFeignService;@AutowiredProductFeignService productFeignService;@AutowiredOrderItemServiceImpl orderItemService;@AutowiredThreadPoolExecutor executor;@AutowiredTokenUtil tokenUtil;/*** 创建订单* GlobalTransactional:seata分布式事务,不适合高并发场景(默认基于AT实现)* @param vo 收货地址、发票信息、使用的优惠券、备注、应付总额、令牌*///@GlobalTransactional@Transactional@Overridepublic SubmitOrderResponseVO submitOrder(OrderSubmitVO orderSubmitVO) throws Exception {SubmitOrderResponseVO result = new SubmitOrderResponseVO();// 返回值// 创建订单线程共享提交数据confirmVoThreadLocal.set(orderSubmitVO);// 1.生成订单实体对象(订单 + 订单项)OrderCreateTO order = createOrder();// 2.验价应付金额(允许0.01误差,前后端计算不一致)if (Math.abs(orderSubmitVO.getPayPrice().subtract(order.getPayPrice()).doubleValue()) >= 0.01) {// 验价不通过throw new VerifyPriceException();}// 验价成功// 3.保存订单saveOrder(order);// 4.库存锁定(wms_ware_sku)// 封装待锁定商品项TOWareSkuLockTO lockTO = new WareSkuLockTO();lockTO.setOrderSn(order.getOrder().getOrderSn());List<OrderItemVO> locks = order.getOrderItems().stream().map((item) -> {OrderItemVO lock = new OrderItemVO();lock.setSkuId(item.getSkuId());lock.setCount(item.getSkuQuantity());lock.setTitle(item.getSkuName());return lock;}).collect(Collectors.toList());lockTO.setLocks(locks);// 待锁定订单项R response = wmsFeignService.orderLockStock(lockTO);if (response.getCode() == 0) {// 锁定成功// TODO 5.远程扣减积分// 封装响应数据返回System.out.println(10 / 0);result.setOrder(order.getOrder());return result;} else {// 锁定失败throw new NoStockException("");}}/*** 封装订单实体类对象* 订单 + 订单项*/private OrderCreateTO createOrder() throws Exception {OrderCreateTO result = new OrderCreateTO();// 订单// 1.生成订单号String orderSn = IdWorker.getTimeId();// 2.生成订单实体对象OrderEntity orderEntity = buildOrder(orderSn);// 3.生成订单项实体对象List<OrderItemEntity> orderItemEntities = buildOrderItems(orderSn);// 4.汇总封装(封装订单价格[订单项价格之和]、封装订单积分、成长值[订单项积分、成长值之和])summaryFillOrder(orderEntity, orderItemEntities);// 5.封装TO返回result.setOrder(orderEntity);result.setOrderItems(orderItemEntities);result.setFare(orderEntity.getFreightAmount());result.setPayPrice(orderEntity.getPayAmount());// 设置应付金额return result;}/*** 生成订单实体对象** @param orderSn 订单号*/private OrderEntity buildOrder(String orderSn) {OrderEntity orderEntity = new OrderEntity();// 订单实体类// 1.封装会员IDMemberResponseVO member = LoginUserInterceptor.loginUser.get();// 拦截器获取登录信息orderEntity.setMemberId(member.getId());// 2.封装订单号orderEntity.setOrderSn(orderSn);// 3.封装运费OrderSubmitVO orderSubmitVO = confirmVoThreadLocal.get();R fare = wmsFeignService.getFare(orderSubmitVO.getAddrId());// 获取地址FareVO fareVO = fare.getData(new TypeReference<FareVO>() {});orderEntity.setFreightAmount(fareVO.getFare());// 4.封装收货地址信息orderEntity.setReceiverName(fareVO.getAddress().getName());// 收货人名字orderEntity.setReceiverPhone(fareVO.getAddress().getPhone());// 收货人电话orderEntity.setReceiverProvince(fareVO.getAddress().getProvince());// 省orderEntity.setReceiverCity(fareVO.getAddress().getCity());// 市orderEntity.setReceiverRegion(fareVO.getAddress().getRegion());// 区orderEntity.setReceiverDetailAddress(fareVO.getAddress().getDetailAddress());// 详细地址orderEntity.setReceiverPostCode(fareVO.getAddress().getPostCode());// 收货人邮编// 5.封装订单状态信息orderEntity.setStatus(OrderConstant.OrderStatusEnum.CREATE_NEW.getCode());// 6.设置自动确认时间orderEntity.setAutoConfirmDay(OrderConstant.autoConfirmDay);// 7天// 7.设置未删除状态orderEntity.setDeleteStatus(ObjectConstant.BooleanIntEnum.NO.getCode());// 8.设置时间Date now = new Date();orderEntity.setCreateTime(now);orderEntity.setModifyTime(now);return orderEntity;}/*** 生成订单项实体对象* 购物车每项选中商品产生一个订单项*/private List<OrderItemEntity> buildOrderItems(String orderSn) throws Exception {// 封装订单项(最后确定的价格,不会再改变)List<OrderItemVO> currentCartItems = cartFeignService.getCurrentCartItems();// 获取当前用户购物车所有商品if (!CollectionUtils.isEmpty(currentCartItems)) {// 遍历购物车商品,循环构建每个订单项List<OrderItemEntity> itemEntities = currentCartItems.stream().filter(cartItem -> cartItem.getCheck()).map(cartItem -> buildOrderItem(orderSn, cartItem)).collect(Collectors.toList());return itemEntities;} else {throw new Exception();}}/*** 生成单个订单项实体对象*/private OrderItemEntity buildOrderItem(String orderSn, OrderItemVO cartItem) {OrderItemEntity itemEntity = new OrderItemEntity();// 1.封装订单号itemEntity.setOrderSn(orderSn);// 2.封装SPU信息R spuInfo = productFeignService.getSpuInfoBySkuId(cartItem.getSkuId());// 查询SPU信息SpuInfoTO spuInfoTO = spuInfo.getData(new TypeReference<SpuInfoTO>() {});itemEntity.setSpuId(spuInfoTO.getId());itemEntity.setSpuName(spuInfoTO.getSpuName());itemEntity.setSpuBrand(spuInfoTO.getSpuName());itemEntity.setCategoryId(spuInfoTO.getCatalogId());// 3.封装SKU信息itemEntity.setSkuId(cartItem.getSkuId());itemEntity.setSkuName(cartItem.getTitle());itemEntity.setSkuPic(cartItem.getImage());// 商品sku图片itemEntity.setSkuPrice(cartItem.getPrice());// 这个是最新价格,购物车模块查询数据库得到itemEntity.setSkuQuantity(cartItem.getCount());// 当前商品数量String skuAttrsVals = String.join(";", cartItem.getSkuAttrValues());itemEntity.setSkuAttrsVals(skuAttrsVals);// 商品销售属性组合["颜色:星河银","版本:8GB+256GB"]// 4.优惠信息【不做】// 5.积分信息int num = cartItem.getPrice().multiply(new BigDecimal(cartItem.getCount())).intValue();// 分值=单价*数量itemEntity.setGiftGrowth(num);// 成长值itemEntity.setGiftIntegration(num);// 积分// 6.价格信息itemEntity.setPromotionAmount(BigDecimal.ZERO);// 促销金额itemEntity.setCouponAmount(BigDecimal.ZERO);// 优惠券金额itemEntity.setIntegrationAmount(BigDecimal.ZERO);// 积分优惠金额BigDecimal realAmount = itemEntity.getSkuPrice().multiply(new BigDecimal(itemEntity.getSkuQuantity())).subtract(itemEntity.getPromotionAmount()).subtract(itemEntity.getCouponAmount()).subtract(itemEntity.getIntegrationAmount());itemEntity.setRealAmount(realAmount);// 实际金额,减去所有优惠金额return itemEntity;}/*** 汇总封装订单* 1.计算订单总金额* 2.汇总积分、成长值* 3.汇总应付总额 = 订单总金额 + 运费** @param orderEntity       订单* @param orderItemEntities 订单项*/private void summaryFillOrder(OrderEntity orderEntity, List<OrderItemEntity> orderItemEntities) {// 1.订单总额、促销总金额、优惠券总金额、积分优惠总金额BigDecimal total = new BigDecimal(0);BigDecimal coupon = new BigDecimal(0);BigDecimal promotion = new BigDecimal(0);BigDecimal integration = new BigDecimal(0);// 2.积分、成长值Integer giftIntegration = 0;Integer giftGrowth = 0;for (OrderItemEntity itemEntity : orderItemEntities) {total = total.add(itemEntity.getRealAmount());// 订单总额coupon = coupon.add(itemEntity.getCouponAmount());// 促销总金额promotion = promotion.add(itemEntity.getPromotionAmount());// 优惠券总金额integration = integration.add(itemEntity.getIntegrationAmount());// 积分优惠总金额giftIntegration = giftIntegration + itemEntity.getGiftIntegration();// 积分giftGrowth = giftGrowth + itemEntity.getGiftGrowth();// 成长值}orderEntity.setTotalAmount(total);orderEntity.setCouponAmount(coupon);orderEntity.setPromotionAmount(promotion);orderEntity.setIntegrationAmount(integration);orderEntity.setIntegration(giftIntegration);// 积分orderEntity.setGrowth(giftGrowth);// 成长值// 3.应付总额orderEntity.setPayAmount(orderEntity.getTotalAmount().add(orderEntity.getFreightAmount()));// 订单总额 + 运费}/*** 保存订单* 将封装生成的订单对象 + 订单项对象持久化到DB* @param order*/private void saveOrder(OrderCreateTO order) {// 1.持久化订单对象OrderEntity orderEntity = order.getOrder();save(orderEntity);// 2.持久化订单项对象List<OrderItemEntity> itemEntities = order.getOrderItems();orderItemService.saveBatch(itemEntities);}
}
7.1.2锁定库存

描述:所有商品锁定成功即成功,任一商品锁定失败创建订单回滚
这里没有给出代码,可以查看【第二版】

7.2.第二版(柔性事务)

7.2.1.实现方案
创建订单是高并发场景,不采用Seata(默认Seata是采用AT模式【2PC模式的变种】,性能低)
采用方案:【柔性事务】保证AP,采用本地事务+延时队列+监听死信队列解锁库存 的方案实现最终一致性
订单模块一个延时队列+死信队列,用于30min关闭订单
库存模块一个延时队列+死信队列,用于40min解锁库存

优化:

可靠消息+最终一致性:锁库存时,往队列发送一条库存解锁消息(在队列中设置超时时间而不是在消息中设置,具体查看MQ.md)消息超时后经过死信路由到达延时队列,解锁库存service监听延时队列,查询订单状态判断是否需要解锁库存
7.2.2.实现步骤
延时队列、死信队列
order模块队列
1.order创建关闭订单的延时队列、死信队列、交换机、绑定关系
/*** 创建队列,交换机,延时队列,绑定关系 的configuration* Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖*/
@Configuration
public class MyRabbitMQConfig {/*** 延时队列*/@Beanpublic Queue orderDelayQueue() {/*** Queue(String name,  队列名字*       boolean durable,  是否持久化*       boolean exclusive,  是否排他*       boolean autoDelete, 是否自动删除*       Map<String, Object> arguments) 属性【TTL、死信路由、死信路由键】*/HashMap<String, Object> arguments = new HashMap<>();arguments.put("x-dead-letter-exchange", "order-event-exchange");// 死信路由arguments.put("x-dead-letter-routing-key", "order.release.order");// 死信路由键arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟Queue queue = new Queue("order.delay.queue", true, false, false, arguments);return queue;}/*** 交换机(死信路由)*/@Beanpublic Exchange orderEventExchange() {return new TopicExchange("order-event-exchange", true, false);}/*** 死信队列*/@Beanpublic Queue orderReleaseQueue() {Queue queue = new Queue("order.release.order.queue", true, false, false);return queue;}/*** 绑定:交换机与延迟队列*/@Beanpublic Binding orderCreateBinding() {/*** String destination, 目的地(队列名或者交换机名字)* DestinationType destinationType, 目的地类型(Queue、Exhcange)* String exchange,* String routingKey,* Map<String, Object> arguments**/return new Binding("order.delay.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.create.order",null);}/*** 绑定:交换机与死信队列*/@Beanpublic Binding orderReleaseBinding() {return new Binding("order.release.order.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.release.order",null);}/*** 绑定:订单释放直接和库存释放进行*/@Beanpublic Binding orderReleaseOtherBinding() {return new Binding("stock.release.stock.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.release.other.#",null);}
}
ware模块队列
1.ware模块导入mq依赖
<!--amqp高级消息队列协议,rabbitmq实现-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId>
</dependency>2.ware模块导入配置
spring:rabbitmq:host: 192.168.56.10port: 5672# 虚拟主机virtual-host: /# 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】publisher-confirm-type: correlated# 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】publisher-returns: true# 消息在没有被队列接收时是否强行退回template:mandatory: true# 消费者手动确认模式,关闭自动确认,否则会消息丢失listener:simple:acknowledge-mode: manual3.添加注解
// 开启rabbit
@EnableRabbit4.创建配置类
/*** @Author: wanzenghui* @Date: 2021/12/15 0:04*/
@Configuration
public class MyRabbitConfig {@AutowiredRabbitTemplate rabbitTemplate;@Beanpublic MessageConverter messageConverter() {// 使用json序列化器来序列化消息,发送消息时,消息对象会被序列化成json格式return new Jackson2JsonMessageConverter();}/*** 定制RabbitTemplate* 1、服务收到消息就会回调* 1、spring.rabbitmq.publisher-confirms: true* 2、设置确认回调* 2、消息正确抵达队列就会进行回调* 1、spring.rabbitmq.publisher-returns: true* spring.rabbitmq.template.mandatory: true* 2、设置确认回调ReturnCallback* <p>* 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)*/@PostConstruct   // (MyRabbitConfig对象创建完成以后,执行这个方法)public void initRabbitTemplate() {/*** 发送消息触发confirmCallback回调* @param correlationData:当前消息的唯一关联数据(如果发送消息时未指定此值,则回调时返回null)* @param ack:消息是否成功收到(ack=true,消息抵达Broker)* @param cause:失败的原因*/rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {System.out.println("发送消息触发confirmCallback回调" +"\ncorrelationData ===> " + correlationData +"\nack ===> " + ack + "" +"\ncause ===> " + cause);System.out.println("=================================================");});/*** 消息未到达队列触发returnCallback回调* 只要消息没有投递给指定的队列,就触发这个失败回调* @param message:投递失败的消息详细信息* @param replyCode:回复的状态码* @param replyText:回复的文本内容* @param exchange:接收消息的交换机* @param routingKey:接收消息的路由键*/rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {// 需要修改数据库 消息的状态【后期定期重发消息】System.out.println("消息未到达队列触发returnCallback回调" +"\nmessage ===> " + message +"\nreplyCode ===> " + replyCode +"\nreplyText ===> " + replyText +"\nexchange ===> " + exchange +"\nroutingKey ===> " + routingKey);System.out.println("==================================================");});}
}5.创建ware解锁库存的延时队列、死信队列、交换机、绑定关系
/*** 创建队列,交换机,延时队列,绑定关系 的configuration* 1.Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖* 2.懒加载,只有第一次使用的时候才会创建(例如监听队列)*/
@Configuration
public class MyRabbitMQConfig {/*** 用于首次创建队列、交换机、绑定关系的监听* @param message*/@RabbitListener(queues = "stock.release.stock.queue")public void handle(Message message) {}/*** 交换机* Topic,可以绑定多个队列*/@Beanpublic Exchange stockEventExchange() {//String name, boolean durable, boolean autoDelete, Map<String, Object> argumentsreturn new TopicExchange("stock-event-exchange", true, false);}/*** 死信队列*/@Beanpublic Queue stockReleaseStockQueue() {//String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> argumentsreturn new Queue("stock.release.stock.queue", true, false, false);}/*** 延时队列*/@Beanpublic Queue stockDelay() {HashMap<String, Object> arguments = new HashMap<>();arguments.put("x-dead-letter-exchange", "stock-event-exchange");arguments.put("x-dead-letter-routing-key", "stock.release");// 消息过期时间 2分钟arguments.put("x-message-ttl", 120000);return new Queue("stock.delay.queue", true, false, false,arguments);}/*** 绑定:交换机与死信队列*/@Beanpublic Binding stockLocked() {//String destination, DestinationType destinationType, String exchange, String routingKey,// 			Map<String, Object> argumentsreturn new Binding("stock.release.stock.queue",Binding.DestinationType.QUEUE,"stock-event-exchange","stock.release.#",null);}/*** 绑定:交换机与延时队列*/@Beanpublic Binding stockLockedBinding() {return new Binding("stock.delay.queue",Binding.DestinationType.QUEUE,"stock-event-exchange","stock.locked",null);}
}
7.2.3.解锁场景
场景:1.下订单成功,用户手动取消 || 订单过期未支付2.其他业务调用失败,订单回滚,但库存锁定成功(最终一致性,需要解锁库存)
7.2.4.锁定库存
1.锁定库存
2.往库存工作单存储当前锁定(本地事务表)
3.往延时队列发送库存锁定成功消息
/*** 库存锁定,sql执行锁定锁定** @param lockTO* @return 锁定结果* @Transactional(rollbackFor = NoStockException.class):指定的异常出现会导致回滚* 未指定异常,任何运行时异常都会导致回滚,可以省略rollbackFor*/
@Transactional
@Override
public Boolean orderLockStock(WareSkuLockTO lockTO) {// 按照收货地址找到就近仓库,锁定库存(暂未实现)// 采用方案:获取每项商品在哪些仓库有库存,轮询尝试锁定,任一商品锁定失败回滚// 1.往库存工作单存储当前锁定(本地事务表)WareOrderTaskEntity taskEntity = new WareOrderTaskEntity();taskEntity.setOrderSn(lockTO.getOrderSn());orderTaskService.save(taskEntity);// 2.封装待锁定库存项MapMap<Long, OrderItemVO> lockItemMap = lockTO.getLocks().stream().collect(Collectors.toMap(key -> key.getSkuId(), val -> val));// 3.查询(库存 - 库存锁定 >= 待锁定库存数)的仓库List<WareSkuEntity> wareEntities = baseMapper.selectListHasSkuStock(lockItemMap.keySet()).stream().filter(entity -> entity.getStock() - entity.getStockLocked() >= lockItemMap.get(entity.getSkuId()).getCount()).collect(Collectors.toList());// 判断是否查询到仓库if (CollectionUtils.isEmpty(wareEntities)) {// 匹配失败,所有商品项没有库存Set<Long> skuIds = lockItemMap.keySet();throw new NoStockException(skuIds);}// 将查询出的仓库数据封装成Map,key:skuId  val:wareIdMap<Long, List<WareSkuEntity>> wareMap = wareEntities.stream().collect(Collectors.groupingBy(key -> key.getSkuId()));// 4.判断是否为每一个商品项至少匹配了一个仓库List<WareOrderTaskDetailEntity> taskDetails = new ArrayList<>();// 库存锁定工作单详情Map<Long, StockLockedTO> lockedMessageMap = new HashMap<>();// 库存锁定工作单消息if (wareMap.size() < lockTO.getLocks().size()) {// 匹配失败,部分商品没有库存Set<Long> skuIds = lockItemMap.keySet();skuIds.removeAll(wareMap.keySet());// 求商品项差集throw new NoStockException(skuIds);} else {// 所有商品都存在有库存的仓库// 5.锁定库存for (Map.Entry<Long, List<WareSkuEntity>> entry : wareMap.entrySet()) {Boolean skuStocked = false;Long skuId = entry.getKey();// 商品OrderItemVO item = lockItemMap.get(skuId);Integer count = item.getCount();// 待锁定个数List<WareSkuEntity> hasStockWares = entry.getValue();// 有足够库存的仓库for (WareSkuEntity ware : hasStockWares) {Long num = baseMapper.lockSkuStock(skuId, ware.getWareId(), count);if (num == 1) {// 锁定成功,跳出循环skuStocked = true;// 创建库存锁定工作单详情(每一件商品锁定详情)WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity(null, skuId,item.getTitle(), count, taskEntity.getId(), ware.getWareId(),WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode());taskDetails.add(taskDetail);// 创建库存锁定工作单消息(每一件商品一条消息)StockDetailTO detailMessage = new StockDetailTO();BeanUtils.copyProperties(taskDetail, detailMessage);StockLockedTO lockedMessage = new StockLockedTO(taskEntity.getId(), detailMessage);lockedMessageMap.put(skuId, lockedMessage);break;}}if (!skuStocked) {// 匹配失败,当前商品所有仓库都未锁定成功throw new NoStockException(skuId);}}}// 6.往库存工作单详情存储当前锁定(本地事务表)orderTaskDetailService.saveBatch(taskDetails);// 7.发送消息for (WareOrderTaskDetailEntity taskDetail : taskDetails) {StockLockedTO message = lockedMessageMap.get(taskDetail.getSkuId());message.getDetail().setId(taskDetail.getId());// 存储库存详情IDrabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", message);}return true;
}
7.2.5.生成订单
下单成功,往订单解锁延时队列发送消息
// 发送创建订单到延时队列
/*** 创建订单* GlobalTransactional:seata分布式事务,不适合高并发场景(默认基于AT实现)** @param vo 收货地址、发票信息、使用的优惠券、备注、应付总额、令牌*/
//@GlobalTransactional
@Transactional
@Override
public SubmitOrderResponseVO submitOrder(OrderSubmitVO orderSubmitVO) throws Exception {SubmitOrderResponseVO result = new SubmitOrderResponseVO();// 返回值// 创建订单线程共享提交数据confirmVoThreadLocal.set(orderSubmitVO);// 1.生成订单实体对象(订单 + 订单项)OrderCreateTO order = createOrder();// 2.验价应付金额(允许0.01误差,前后端计算不一致)if (Math.abs(orderSubmitVO.getPayPrice().subtract(order.getPayPrice()).doubleValue()) >= 0.01) {// 验价不通过throw new VerifyPriceException();}// 验价成功// 3.保存订单saveOrder(order);// 4.库存锁定(wms_ware_sku)// 封装待锁定商品项TOWareSkuLockTO lockTO = new WareSkuLockTO();lockTO.setOrderSn(order.getOrder().getOrderSn());List<OrderItemVO> locks = order.getOrderItems().stream().map((item) -> {OrderItemVO lock = new OrderItemVO();lock.setSkuId(item.getSkuId());lock.setCount(item.getSkuQuantity());lock.setTitle(item.getSkuName());return lock;}).collect(Collectors.toList());lockTO.setLocks(locks);// 待锁定订单项R response = wmsFeignService.orderLockStock(lockTO);if (response.getCode() == 0) {// 锁定成功// TODO 5.远程扣减积分// 封装响应数据返回result.setOrder(order.getOrder());//System.out.println(10 / 0); // 模拟订单回滚,库存不会滚// 6.发送创建订单到延时队列rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());return result;} else {// 锁定失败throw new NoStockException("");}
}
bug_解锁订单晚于解锁库存执行

bug:订单解锁晚于库存解锁执行导致库存永远不会被解锁bug重现:机器卡顿,订单解锁的消息延迟抵达,造成订单解锁晚于库存解锁执行,此时库存解锁失败,因为订单还处于未支付状态,导致库存未解锁,并且消息已经确认解决方案:方案一:库存解锁消息重新入队(不建议,因为无法判断消息延迟的具体时间,造成消息空转浪费资源)方案二:消费订单解锁消息时,往库存解锁的死信队列丢一条消息(同时是消费者和生产者)

7.2.6.解锁订单
场景:1.订单过期未支付实现:生成订单时创建消息放入延时队列解锁订单方法监听死信队列解锁订单时为了防止订单解锁晚于库存解锁的BUG,此时主动往解锁库存的死信队列发送一条消息
/*** 创建队列,交换机,延时队列,绑定关系 的configuration* 1.Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖* 2.懒加载,只有第一次使用的时候才会创建(例如监听队列)*/
@Configuration
public class MyRabbitMQConfig {/*** 延时队列*/@Beanpublic Queue orderDelayQueue() {/*** Queue(String name,  队列名字*       boolean durable,  是否持久化*       boolean exclusive,  是否排他*       boolean autoDelete, 是否自动删除*       Map<String, Object> arguments) 属性【TTL、死信路由、死信路由键】*/HashMap<String, Object> arguments = new HashMap<>();arguments.put("x-dead-letter-exchange", "order-event-exchange");// 死信路由arguments.put("x-dead-letter-routing-key", "order.release.order");// 死信路由键arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟return new Queue("order.delay.queue", true, false, false, arguments);}/*** 交换机(死信路由)*/@Beanpublic Exchange orderEventExchange() {return new TopicExchange("order-event-exchange", true, false);}/*** 死信队列*/@Beanpublic Queue orderReleaseQueue() {return new Queue("order.release.order.queue", true, false, false);}/*** 绑定:交换机与订单解锁延迟队列*/@Beanpublic Binding orderCreateBinding() {/*** String destination, 目的地(队列名或者交换机名字)* DestinationType destinationType, 目的地类型(Queue、Exhcange)* String exchange,* String routingKey,* Map<String, Object> arguments**/return new Binding("order.delay.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.create.order",null);}/*** 绑定:交换机与订单解锁死信队列*/@Beanpublic Binding orderReleaseBinding() {return new Binding("order.release.order.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.release.order",null);}/*** 绑定:交换机与库存解锁*/@Beanpublic Binding orderReleaseOtherBinding() {return new Binding("stock.release.stock.queue",Binding.DestinationType.QUEUE,"order-event-exchange","order.release.other.#",null);}
}
/*** 定时关单,监听死信队列* @Author: wanzenghui* @Date: 2022/1/3 17:24*/
@Slf4j
@RabbitListener(queues = "order.release.order.queue")
@Component
public class OrderCloseListener {@AutowiredOrderService orderService;@RabbitHandlerpublic void handleOrderRelease(OrderEntity order, Message message, Channel channel) throws IOException {log.debug("订单解锁,订单号:" + order.getOrderSn());try {orderService.closeOrder(order);// 手动删除消息channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);} catch (Exception e) {// 解锁失败 将消息重新放回队列,让别人消费channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);}}
}@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {/*** 关闭订单*/@Overridepublic void closeOrder(OrderEntity order) {OrderEntity _order = getById(order.getId());if (OrderConstant.OrderStatusEnum.CREATE_NEW.getCode().equals(_order.getStatus())) {// 待付款状态允许关单OrderEntity temp = new OrderEntity();temp.setId(order.getId());temp.setStatus(OrderConstant.OrderStatusEnum.CANCLED.getCode());updateById(temp);// 发送消息给MQOrderTO orderTO = new OrderTO();BeanUtils.copyProperties(_order, orderTO);//TODO 确保每个消息发送成功,给每个消息做好日志记录,(给数据库保存每一个详细信息)保存每个消息的详细信息//TODO 定期扫描数据库,重新发送失败的消息rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTO);}}
}
/*** 解锁库存,监听死信队列** @author: wanzenghui**/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Component
public class StockReleaseListener {@Autowiredprivate WareSkuService wareSkuService;/*** 客户取消订单,监听到消息*/@RabbitHandlerpublic void handleOrderCloseRelease(OrderTO orderTo, Message message, Channel channel) throws IOException {log.debug("订单关闭准备解锁库存,订单号:" + orderTo.getOrderSn());try {wareSkuService.unLockStock(orderTo);// 手动删除消息channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);} catch (Exception e) {// 解锁失败 将消息重新放回队列,让别人消费channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);}}
}@Slf4j
@Service("wareSkuService")
public class WareSkuServiceImpl extends ServiceImpl<WareSkuDao, WareSkuEntity> implements WareSkuService {/*** 库存解锁* 订单解锁触发,防止库存解锁消息优先于订单解锁消息到期,导致库存无法解锁*/@Transactional@Overridepublic void unLockStock(OrderTO order) {String orderSn = order.getOrderSn();// 订单号// 1.根据订单号查询库存锁定工作单WareOrderTaskEntity task = orderTaskService.getOrderTaskByOrderSn(orderSn);// 2.按照工作单查询未解锁的库存,进行解锁List<WareOrderTaskDetailEntity> taskDetails = orderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>().eq("task_id", task.getId()).eq("lock_status", WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode()));// 并发问题// 3.解锁库存for (WareOrderTaskDetailEntity taskDetail : taskDetails) {unLockStock(taskDetail.getSkuId(), taskDetail.getWareId(), taskDetail.getSkuNum(), taskDetail.getId());}}/*** 库存解锁* 1.sql执行释放锁定* 2.更新库存工作单状态为已解锁** @param skuId* @param wareId* @param count*/public void unLockStock(Long skuId, Long wareId, Integer count, Long taskDetailId) {// 1.库存解锁baseMapper.unLockStock(skuId, wareId, count);// 2.更新工作单的状态 已解锁WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity();taskDetail.setId(taskDetailId);taskDetail.setLockStatus(WareOrderTaskConstant.LockStatusEnum.UNLOCKED.getCode());orderTaskDetailService.updateById(taskDetail);}
}
7.2.7.解锁库存
场景:1.下订单成功,用户手动取消 || 订单过期未支付2.订单回滚,其他业务调用失败,但库存锁定成功(最终一致性,解锁库存)实现:监听死信队列,拿到库存锁定工作单解锁库存(解锁时判断是否允许解锁)
/*** 监听死信队列,解锁库存* 库存解锁,监听** @author: wanzenghui**/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Component
public class StockReleaseListener {@Autowiredprivate WareSkuService wareSkuService;/*** 库存解锁(监听死信队列)* 场景:* 1.下订单成功【需要解锁】(订单过期未支付、被用户手动取消、其他业务调用失败(订单回滚))* 2.下订单失败【无需解锁】(库存锁定失败(库存锁定已回滚,但消息已发出))* <p>* 注意:需要开启手动确认,不要删除消息,当前解锁失败需要重复解锁*/@RabbitHandlerpublic void handleStockLockedRelease(StockLockedTO locked, Message message, Channel channel) throws IOException {log.debug("收到解锁库存消息");//当前消息是否重新派发过来// Boolean redelivered = message.getMessageProperties().getRedelivered();try {// 解锁库存wareSkuService.unLockStock(locked);// 解锁成功,手动确认channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);} catch (Exception e) {// 解锁失败,消息入队channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);}}
}@Slf4j
@Service("wareSkuService")
public class WareSkuServiceImpl extends ServiceImpl<WareSkuDao, WareSkuEntity> implements WareSkuService {@AutowiredWareOrderTaskServiceImpl orderTaskService;@AutowiredWareOrderTaskDetailServiceImpl orderTaskDetailService;@AutowiredOrderFeignService orderFeignService;/*** 库存解锁*/@Overridepublic void unLockStock(StockLockedTO locked) throws Exception {StockDetailTO taskDetailTO = locked.getDetail();// 库存工作单详情TOWareOrderTaskDetailEntity taskDetail = orderTaskDetailService.getById(taskDetailTO.getId());// 库存工作单详情Entityif (taskDetail != null) {// 1.工作单未回滚,需要解锁WareOrderTaskEntity task = orderTaskService.getById(locked.getId());// 库存工作单EntityR r = orderFeignService.getOrderByOrderSn(task.getOrderSn());// 订单Entityif (r.getCode() == 0) {// 订单数据返回成功OrderTO order = r.getData(new TypeReference<OrderTO>() {});if (order == null || OrderConstant.OrderStatusEnum.CANCLED.getCode().equals(order.getStatus())) {// 2.订单已回滚 || 订单未回滚已取消状态if (WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode().equals(taskDetail.getLockStatus())) {// 订单已锁定状态,需要解锁(消息确认)unLockStock(taskDetailTO.getSkuId(), taskDetailTO.getWareId(), taskDetailTO.getSkuNum(), taskDetailTO.getId());} else {// 订单其他状态,不可解锁(消息确认)}}} else {// 订单远程调用失败(消息重新入队)throw new Exception();}} else {// 3.无库存锁定工作单记录,已回滚,无需解锁(消息确认)}}/*** 库存解锁* 1.sql执行释放锁定* 2.更新库存工作单状态为已解锁** @param skuId* @param wareId* @param count*/@Overridepublic void unLockStock(Long skuId, Long wareId, Integer count, Long taskDetailId) {// 1.库存解锁baseMapper.unLockStock(skuId, wareId, count);// 2.更新工作单的状态 已解锁WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity();taskDetail.setId(taskDetailId);taskDetail.setLockStatus(WareOrderTaskConstant.LockStatusEnum.UNLOCKED.getCode());orderTaskDetailService.updateById(taskDetail);}
}
bug_ware远程调用订单被登录拦截
// ware远程调用订单,请求头没有登录消息被拦截,应该放行/*** 登录拦截器* 从session中获取了登录信息(redis中),封装到了ThreadLocal中** @Author: wanzenghui* @Date: 2021/12/20 22:29*/
@Component
public class LoginUserInterceptor implements HandlerInterceptor {public static ThreadLocal<MemberResponseVO> loginUser = new ThreadLocal<>();@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {// 放行无需登录的请求String uri = request.getRequestURI();AntPathMatcher antPathMatcher = new AntPathMatcher();// 匹配器boolean match = antPathMatcher.match("/order/order/status/**", uri);// 查询订单消息boolean match1 = antPathMatcher.match("/payed/notify", uri);// 支付回调if (match || match1) {return true;}// 获取登录用户信息MemberResponseVO attribute = (MemberResponseVO) request.getSession().getAttribute(AuthConstant.LOGIN_USER);if (attribute != null) {// 已登录,放行// 封装用户信息到threadLocalloginUser.set(attribute);return true;} else {// 未登录,跳转登录页面response.setContentType("text/html;charset=UTF-8");PrintWriter out = response.getWriter();out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='.html'</script>");// session.setAttribute("msg", "请先进行登录");// response.sendRedirect(".html");return false;}}
}

7.3.消息丢失、消息重复、消息积压

  • 消息丢失:

    • 情况1:网络连接失败,消息未抵达Broker
    • 解决:发送消息时同时将消息持久化到MQ中并插入DB(DB消息状态为已抵达)
      当出现异常时在catch处修改消息状态为错误抵达
    • 情况2:消息抵达Broker,但为抵达queue,消息会丢失(只有抵达了queue消息才会持久化)
    • 解决:开启生产者确认机制,将触发returnCallback.returnedMessage的消息DB状态修改为错误抵达
    • 情况3:消费者未ack时宕机,导致消息丢失
    • 解决:开启消费者手动ack

  • 消息重复
    • 情况1:业务逻辑已经执行,但是ack时宕机,消息由unack变为ready,消息重新入队
    • 解决:将接口设计成幂等性,例如库存解锁时判断工作单的状态,已解锁则无操作
    • 解决2:防重表

  • 消息积压
    • 情况1:生产者流量太大

    • 解决:减慢发送消息速率(验证码、防刷、重定向、削峰)

    • 情况2:消费者能力不足或宕机

    • 解决:上线更多消费者

    • 解决2:上线专门的队列消费服务,批量取出消息入库,离线处理业务慢慢处理

1.网络宕机修改mq_message消息状态
/*** 关闭订单*/
@Override
public void closeOrder(OrderEntity order) {OrderEntity _order = getById(order.getId());if (OrderConstant.OrderStatusEnum.CREATE_NEW.getCode().equals(_order.getStatus())) {// 代付款状态允许关单OrderEntity temp = new OrderEntity();temp.setId(order.getId());temp.setStatus(OrderConstant.OrderStatusEnum.CANCLED.getCode());updateById(temp);try {// 发送消息给MQOrderTO orderTO = new OrderTO();BeanUtils.copyProperties(_order, orderTO);//TODO 持久化消息到mq_message表中,并设置消息状态为3-已抵达(保存日志记录)rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTO);} catch (Exception e) {// TODO 消息为抵达Broker,修改mq_message消息状态为2-错误抵达}}
}2.消息未抵达queue时修改mq_message消息状态
@Configuration
public class MyRabbitConfig {/*** 定制RabbitTemplate* 1、服务收到消息就会回调* 1、spring.rabbitmq.publisher-confirms: true* 2、设置确认回调* 2、消息正确抵达队列就会进行回调* 1、spring.rabbitmq.publisher-returns: true* spring.rabbitmq.template.mandatory: true* 2、设置确认回调ReturnCallback* <p>* 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)*/@PostConstruct   // (MyRabbitConfig对象创建完成以后,执行这个方法)public void initRabbitTemplate() {/*** 消息未到达队列触发returnCallback回调* 只要消息没有投递给指定的队列,就触发这个失败回调* @param message:投递失败的消息详细信息* @param replyCode:回复的状态码* @param replyText:回复的文本内容* @param exchange:接收消息的交换机* @param routingKey:接收消息的路由键*/rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {System.out.println("消息未到达队列触发returnCallback回调" +"\nmessage ===> " + message +"\nreplyCode ===> " + replyCode +"\nreplyText ===> " + replyText +"\nexchange ===> " + exchange +"\nroutingKey ===> " + routingKey);// TODO 修改mq_message,设置消息状态为2-错误抵达【后期定时器重发消息】});}
}3.开启消费者手动确认,详见之前属性配置处4.将消费者接口设计成幂等性防止重复消费
优化方案
可以添加一个消息服务,各模块调用发送消息API即可
实现消息存库+异常修改状态思考:如果feign调用失败不会出现问题,做好本地事务(feign失败即回滚)+接口幂等性即可

8.支付

参考支付.md

8.1.member模块

支付成功回调地址member模块,所以这里要作member的相关配置1.上传静态资源(订单列表页,用于支付成功同步回调)
将订单页 文件夹下静态资源拷贝到=>/mydata/nginx/html/static/member
将orderList.html拷贝到member模块下2.member模块增加登录拦截器				LoginUserInterceptor增加拦截器配置类,将登录拦截器注册进去	  WebMvcConfigurer3.网关配置转发- id: gulimall_member_routeuri: lb://gulimall-memberpredicates:- Host=member.gulimall4.增加本地host映射
# gulimall
192.168.56.10 gulimall
192.168.56.10 search.gulimall
192.168.56.10 item.gulimall
192.168.56.10 auth.gulimall
192.168.56.10 cart.gulimall
192.168.56.10 order.gulimall
192.168.56.10 member.gulimall5.Feign请求头丢失问题订单支付成功回调:.html此时浏览器访问时member是带了cookie的,但远程请求order查询订单数据时,请求头丢失添加feign请求拦截器,封装请求头:GuliFeignConfig

8.1.1.同步回调
不建议在同步回调直接修改订单状态,推荐在异步回调的时候修改订单状态
8.1.2.异步回调

1.推荐在异步回调时修改订单状态
2.修改订单状态前要验签sign程序执行完后必须打印输出“success”(不包含引号)。如果商户反馈给支付宝的字符不是 success 这7个字符,支付宝服务器会不断重发通知,直到超过 24 小时 22 分钟。一般情况下,25 小时以内完成 8 次通知(通知的间隔频率一般是:4m,10m,10m,1h,2h,6h,15h)。

8.2.内网穿透联调BUG

8.2.1.内网穿透
参考nps、npc相关文档搭建内网穿透
  • bug1:使用nps作内网穿透,无法使用域名必须使用IP:PORT,所以会造成nginx无法根据访问的域名gulimall来匹配请求
    • 解决:
      • 方案一:修改nginx配置文件gulimall.conf监听server_name 124.223.7.41

  • bug2:添加以上域名监听后,访问124.223.7.41:8888出现404异常
    • 原因:网关88未拦截到请求
    • 解决:
      • 方案一:在网关增加拦截规则,拦截124.223.7.41,将请求发送到order.gulimall
      • 方案二:在nginx转发时,设置host=order.gulimall,使网关可以正确拦截【推荐】
      • 方案三:内网穿透的地址直接配成192.168.56.1:9000【缺点:没有负载均衡了】
bug1:修改gulimall.conf
server {listen       80;server_name gulimall *.gulimall 124.223.7.41;location /static/ {root /usr/share/nginx/html;}location / {proxy_set_header Host $host;proxy_pass http://gulimall;}error_page   500 502 503 504  /50x.html;location = /50x.html {root   /usr/share/nginx/html;}
}bug2:
方案一:- id: gulimall_order_route2uri: lb://gulimall-orderpredicates:- Host=124.223.7.41方案二:修改gulimall.conf
server {listen       80;server_name gulimall *.gulimall 124.223.7.41;location /static/ {root /usr/share/nginx/html;}location /payed/ {proxy_set_header Host order.gulimall;proxy_pass http://gulimall;}location / {proxy_set_header Host $host;proxy_pass http://gulimall;}error_page   500 502 503 504  /50x.html;location = /50x.html {root   /usr/share/nginx/html;}
}

内网穿透配置:

查看nginx异常命令cd  /mydata/nginx/logs
cat error.log|grep 'payed'

9.收单

1.订单超时,不允许支付解决:支付时设置超时时间:应该设置订单绝对超时时间,而不是30m,按照创建订单+30m来算截止时间time_expire2.订单解锁完成,异步通知才到解决:释放库存的时候,手动调用收单功能(参照官方demo的实现)

九、秒杀模块

1.后台接口

1.1.【新增】秒杀场次

CREATE TABLE `sms_seckill_session` (`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',`name` varchar(200) DEFAULT NULL COMMENT '场次名称',`start_time` datetime DEFAULT NULL COMMENT '每日开始时间',`end_time` datetime DEFAULT NULL COMMENT '每日结束时间',`status` tinyint(1) DEFAULT NULL COMMENT '启用状态',`create_time` datetime DEFAULT NULL COMMENT '创建时间',PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动场次';
@RestController
@RequestMapping("coupon/seckillsession")
public class SeckillSessionController {@Autowiredprivate SeckillSessionService seckillSessionService;/*** 保存*/@RequestMapping("/save")public R save(@RequestBody SeckillSessionEntity seckillSession){seckillSessionService.save(seckillSession);return R.ok();}
}

1.2.【查询】指定场次关联的商品列表

http://localhost:88/api/coupon/seckillskurelation/list?t=1641391939514&page=1&limit=10&key=&promotionSessionId=1
@Service("seckillSkuRelationService")
public class SeckillSkuRelationServiceImpl extends ServiceImpl<SeckillSkuRelationDao, SeckillSkuRelationEntity> implements SeckillSkuRelationService {@Overridepublic PageUtils queryPage(Map<String, Object> params) {QueryWrapper<SeckillSkuRelationEntity> wrapper = new QueryWrapper<>();String promotionSessionId = (String) params.get("promotionSessionId");if (StringUtils.isNotBlank(promotionSessionId)) {wrapper.eq("promotion_session_id", promotionSessionId);}IPage<SeckillSkuRelationEntity> page = this.page(new Query<SeckillSkuRelationEntity>().getPage(params),wrapper);return new PageUtils(page);}
}

1.3.【新增】秒杀场次关联商品

CREATE TABLE `sms_seckill_sku_relation` (`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',`promotion_id` bigint(20) DEFAULT NULL COMMENT '活动id',`promotion_session_id` bigint(20) DEFAULT NULL COMMENT '活动场次id',`sku_id` bigint(20) DEFAULT NULL COMMENT '商品id',`seckill_price` decimal(10,4) DEFAULT NULL COMMENT '秒杀价格',`seckill_count` int(11) DEFAULT NULL COMMENT '秒杀总量',`seckill_limit` int(11) DEFAULT NULL COMMENT '每人限购数量',`seckill_sort` int(11) DEFAULT NULL COMMENT '排序',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动商品关联';
/*** 秒杀活动商品关联** @author wanzenghui* @email lemon_wan@aliyun* @date 2021-09-02 22:43:18*/
@RestController
@RequestMapping("coupon/seckillskurelation")
public class SeckillSkuRelationController {@Autowiredprivate SeckillSkuRelationService seckillSkuRelationService;/*** 保存*/@RequestMapping("/save")public R save(@RequestBody SeckillSkuRelationEntity seckillSkuRelation){seckillSkuRelationService.save(seckillSkuRelation);return R.ok();}
}

2.新增秒杀模块

3.【定时上架】秒杀场次+商品

1.提前将要秒杀的商品上架到redis中(减少db压力)从redis获取秒杀商品实现:使用定时任务,扫描第二天要秒杀的商品上架到redis中2.秒杀商品的库存也上传到redis从redis扣除库存(信号量的方式)
/*** 定时任务* @Description:* @Created: with IntelliJ IDEA.* @author: wanzenghui* @createTime: 2020-07-09 19:22*/
@Slf4j
@Service
public class SeckillScheduled {@AutowiredSeckillService seckillService;@AutowiredRedissonClient redissonClient;/*** 秒杀商品定时上架,保证幂等性问题*  每天晚上3点,上架最近三天需要秒杀的商品*  当天00:00:00 - 23:59:59*  明天00:00:00 - 23:59:59*  后天00:00:00 - 23:59:59*/@Scheduled(cron = "*/10 * * * * ? ")//@Scheduled(cron = "0 0 3 * * ? ")public void uploadSeckillSkuLatest3Days() {// 重复上架无需处理log.info("上架秒杀的商品...");// 分布式锁(幂等性)RLock lock = redissonClient.getLock(SeckillConstant.UPLOAD_LOCK);try {lock.lock(10, TimeUnit.SECONDS);// 上架最近三天需要秒杀的商品seckillService.uploadSeckillSkuLatest3Days();} catch (Exception e) {e.printStackTrace();} finally {lock.unlock();}}
}
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {@AutowiredCouponFeignService couponFeignService;@AutowiredProductFeignService productFeignService;@AutowiredStringRedisTemplate redisTemplate;@AutowiredRabbitTemplate rabbitTemplate;@AutowiredRedissonClient redissonClient;/*** 上架最近三天需要秒杀的商品*/@Overridepublic void uploadSeckillSkuLatest3Days() {// 1.查询最近三天需要参加秒杀的场次+商品R lates3DaySession = couponFeignService.getLates3DaySession();if (lates3DaySession.getCode() == 0) {// 获取场次List<SeckillSessionWithSkusTO> sessions = lates3DaySession.getData("data", new TypeReference<List<SeckillSessionWithSkusTO>>() {});// 2.上架场次信息saveSessionInfos(sessions);// 3.上架商品信息saveSessionSkuInfo(sessions);}}/*** 上架场次*/private void saveSessionInfos(List<SeckillSessionWithSkusTO> sessions) {if (!CollectionUtils.isEmpty(sessions)) {sessions.stream().forEach(session -> {// 1.遍历场次long startTime = session.getStartTime().getTime();// 场次开始时间戳long endTime = session.getEndTime().getTime();// 场次结束时间戳String key = SeckillConstant.SESSION_CACHE_PREFIX + startTime + "_" + endTime;// 场次的key// 2.判断场次是否已上架(幂等性)Boolean hasKey = redisTemplate.hasKey(key);if (!hasKey) {// 未上架// 3.封装场次信息List<String> skuIds = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "_" + item.getSkuId().toString()).collect(Collectors.toList());// skuId集合// 4.上架redisTemplate.opsForList().leftPushAll(key, skuIds);}});}}/*** 上架商品信息*/private void saveSessionSkuInfo(List<SeckillSessionWithSkusTO> sessions) {if (!CollectionUtils.isEmpty(sessions)) {// 查询所有商品信息List<Long> skuIds = new ArrayList<>();sessions.stream().forEach(session -> {List<Long> ids = session.getRelationSkus().stream().map(SeckillSkuVO::getSkuId).collect(Collectors.toList());skuIds.addAll(ids);});R info = productFeignService.getSkuInfos(skuIds);if (info.getCode() == 0) {// 将查询结果封装成Map集合Map<Long, SkuInfoTO> skuMap = info.getData(new TypeReference<List<SkuInfoTO>>() {}).stream().collect(Collectors.toMap(SkuInfoTO::getSkuId, val -> val));// 绑定秒杀商品hashBoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);// 1.遍历场次sessions.stream().forEach(session -> {// 2.遍历商品session.getRelationSkus().stream().forEach(seckillSku -> {// 判断商品是否已上架(幂等性)String skuKey = seckillSku.getPromotionSessionId().toString() + "_" + seckillSku.getSkuId().toString();// 商品的key(需要添加场次ID前缀,同一款商品可能场次不同)if (!operations.hasKey(skuKey)) {// 未上架// 3.封装商品信息SeckillSkuRedisTO redisTo = new SeckillSkuRedisTO();// 存储到redis的对象SkuInfoTO sku = skuMap.get(seckillSku.getSkuId());BeanUtils.copyProperties(seckillSku, redisTo);// 商品秒杀信息redisTo.setSkuInfo(sku);// 商品详细信息redisTo.setStartTime(session.getStartTime().getTime());// 秒杀开始时间redisTo.setEndTime(session.getEndTime().getTime());// 秒杀结束时间// 商品随机码:用户参与秒杀时,请求需要带上随机码(防止恶意攻击)String token = UUID.randomUUID().toString().replace("-", "");// 商品随机码(随机码只会在秒杀开始时暴露)redisTo.setRandomCode(token);// 设置商品随机码// 4.上架商品(序列化成json格式存入Redis中)String jsonString = JSONObject.toJSONString(redisTo);operations.put(skuKey, jsonString);// 5.上架商品的分布式信号量,key:商品随机码 值:库存(限流)RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + token);// 信号量(扣减成功才进行后续操作,否则快速返回)semaphore.trySetPermits(seckillSku.getSeckillCount());}});});}}}
}

4.【查询】当前可参与的秒杀商品列表

@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {@AutowiredCouponFeignService couponFeignService;@AutowiredProductFeignService productFeignService;@AutowiredStringRedisTemplate redisTemplate;@AutowiredRabbitTemplate rabbitTemplate;@AutowiredRedissonClient redissonClient;/*** 获取到当前可以参加秒杀商品的信息*///@SentinelResource(value = "getCurrentSeckillSkusResource", blockHandler = "blockHandler")@Overridepublic List<SeckillSkuRedisTO> getCurrentSeckillSkus() {//try (Entry entry = SphU.entry("seckillSkus")) {// 1.查询当前时间所属的秒杀场次long currentTime = System.currentTimeMillis();// 当前时间// 查询所有秒杀场次的keySet<String> keys = redisTemplate.keys(SeckillConstant.SESSION_CACHE_PREFIX + "*");// keys seckill:sessions:*for (String key : keys) {//seckill:sessions:1594396764000_1594453242000String replace = key.replace(SeckillConstant.SESSION_CACHE_PREFIX, "");// 截取时间,去掉前缀String[] time = replace.split("_");long startTime = Long.parseLong(time[0]);// 开始时间long endTime = Long.parseLong(time[1]);// 截止时间// 判断是否处于该场次if (currentTime >= startTime && currentTime <= endTime) {// 2.查询当前场次信息(查询结果List< sessionId_skuId > )List<String> sessionIdSkuIds = redisTemplate.opsForList().range(key, -100, 100);// 获取list范围内100条数据// 获取商品信息BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);assert sessionIdSkuIds != null;// 根据List< sessionId_skuId >从Map中批量获取商品信息List<String> skus = skuOps.multiGet(sessionIdSkuIds);if (!CollectionUtils.isEmpty(skus)) {// 将商品信息反序列成对象List<SeckillSkuRedisTO> skuInfos = skus.stream().map(sku -> {SeckillSkuRedisTO skuInfo = JSON.parseObject(sku.toString(), SeckillSkuRedisTO.class);// redisTo.setRandomCode(null);当前秒杀开始需要随机码return skuInfo;}).collect(Collectors.toList());return skuInfos;}// 3.匹配场次成功,退出循环break;}}//} catch (BlockException e) {//    log.error("资源被限流{}", e.getMessage());//}return null;}
}

5.【查询】商品详情页展示秒杀信息

// product模块获取商品详情的时候,同时查询该商品的秒杀信息@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {@AutowiredCouponFeignService couponFeignService;@AutowiredProductFeignService productFeignService;@AutowiredStringRedisTemplate redisTemplate;@AutowiredRabbitTemplate rabbitTemplate;@AutowiredRedissonClient redissonClient;/*** 根据skuId查询商品当前时间秒杀信息** @param skuId*/@Overridepublic SeckillSkuRedisTO getSkuSeckilInfo(Long skuId) {// 1.匹配查询当前商品的秒杀信息BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);// 获取所有商品的key:sessionId_Set<String> keys = skuOps.keys();if (!CollectionUtils.isEmpty(keys)) {String lastIndex = "_" + skuId;for (String key : keys) {if (key.lastIndexOf(lastIndex) > -1) {// 商品id匹配成功String jsonString = skuOps.get(key);// 进行序列化SeckillSkuRedisTO skuInfo = JSON.parseObject(jsonString, SeckillSkuRedisTO.class);Long currentTime = System.currentTimeMillis();Long endTime = skuInfo.getEndTime();if (currentTime <= endTime) {// 当前时间小于截止时间Long startTime = skuInfo.getStartTime();if (currentTime >= startTime) {// 返回当前正处于秒杀的商品信息return skuInfo;}// 返回预告信息,不返回随机码skuInfo.setRandomCode(null);// 随机码return skuInfo;}}}}return null;}
}

6.秒杀抢购

6.1.高并发需关注的问题

  • 1.单一职责
  • 2.秒杀链接加密
    • 随机码,秒杀开始才暴露
  • 3.库存预热+快速扣减(redis存储库存信号量,最终正常进入购物车的流量最多是库存数)
    • 按照库存信号量原子扣减
  • 4.动静分离
    • nginx/CDN
  • 5.恶意请求拦截
    • 网关层按照访问次数拦截脚本请求【异常请求】
  • 6.流量错峰
    • 【最重要是体现在秒杀开始的那一刻的错峰】判断登录状态、输入验证码、加入购物车、提交订单
  • 7.限流&熔断&降级
    • 前端限流:间隔1秒允许点击
    • 后端限流:
      • 限制次数:同一个用户10次放行2次
      • 限制总量:秒杀服务峰值处理能力10万,网关层放行不得超过10万,超过的等待两秒放行
    • 熔断:A->B->C,链路中B总是失败,则下次调用时直接返回错误不调用B
    • 降级:流量太大,秒杀模块将流量引导到降级页面,服务繁忙页【正常请求】
  • 8.队列削峰(杀手锏)
    • 扣减库存信号量成功的秒杀信息存入队列,订单系统监听队列创建订单(按照自己的处理能力消费)

6.2.【秒杀】队列削峰

两种方案:方案一:加入购物车(仍然走购物车流程,但价格按照秒杀价格计算),创建订单、锁定库存优点:只需要做好适配,无大改动缺点:将秒杀的流量带给了其他模块方案二:【采用方案二,队列削峰】直接发送MQ消息,订单根据消息创建订单(不需要锁定库存,库存预热了【信号量】),订单关闭增加信号量优点:没有将秒杀的压力分担给其他模块,只有校验合法性没有远程调用、db操作缺点:订单等模块需要提供监听消费信息创建订单,如果订单崩了,会导致支付失败假设一个请求50ms,一个线程1s能处理20个请求
Tomcat开启500个线程,1s能处理10000个请求

方案1:

方案2:

/*** @Description:* @Created: with IntelliJ IDEA.* @author: wanzenghui* @createTime: 2020-07-09 19:29**/@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {@AutowiredStringRedisTemplate redisTemplate;@AutowiredRabbitTemplate rabbitTemplate;@AutowiredRedissonClient redissonClient;/*** 秒杀商品* 1.校验登录状态* 2.校验秒杀时间* 3.校验随机码、场次、商品对应关系* 4.校验信号量扣减,校验购物数量是否限购* 5.校验是否重复秒杀(幂等性)【秒杀成功SETNX占位  userId_sessionId_skuId】* 6.扣减信号量* 7.发送消息,创建订单号和订单信息* 8.订单模块消费消息,生成订单* @param killId    sessionId_skuid* @param key   随机码* @param num   商品件数*/@Overridepublic String kill(String killId, String key, Integer num) throws InterruptedException {// TODO 1.拦截器校验登录状态long start = System.currentTimeMillis();// 获取当前用户信息MemberResponseVO user = LoginUserInterceptor.loginUser.get();// 获取当前秒杀商品的详细信息BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);String jsonString = skuOps.get(killId);// 根据sessionId_skuid获取秒杀商品信息if (StringUtils.isEmpty(jsonString)) {// 这一步已经默认校验了场次+商品,如果为空表示校验失败return null;}// json反序列化商品信息SeckillSkuRedisTO skuInfo = JSON.parseObject(jsonString, SeckillSkuRedisTO.class);Long startTime = skuInfo.getStartTime();Long endTime = skuInfo.getEndTime();long currentTime = System.currentTimeMillis();// TODO 2.校验秒杀时间if (currentTime >= startTime && currentTime <= endTime) {// TODO 3.校验随机码String randomCode = skuInfo.getRandomCode();// 随机码if (randomCode.equals(key)) {// 获取每人限购数量Integer seckillLimit = skuInfo.getSeckillLimit();// 获取信号量String seckillCount = redisTemplate.opsForValue().get(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);Integer count = Integer.valueOf(seckillCount);// TODO 4.校验信号量(库存是否充足)、校验购物数量是否限购if (num > 0 && num <= seckillLimit && count > num) {// TODO 5.校验是否重复秒杀(幂等性)【秒杀成功后占位,userId-sessionId-skuId】// SETNX 原子性处理String userKey = SeckillConstant.SECKILL_USER_PREFIX + user.getId() + "_" + killId;// 自动过期时间(活动结束时间 - 当前时间)Long ttl = endTime - currentTime;Boolean isRepeat = redisTemplate.opsForValue().setIfAbsent(userKey, num.toString(), ttl, TimeUnit.MILLISECONDS);if (isRepeat) {// 占位成功// TODO 6.扣减信号量RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);boolean isAcquire = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);if (isAcquire) {// 信号量扣减成功,秒杀成功,快速下单// TODO 7.发送消息,创建订单号和订单信息// 秒杀成功 快速下单 发送消息到 MQ 整个操作时间在 10ms 左右String orderSn = IdWorker.getTimeId();// 订单号SeckillOrderTO order = new SeckillOrderTO();// 订单order.setOrderSn(orderSn);// 订单号order.setMemberId(user.getId());// 用户IDorder.setNum(num);// 商品上来给你order.setPromotionSessionId(skuInfo.getPromotionSessionId());// 场次idorder.setSkuId(skuInfo.getSkuId());// 商品idorder.setSeckillPrice(skuInfo.getSeckillPrice());// 秒杀价格// TODO 需要保证可靠消息,发送者确认+消费者确认(本地事务的形式)rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", order);long end = System.currentTimeMillis();log.info("秒杀成功,耗时..." + (end - start));return orderSn;}}}}}long end = System.currentTimeMillis();log.info("秒杀失败,耗时..." + (end - start));return null;}
}

6.3.TODO释放信号量

1.接收创建秒杀订单的队列也应该做成延时队列,超时未支付,消息进入死信队列释放订单
2.监听释放订单的消费者,释放订单后,发送一条释放信号量的信息到释放信号量的死信队列
3.监听释放信号量的死信队列,逻辑跟释放库存一样(释放订单产生一条释放库存的消息,延时队列产生一条释放库存的消息)

6.4.TODO释放库存

场次超时后,将信号量归还到库存

十、熔断、限流、链路追踪

1.整合步骤

1.1.定义资源

多种定义资源的方法
1.主流框架适配,例如适配feign后所有feign请求都是资源【限流、降级后,触发熔断fallback】spring所有controller请求都是资源【限流、降级后,触发UrlBlockSentinelHandler处理】适配gateway后,所有routes都是资源【限流、降级后,触发UrlBlockSentinelHandler处理】2.自定义资源,使用try{}catch{}【限流、降级后,在catch中处理】
3.注解定义资源,使用@SentinelResource(blockHandler = "blockHandlerForGetUser")【流、降级后,在blockHandlerForGetUser中处理】

1.2.定义规则

1.3.检验规则是否生效

2.整合sentinel

参考 12.熔断+降级+限流+链路追踪(sentinel).md

总结

后台请求和前台请求路由

后台请求都添加了/api/member/xxx/api/coupon/xxx/api/renren-fast/xx前台请求都没有/api结论:1.gateway拦截后台请求的时候,要将/api/member/xx  请求拦截,然后将请求替换成/member/xx2.gateway拦截前台请求按照host拦截,不按照url拦截,例如order.gulimallmember.gulimall

单元测试

版本2.1.8:<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>@RunWith(SpringRunner.class)// 使用spring驱动
@SpringBootTest
public class GulimallSearchApplicationTests {@Autowiredprivate RestHighLevelClient client;@Testpublic void contextLoads() {}@Testvoid testEs() {System.out.println(client);}
}版本2.3.2:<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope><exclusions><exclusion><groupId>org.junit.vintage</groupId><artifactId>junit-vintage-engine</artifactId></exclusion></exclusions></dependency>@SpringBootTest
class GulimallSearchApplicationTests {@Autowiredprivate RestHighLevelClient client;@Testvoid testEs() {System.out.println(client);}
}

组件间调用R类型问题

1.方法1:(无法实现,以为R继承自HashMap,data作为私有属性无法使用)设计返回类型R的时候加上泛型(feign调用时,springboot底层会根据泛型封装data类型)
public class R<T> extends HashMap<String, Object> {private T data;public T getDate() {return data;}public void setData(T data) {this.data = data;}
}2.方法2:controller返回数据类型不使用R,直接使用List<SkuHasStockTO>3.方法3:
public class R<T> extends HashMap<String, Object> {public R put(String key, Object value) {super.put(key, value);return this;}/*** 封装数据*/public R setData(Object data) {return put("data", data);}/*** 解析数据* 1.@ResponseBody返回类型被封装成了Json格式* 2.feign接收参数时也会封装成json格式,data对象也被解析成json格式的数据([集合对象]或{map对象})* 3.将data转成json字符串格式,然后再解析成对象*/public <T> T getData(TypeReference<T> type) {Object data = get("data");String jsonString = JSONObject.toJSONStringWithDateFormat(data, DateConstant.DATE_FORMAT);return JSONObject.parseObject(jsonString, type);}
}

feign调用源码

/**
* 1、构造请求数据,将对象转为json
*      RequestTemplate template = buildTemplateFromArgs.create(argv);
* 2、发送请求进行执行:【执行成功会解码响应数据】
*      excuteAndDecode(template)
* 3、执行请求会有重试机制
*      while(true){
*          try{
*              excuteAndDecode(template)
*          }catch() {
*              try{
*                  // 默认重试5次【具体是否重试查看重试器的实现】
*                  retryer.continueOrPropagate(e);
*               }catch() {
*                  throw ex;
*               }
*              continue;
*          }
*      }
*
*
*/

查询结果使用包装类型

mapper查询返回使用Long代替long原因:查询结果为null时,无法为long封装null值

RedirectAttributes

作用:重定向数据域1.attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次2.attributes.addAttribute("skuId", skuId);// 会在url后面拼接参数

添加新模块步骤

1.网关转发配置
2.spring-session依赖spring-session配置spring-session注解(@EnableRedisHttpSession)
3.登录拦截 LoginUserInterceptor、WebMvcConfigurer
4.域名映射
5.添加feign拦截器:GuliFeignConfig构造请求头,避免cookie丢失登录拦截
6.添加mybatis拦截器:MybatisConfig分页查询时封装总记录数、总页码数

更多推荐

谷粒商城高级篇下

本文发布于:2024-02-06 11:01:09,感谢您对本站的认可!
本文链接:https://www.elefans.com/category/jswz/34/1748508.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
本文标签:谷粒   高级   商城

发布评论

评论列表 (有 0 条评论)
草根站长

>www.elefans.com

编程频道|电子爱好者 - 技术资讯及电子产品介绍!