【谷粒商城】分布式事务与下单

  • 笔记-基础篇-1(P1-P28):https://blog.csdn.net/hancoder/article/details/106922139

  • 笔记-基础篇-2(P28-P100):https://blog.csdn.net/hancoder/article/details/107612619

  • 笔记-高级篇(P340):https://blog.csdn.net/hancoder/article/details/107612746

  • 笔记-vue:https://blog.csdn.net/hancoder/article/details/107007605

  • 笔记-elastic search、上架、检索:https://blog.csdn.net/hancoder/article/details/113922398

  • 笔记-认证服务:https://blog.csdn.net/hancoder/article/details/114242184

  • 笔记-分布式锁与缓存:https://blog.csdn.net/hancoder/article/details/114004280

  • 笔记-集群篇:https://blog.csdn.net/hancoder/article/details/107612802

  • springcloud笔记:https://blog.csdn.net/hancoder/article/details/109063671

  • 笔记版本说明:2020年提供过笔记文档,但只有P1-P50的内容,2021年整理了P340的内容。请点击标题下面分栏查看系列笔记

  • 声明:

    • 可以白嫖,但请勿转载发布,笔记手打不易
    • 本系列笔记不断迭代优化,csdn:hancoder上是最新版内容,10W字都是在csdn免费开放观看的。
    • 离线md笔记文件获取方式见文末。2021-3版本的md笔记打完压缩包共500k(云图床),包括本项目笔记,还有cloud、docker、mybatis-plus、rabbitMQ等个人相关笔记
  • sql:https://github.com/FermHan/gulimall/sql文件

  • 本项目其他笔记见专栏:https://blog.csdn.net/hancoder/category_10822407.html

本篇2.5W字,请直接ctrl+F搜索内容

一、gulimall-cart

构建gulimall-cart,复制静态资源到nginx,修改网关

购物车分为离线购物车和登录购物车

离线购物车重启浏览器了也还有

二、购物车

1、购物车需求

特点:读多写少,放入数据库并不合适

登录状态:登录购物车

  • 放入数据库
  • mongodb
  • 放入redis(采用)
    • 登录以后,将离线购物车里合并后清空离线购物车

未登录状态:离线购物车

  • 放入localstorage浏览器的技术
  • cookie
  • WebSQL
  • 放入redis(采用)
    • 浏览器重启后还在

2、购物车VO

(1) 数据结构分析

购物车

{
    skuid:123123,
    check:true, # 每一项是否被选中
    title:"apple ...",
    defaultImage:"",
    price:4999,
    count:1,
    totalPrice:4999, # 商品的总价=单价*数量
    skuSaleVO:{...}
}

购物车不只一条数据

[
    {sku1},{sku2},{}
]

redis有5种不同数据结构,这里选择哪一种比较合适呢?Map>

不好的方式:不同用户应该有独立的购物车,因此购物车应该以用户作为key来存储,value是用户的所有购车信息。这样看来基本的k-v结构就可以了。

但是,我们对购车中的商品进行增、删、改操作,基本都需要根据商品id讲行判断,为了方便后期处理,我们的购车也应该是k-v结构,key是商品id,value才是这个商品的购车信息。

一个购物车是由各个购物项组成的,但是我们用List进行存储并不合适,因为使用List查找某个购物项时需要挨个遍历每个购物项,会造成大量时间损耗,为保证查找速度,我们使用hash进行存储

每个人都有一个hash表,key为skuId,value为数据

【谷粒商城】分布式事务与下单_第1张图片

(2) 购物项vo

public class CartItem {

	private Long skuId;

	/*** 是否被选中*/
	private Boolean check = true;

	private String title;
	private String image;

	private List<String> skuAttr;

	/*** 价格*/
	private BigDecimal price;
	/*** 数量*/
	private Integer count;

(3) 购物车vo

【谷粒商城】分布式事务与下单_第2张图片

public class Cart {

	private List<CartItem> items;

	/*** 商品的数量*/
	private Integer countNum;
	/*** 商品的类型数量*/
	private Integer countType;

	/*** 整个购物车的总价*/
	private BigDecimal totalAmount;

	/*** 减免的价格*/
	private BigDecimal reduce = new BigDecimal("0.00");

	/*** 计算商品的总量*/
	public Integer getCountNum() {
		int count = 0;
		if(this.items != null && this.items.size() > 0){
			for (CartItem item : this.items) {
				count += item.getCount();
			}
		}
		return count;
	}

	public Integer getCountType() {
		int count = 0;
		if(this.items != null && this.items.size() > 0){
			for (CartItem item : this.items) {
				count += 1;
			}
		}
		return count;
	}

	public BigDecimal getTotalAmount() {
		BigDecimal amount = new BigDecimal("0");
		if(this.items != null && this.items.size() > 0){
			for (CartItem item : this.items) {
				if(item.getCheck()){
					BigDecimal totalPrice = item.getTotalPrice();
					amount = amount.add(totalPrice);
				}
			}
		}
		return amount.subtract(this.getReduce());
	}

3、 ThreadLocal用户身份鉴别

(1) threadlocal说明

threadlocal的效果是其中存储的内容只有当前线程能访问的

如果想了解更多threadlocal知识可以查看:https://blog.csdn.net/hancoder/article/details/107853513

threadlocal的原理是每个线程都有一个map,key为threadlocal对象,value为对象所对应的值

参考京东,在点击购物车时,会为临时用户生成一个nameuser-keycookie临时标识,过期时间为一个月,如果手动清除user-key,那么临时购物车的购物项也被清除,所以user-key是用来标识和存储临时购物车数据的

(2) 使用ThreadLocal进行用户身份鉴别信息传递

但是注意的是tomcat中线程可以复用,所以线程和会话不是一对一的关系。但是没有关系,会在拦截器中先判断会话有没有用户信息(cookie),

  • 首先明确每次拦截器都会重新设置threadlocal
  • 没有的话创建一个临时用户,回去的时候告诉用户的临时cookie。threadlocal中只封装临时用户信息
  • 有的话把临时用户和登录用户封装到一起,设置到threadlocal中
拦截器拦截会话

购物车拦截器的配置

@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
    //拦截所有请求
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
    }
}

购物车拦截器

  • 可以看到有句session.getAttribute(AuthServerConstant.LOGIN_USER);
  • 看cookie中有没有临时数据,就是cookie带过来的
  • 将用户信息放到threadlocal中让当前用户使用threadLocal.set(userInfoTo);
public class CartInterceptor implements HandlerInterceptor {

	// 静态,
	public static ThreadLocal<UserInfoTo> threadLocal = new ThreadLocal<>();

	@Override
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

		// 准备好要设置到threadlocal里的user对象
		UserInfoTo userInfoTo = new UserInfoTo();
		HttpSession session = request.getSession();
		// 获取loginUser对应的用户value,没有也不去登录了。登录逻辑放到别的代码里,需要登录时再重定向
		MemberRespVo user = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
		if (user != null){ // 用户登陆了,设置userId
			userInfoTo.setUsername(user.getUsername());
			userInfoTo.setUserId(user.getId());
		}
		
		// 不登录也没关系,可以访问临时用户购物车
		// 去查看请求带过来的cookies里的临时购物车cookie
		Cookie[] cookies = request.getCookies();
		if(cookies != null && cookies.length > 0){
			for (Cookie cookie : cookies) {
				String name = cookie.getName();
				if(name.equals(CartConstant.TEMP_USER_COOKIE_NAME)){
					userInfoTo.setUserKey(cookie.getValue());
					userInfoTo.setTempUser(true);
				}
			}
		}
		// 如果没有临时用户 则分配一个临时用户 // 分配的临时用户在postHandle的时候放到cookie里即可
		if (StringUtils.isEmpty(userInfoTo.getUserKey())){
			String uuid = UUID.randomUUID().toString().replace("-","");
			userInfoTo.setUserKey("GULI-" + uuid);//临时用户
		}
		threadLocal.set(userInfoTo);
		return true;
		// 还有一个登录后应该删除临时购物车的逻辑没有实现
	}

	/**
	 * 执行完毕之后分配临时用户让浏览器保存
	 */
	@Override
	public void postHandle(HttpServletRequest request, 
						   HttpServletResponse response, Object handler,
						   ModelAndView modelAndView) throws Exception {

		UserInfoTo userInfoTo = threadLocal.get();
		// 如果是临时用户,返回临时购物车的cookie
		if(!userInfoTo.isTempUser()){
			Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
			// 设置这个cookie作用域 过期时间
			cookie.setDomain("gulimall.com");
			cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIME_OUT);
			response.addCookie(cookie);
		}
	}
}

3. 添加商品到购物车

需要的服务:gateway、product、ware、cart、seckill、search、auth

/*** 添加商品到购物车
	 *  RedirectAttributes.addFlashAttribute():将数据放在session中,可以在页面中取出,但是只能取一次
	 *  RedirectAttributes.addAttribute():将数据拼接在url后面,?skuId=xxx
	 * */
@GetMapping("/addToCart")
public String addToCart(@RequestParam("skuId") Long skuId,
                        @RequestParam("num") Integer num,
                        RedirectAttributes redirectAttributes)  // 重定向数据, 会自动将数据添加到url后面
    throws ExecutionException, InterruptedException {

    // 添加数量到用户购物车
    cartService.addToCart(skuId, num);
    // 返回skuId告诉哪个添加成功了
    redirectAttributes.addAttribute("skuId", skuId);
    // 重定向到成功页面
    return "redirect:http://cart.gulimall.com/addToCartSuccess.html";
}

// 添加sku到购物车响应页面
@GetMapping("/addToCartSuccess.html")
public String addToCartSuccessPage(@RequestParam(value = "skuId",required = false) Object skuId, Model model){
    CartItem cartItem = null;
    // 然后在查一遍 购物车
    if(skuId == null){
        model.addAttribute("item", null);
    }else{
        try {
            cartItem = cartService.getCartItem(Long.parseLong((String)skuId));
        } catch (NumberFormatException e) {
            log.warn("恶意操作! 页面传来skuId格式错误");
        }
        model.addAttribute("item", cartItem);
    }
    return "success";
}
获取用户购物车数据

先获取redis里该用户购物车的那个map,每个用户的购物车都是个map,map名为ATGUIGU:cart:用户id

登录用户优先

private BoundHashOperations<String, Object, Object> getCartOps() {
    // 1. 这里我们需要知道操作的是离线购物车还是在线购物车
    UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
  
    String cartKey = CART_PREFIX; //  "ATGUIGU:cart:";
    if(userInfoTo.getUserId() != null){
        log.debug("\n用户 [" + userInfoTo.getUsername() + "] 正在操作购物车");
        // 已登录的用户购物车的标识
        cartKey += userInfoTo.getUserId();
    }else{
        log.debug("\n临时用户 [" + userInfoTo.getUserKey() + "] 正在操作购物车");
        // 未登录的用户购物车的标识
        cartKey += userInfoTo.getUserKey();
    }
    // 绑定这个 key 以后所有对redis 的操作都是针对这个key
    return stringRedisTemplate.boundHashOps(cartKey);
}
购物车service
  • 若购物车中已经存在该商品,只需增添数量
  • 否则需要查询商品购物项所需信息,并添加新商品至购物车
    • map的key是skuId,value是数量
@Override // CartServiceImpl
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
    // 获取当前用户的map
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    // 查看该用户购物车里是否有指定的skuId
    String res = (String) cartOps.get(skuId.toString());

    // 查看用户购物车里是否已经有了该sku项
    if(StringUtils.isEmpty(res)){
        CartItem cartItem = new CartItem();
        // 异步编排
        CompletableFuture<Void> getSkuInfo = CompletableFuture.runAsync(() -> {
            // 1. 远程查询当前要添加的商品的信息
            R skuInfo = productFeignService.SkuInfo(skuId);
            SkuInfoVo sku = skuInfo.getData("skuInfo", new TypeReference<SkuInfoVo>() {});
            // 2. 填充购物项
            cartItem.setCount(num);
            cartItem.setCheck(true);
            cartItem.setImage(sku.getSkuDefaultImg());
            cartItem.setPrice(sku.getPrice());
            cartItem.setTitle(sku.getSkuTitle());
            cartItem.setSkuId(skuId);
        }, executor);

        // 3. 远程查询sku销售属性,销售属性是个list
        CompletableFuture<Void> getSkuSaleAttrValues = CompletableFuture.runAsync(() -> {
            List<String> values = productFeignService.getSkuSaleAttrValues(skuId);
            cartItem.setSkuAttr(values);
        }, executor);
        // 等待执行完成
        CompletableFuture.allOf(getSkuInfo, getSkuSaleAttrValues).get();

        // sku放到用户购物车redis中
        cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
        return cartItem;
    }else{//购物车里已经有该sku了,数量+1即可
        CartItem cartItem = JSON.parseObject(res, CartItem.class);
        // 不太可能并发,无需加锁
        cartItem.setCount(cartItem.getCount() + num);
        cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
        return cartItem;
    }
}

4. 展示购物车

  • 若用户未登录,则直接使用user-key获取购物车数据
  • 否则使用userId获取购物车数据,并将user-key对应临时购物车数据与用户购物车数据合并,并删除临时购物车
@RequestMapping("/cart.html")
public String getCartList(Model model) {
    CartVo cartVo=cartService.getCart();
    model.addAttribute("cart", cartVo);
    return "cartList";
}


@Override
public Cart getCart() throws ExecutionException, InterruptedException {
    UserInfoTo userInfoTo = CartInterceptor.threadLocal.get();
    Cart cart = new Cart();
    // 临时购物车的key
    String tempCartKey = CART_PREFIX + userInfoTo.getUserKey();
    // 是否登录
    if(userInfoTo.getUserId() != null){
        // 已登录 对用户的购物车进行操作
        String cartKey = CART_PREFIX + userInfoTo.getUserId();
        // 1 如果临时购物车的数据没有进行合并
        List<CartItem> tempItem = getCartItems(tempCartKey);
        if(tempItem != null){
            // 2 临时购物车有数据 则进行合并
            log.info("\n[" + userInfoTo.getUsername() + "] 的购物车已合并");
            for (CartItem cartItem : tempItem) {
                addToCart(cartItem.getSkuId(), cartItem.getCount());
            }
            // 3 清空临时购物车,防止重复添加
            clearCart(tempCartKey);
            // 设置为非临时用户
            userInfoTo.setTempUser(false);
        }
        // 4 获取登录后的购物车数据 [包含合并过来的临时购物车数据]
        List<CartItem> cartItems = getCartItems(cartKey);
        cart.setItems(cartItems);
    }else {
        // 没登录 获取临时购物车的所有购物项
        cart.setItems(getCartItems(tempCartKey));
    }
    return cart;
}

/**
	 * 获取购物车所有项
	 */
private List<CartItem> getCartItems(String cartKey){
    BoundHashOperations<String, Object, Object> hashOps = stringRedisTemplate.boundHashOps(cartKey);
    // key不重要,拿到值即可
    List<Object> values = hashOps.values();
    if(values != null && values.size() > 0){
        return values.stream().map(obj -> JSON.parseObject(JSON.toJSONString(obj) , CartItem.class)).collect(Collectors.toList());
    }
    return null;
}

5. 选中购物车项

更改购物项选中状态

@RequestMapping("/checkCart")
public String checkCart(@RequestParam("isChecked") Integer isChecked,@RequestParam("skuId")Long skuId) {
    cartService.checkItem(skuId, isChecked);
    return "redirect:http://cart.gulimall.com/cart.html";
}

//修改skuId对应购物车项的选中状态
@Override
public void checkItem(Long skuId, Integer check) {
    // 获取要选中的购物项 // 信息还是在原来的缓存中,更新即可
    CartItem cartItem = getCartItem(skuId);
    cartItem.setCheck(check==1?true:false);
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
}

@Override
public CartItem getCartItem(Long skuId) {
    BoundHashOperations<String, Object, Object> cartOps = getCartOps();
    String o = (String) cartOps.get(skuId.toString());
    return JSON.parseObject(o, CartItem.class);
}

6. 修改购物项数量

@RequestMapping("/countItem")
public String changeItemCount(@RequestParam("skuId") Long skuId, @RequestParam("num") Integer num) {
    cartService.changeItemCount(skuId, num);
    return "redirect:http://cart.gulimall.com/cart.html";
}

@Override
public void changeItemCount(Long skuId, Integer num) {
    BoundHashOperations<String, Object, Object> ops = getCartItemOps();
    String cartJson = (String) ops.get(skuId.toString());
    CartItemVo cartItemVo = JSON.parseObject(cartJson, CartItemVo.class);
    cartItemVo.setCount(num);
    ops.put(skuId.toString(),JSON.toJSONString(cartItemVo));
}

7. 删除购物车项

@RequestMapping("/deleteItem")
public String deleteItem(@RequestParam("skuId") Long skuId) {
    cartService.deleteItem(skuId);
    return "redirect:http://cart.gulimall.com/cart.html";
}

@Override
public void deleteItem(Long skuId) {
    BoundHashOperations<String, Object, Object> ops = getCartItemOps();
    ops.delete(skuId.toString());
}

三、消息队列

https://blog.csdn.net/hancoder/article/details/114297652

四、Session共享

这部分的内容请去认证服务笔记里看https://blog.csdn.net/hancoder/article/details/114242184

思想就是用redis存储session,并且cookie的作用域跨大到*.gulimall.com

如果域名不同可以用单点登录解决,思想为创建登录服务器,去登录服务器获取用户的redis-key,然后在自己的服务里请求redis对应的用户后保存到自己的session里

五、订单模型

资料源码中等待付款是订单详情页;订单页是用户订单列表;结算页是订单确认页;收银页是支付页cd

在nginx中新建目录order

  • 放到IDEA-order项目中
  • order/detail中放入【等待付款】的静态资源。index.html重命名为detail.html
  • order/list中放入【订单页】的静态资源。index.html重命名为list.html
  • order/confirm中放入【结算页】的静态资源。index.html重命名为confirm.html
  • order/pay中放入【收银页】的静态资源。index.html重命名为pay.html
  • 修改HOSTS,192.168.56.10 order.gulimall.com
  • nginx中已经配置过转发
  • 在gateway中新增order路由
  • 修改html中的路径/static前缀。比如/static/order/confirm
  • 注意一下有的同学在@GetMapping("/memverOrder.html")里的参数没有匹配好,第一个参数可以直接定义为String
  • 注意一下看看数据库里用户和订单表的对应情况,你登录该用户才能看到他的订单

订单概念

订单中心:

电商系统涉及到3流,分别是信息流,资金流,物流,而订单系统作为中枢将三者有机的集合起来。

订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。

【谷粒商城】分布式事务与下单_第3张图片

订单状态

    1. 待付款

用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支付,需要汪意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超时后将自动取消订单,订单变更关闭状态。

    1. 已付款/待发货

用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调拨,配货,分拣,出库等操作。

    1. 待收货/已发货

仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物品物流状态

    1. 已完成
      用户确认收货后,订单交易完成。后续支付亻则进行结算,如果订单存在间题进入售后状态
    1. 已取消

付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。

    1. 售后中

用户在付款后申请退款,或商家发货后用户申请退换货。

售后也同样存在各种状态,

  • 当发起售后申请后生成售后订单,
  • 售后订单状态为待审核,等待商家审核,
  • 商家审核过后订单状态变更为待退货,等待用户将商品机会,
  • 商家收到货后订单

订单流程

订单流程是指从订单产生到完成整个流转的过程,从而行程了一套标准流程规则。而不同的产品类型或业务类型在系统中的流程会千差万别,比如上面提到的线上实物订单和虚拟订单的流程,线上实物订单与O2O订单等,所以需要根据不同的类型进行构建订单流程。

不管类型如何订单都包括正向流程和逆向流程,对应的场景就是购买商品和退换货流程,正向流程就是一个正常的网购步骤:

订单生成一>支付订单一>卖家发货一>确认收货一>交易成功。

而每个步骤的背后,订单是如何在多系统之间交互流转的,可概括如下图

【谷粒商城】分布式事务与下单_第4张图片

六、服务通信数据共享问题

订单登录拦截

因为订单系统必然涉及到用户信息,因此进入订单系统的请求必须是已经登录的,所以我们需要通过拦截器对未登录订单请求进行拦截

  • 先注入拦截器HandlerInterceptor组件
  • 在config中实现WebMvcConfigurer接口.addInterceptor()方法
  • 拦截器和认证器的关系我在前面认证模块讲过,可以翻看,这里不赘述了
@Component
public class LoginUserInterceptor implements HandlerInterceptor {

	public static ThreadLocal<MemberRespVo> threadLocal = new ThreadLocal<>();

	@Override
	public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {

		String uri = request.getRequestURI();
		// 这个请求直接放行
		boolean match = new AntPathMatcher().match("/order/order/status/**", uri);
		if(match){
			return true;
		}
		// 获取session
		HttpSession session = request.getSession();
		// 获取登录用户
		MemberRespVo memberRespVo = (MemberRespVo) session.getAttribute(AuthServerConstant.LOGIN_USER);
		if(memberRespVo != null){
			threadLocal.set(memberRespVo);
			return true;
		}else{
			// 没登陆就去登录
			session.setAttribute("msg", AuthServerConstant.NOT_LOGIN);
			response.sendRedirect("http://auth.gulimall.com/login.html");
			return false;
		}
	}
}
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new LoginInterceptor()).addPathPatterns("/**");
    }
}

加上ThreadLocal共享数据,是为了登录后把用户放到本地内存,而不是每次都去远程session里查

在auth-server中登录成功后会把会话设置到session中

MemberRespVo data = login.getData("data",new TypeReference<MemberRespVo>);
session.setAttribute(AuthServerConstant.LOGIN_USER,data);

异步线程的request数据与远程调用cookie的携带

1)新线程没有用户数据的问题RequestContextHolder

RequestContextHolder可以解决的问题:

  • 正常来说在service层是没有request和response的,然而直接从controlller传过来的话解决方法太粗暴。解决方法是SpringMVC提供的RequestContextHolder
  • 用线程池执行任务时非主线程是没有请求数据的,可以通过该方法设置线程中的request数据,原理还是用的threadlocal

RequestContextHolder推荐阅读:https://blog.csdn.net/asdfsadfasdfsa/article/details/79158459

在spring mvc中,为了随时都能取到当前请求的request对象,可以通过RequestContextHolder的静态方法getRequestAttributes()获取Request相关的变量,如request, response等

RequestContextHolder顾名思义,持有上下文的Request容器.使用是很简单的,具体使用如下:

//两个方法在没有使用JSF的项目中是没有区别的
RequestAttributes requestAttributes = RequestContextHolder.currentRequestAttributes();
//                                    RequestContextHolder.getRequestAttributes();
//从session里面获取对应的值
String str = (String) requestAttributes.getAttribute("name",RequestAttributes.SCOPE_SESSION);

HttpServletRequest  request  = ((ServletRequestAttributes)requestAttributes).getRequest();
HttpServletResponse response = ((ServletRequestAttributes)requestAttributes).getResponse();

什么时候把request和response设置进去的:mvc的service()方法里有processRequest(request, response);,每个请求来了都会执行,

  1. 获取上一个请求的参数
  2. 重新建立新的参数
  3. 设置到XXContextHolder
  4. 父类的service()处理请求
  5. 恢复request
  6. 发布事件

2)远程调用丢失用户信息

feign远程调用的请求头中没有含有JSESSIONIDcookie,所以也就不能得到服务端的session数据,也就没有用户数据,cart认为没登录,获取不了用户信息

【谷粒商城】分布式事务与下单_第5张图片

我们追踪远程调用的源码,可以在SynchronousMethodHandler.targetRequest()方法中看到他会遍历容器中的RequestInterceptor进行封装

Request targetRequest(RequestTemplate template) {
  for (RequestInterceptor interceptor : requestInterceptors) {
    interceptor.apply(template);
  }
  return target.apply(template);
}

根据追踪源码,我们可以知道我们可以通过给容器中注入RequestInterceptor,从而给远程调用转发时带上cookie

但是在feign的调用过程中,会使用容器中的RequestInterceptorRequestTemplate进行处理,因此我们可以通过向容器中导入定制的RequestInterceptor为请求加上cookie

public class GuliFeignConfig {
    @Bean
    public RequestInterceptor requestInterceptor() {
        return new RequestInterceptor() {
            @Override
            public void apply(RequestTemplate template) {
                //1. 使用RequestContextHolder拿到老请求的请求数据
                ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
                if (requestAttributes != null) {
                    HttpServletRequest request = requestAttributes.getRequest();
                    if (request != null) {
                        //2. 将老请求得到cookie信息放到feign请求上
                        String cookie = request.getHeader("Cookie");
                        template.header("Cookie", cookie);
                    }
                }
            }
        };
    }
}

注意:上面在封装cookie的时候要拿到原来请求的cookie,设置到新的请求中

RequestContextHolder为SpingMVC中共享request数据的上下文,底层由ThreadLocal实现,也就是说该请求只对当前访问线程有效,如果new了新线程就找不到原来request了

3)线程异步丢失上下文问题

P268

因为异步编排的原因,他会丢掉ThreadLocal中原来线程的数据,从而获取不到loginUser,这种情况下我们可以在方法内的局部变量中先保存原来线程的信息,在异步编排的新线程中拿着局部变量的值重新设置到新线程中即可。

由于RequestContextHolder使用ThreadLocal共享数据,所以在开启异步时获取不到老请求的信息,自然也就无法共享cookie

【谷粒商城】分布式事务与下单_第6张图片

在这种情况下,我们需要在开启异步的时候将老请求的RequestContextHolder的数据设置进去

OrderServiceImpl.confirmOrder()代码

// 从主线程获取用户数据 放到局部变量中
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
    // 把旧RequestAttributes放到新线程的RequestContextHolder中
    RequestContextHolder.setRequestAttributes(attributes);
    // 远程查询所有的收获地址列表
    List<MemberAddressVo> address;
    try {
        address = memberFeignService.getAddress(MemberRespVo.getId());

此外远程获取价格的时候应该用R

七、订单确认页

1)订单确认页VO

点击"去结算"就会跳到订单确认页

  • 展示当前用户收获地址list
  • 所有选中的购物项list
  • 支付方式
  • 送货清单,价格也是最新价格,不是加入购物车时的价格
  • 优惠信息

跳转到确认页时需要携带的数据模型。

  • 要注意生成订单的时候,价格得重新算
  • 在后面的修改中,会让提交订单时不带着购物车数据,而是在后台重新 查询购物车选项。
  • 会带着总价,比对新总价和就总价是否一致
public class OrderConfirmVo { // 跳转到确认页时需要携带的数据模型。

    @Getter
    @Setter
    /** 会员收获地址列表 **/
    private List<MemberAddressVo> memberAddressVos;

    @Getter @Setter
    /** 所有选中的购物项 **/
    private List<OrderItemVo> items;

    /** 发票记录 **/
    @Getter @Setter
    /** 优惠券(会员积分) **/
    private Integer integration;

    /** 防止重复提交的令牌 **/
    @Getter @Setter
    private String orderToken;

    @Getter @Setter
    Map<Long,Boolean> stocks;

    public Integer getCount() { // 总件数
        Integer count = 0;
        if (items != null && items.size() > 0) {
            for (OrderItemVo item : items) {
                count += item.getCount();
            }
        }
        return count;
    }


    /** 计算订单总额**/
    //BigDecimal total;
    public BigDecimal getTotal() { 
        BigDecimal totalNum = BigDecimal.ZERO;
        if (items != null && items.size() > 0) {
            for (OrderItemVo item : items) {
                //计算当前商品的总价格
                BigDecimal itemPrice = item.getPrice().multiply(new BigDecimal(item.getCount().toString()));
                //再计算全部商品的总价格
                totalNum = totalNum.add(itemPrice);
            }
        }
        return totalNum;
    }


    /** 应付价格 **/
    //BigDecimal payPrice;
    public BigDecimal getPayPrice() {
        return getTotal();
    }
}

2)订单确认页数据获取

  • 异步:查询购物项(redis)、库存和收货地址(数据库)都要调用远程服务,串行会浪费大量时间,因此我们使用CompletableFuture进行异步编排
  • 为了防止多次重复点击“订单提交按钮”。我们在返回订单确认页时,在redis中生成一个随机的令牌,过期时间为30min,提交订单时会携带这个令牌,我们将会在订单提交的处理页面核验此令牌。

在购物车页面点击去结算,点击事件是window.location.href = "http://order.gulimall.com/toTrade";

返回订单确认页
// Order服务里的controller
@RequestMapping("/toTrade") // 用于返回订单确认页
public String toTrade(Model model) {
    // 内容是从登录用户里获取,所以不用带过来
    OrderConfirmVo confirmVo = orderService.confirmOrder();
    // 订单确认页要显示的数据
    model.addAttribute("confirmOrder", confirmVo);
    return "confirm";
}

返回信息:

利用CompletableFuture异步获取各项数据

@Override // OrderServiceImpl
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
    // 获取用户,用用户信息获取购物车
    MemberRespVo MemberRespVo = LoginUserInterceptor.threadLocal.get();
    // 封装订单
    OrderConfirmVo confirmVo = new OrderConfirmVo();

    // 我们要从request里获取用户数据,但是其他线程是没有这个信息的,
    // 所以可以手动设置新线程里也能共享当前的request数据
    RequestAttributes attributes = RequestContextHolder.getRequestAttributes();

    // 1.远程查询所有的收获地址列表
    CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
        // 因为异步线程需要新的线程,而新的线程里没有request数据,所以我们自己设置进去
        RequestContextHolder.setRequestAttributes(attributes);

        List<MemberAddressVo> address;
        try {
            address = memberFeignService.getAddress(MemberRespVo.getId());
            confirmVo.setAddress(address);
        } catch (Exception e) {
            log.warn("\n远程调用会员服务失败 [会员服务可能未启动]");
        }
    }, executor);

    // 2. 远程查询购物车服务,并得到每个购物项是否有库存
    CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
        // 异步线程共享 RequestContextHolder.getRequestAttributes()
        RequestContextHolder.setRequestAttributes(attributes);

        // feign在远程调用之前要构造请求 调用很多拦截器
        // 远程获取用户的购物项
        List<OrderItemVo> items = cartFeignService.getCurrentUserCartItems();
        confirmVo.setItems(items);
    }, executor).thenRunAsync(() -> {
        RequestContextHolder.setRequestAttributes(attributes);
        List<OrderItemVo> items = confirmVo.getItems();
        // 获取所有商品的id
        List<Long> skus = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
        R hasStock = wmsFeignService.getSkuHasStock(skus);
        List<SkuStockVo> data = hasStock.getData(new TypeReference<List<SkuStockVo>>() {});
        if (data != null) {
            // 各个商品id 与 他们库存状态的映射map // 学习下收集成map的用法
            Map<Long, Boolean> stocks = data.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
            confirmVo.setStocks(stocks);
        }
    }, executor);

    // 3.查询用户积分
    Integer integration = MemberRespVo.getIntegration();
    confirmVo.setIntegration(integration);

    // 4.其他数据在类内部自动计算

    // TODO 5.防重令牌 设置用户的令牌
    String token = UUID.randomUUID().toString().replace("-", "");
    confirmVo.setOrderToken(token);
    // redis中添加用户id,这个设置可以防止订单重复提交。生成完一次订单后删除redis
    stringRedisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + MemberRespVo.getId(), token, 10, TimeUnit.MINUTES);
    // 等待所有异步任务完成
    CompletableFuture.allOf(getAddressFuture, cartFuture).get();
    return confirmVo;
}

3)运费收件信息获取

  • 启动库存服务

  • 遍历地址进行显示,收货信息在member-recerve-address表中

  • 选中地址后形成订单时该地址

  • 有货无货状态,每个商品单独查比较麻烦,可以用skuId-list异步调用库存系统查出来

  • 加上运费,并且切换地址时要重新计算运费、总额

  • 如何高亮指定的地址border边界框:。主要说明的是th的赋值属性的方法``th:attr="def= a d d r . d e f a u l t S t a t u s " ‘ 和 j q u e r y 赋 值 属 性 的 方 法 ‘ {addr.defaultStatus}"`和jquery赋值属性的方法` addr.defaultStatus"jquery(this).attr(“def”,“1”);`

  • 点击提交订单时计算总额,而不是用当前页面的值,或者比对一下值,不一致让用户重新看订单

    
    <div class="top-3 addr-item" th:each="addr : ${orderConfirmData.address}">
        <p th:attr="def=${addr.defaultStatus},addrId=${addr.id}">[[${addr.name}]]
        p>
        <span>
            [[${addr.name}]] [[${addr.detailAddress}]]  [[${addr.phone}]]
        span>
    div>
    
    function highlight() {
        // 默认颜色是灰色
        $(".addr-item p").css({"border":"2px solid gray"})
        $(".addr-item p[def='1']").css({"border":"2px solid red"})
    }
    
    $(".addr-item p").click(function () {
        $("a.addr-item p").attr("def","0");// 设置属性
        $(this).attr("def","1");
        highlight();
        // 选择发生了变化就要获取当前地址id
        var addrId = $(this).attr("addrId");
        // 发送Ajax请求获取运费信息
        getFare(addrId)
    });
    

数据封装

@Data
public class FareVo { // 邮费
    private MemberAddressVo address;
    private BigDecimal fare;
}

在页面将选中地址的id传给请求。

获取邮费

@RequestMapping("/fare/{addrId}")
public FareVo getFare(@PathVariable("addrId") Long addrId) {
    return wareInfoService.getFare(addrId);
}

@Override
public FareVo getFare(Long addrId) {
    FareVo fareVo = new FareVo();
    R info = memberFeignService.info(addrId);
    if (info.getCode() == 0) {
        MemberAddressVo address = info.getData("memberReceiveAddress", new TypeReference<MemberAddressVo>() {
        });
        fareVo.setAddress(address);
        String phone = address.getPhone();
        //取电话号的最后两位作为邮费
        String fare = phone.substring(phone.length() - 2, phone.length());
        fareVo.setFare(new BigDecimal(fare));
    }
    return fareVo;
}

八、接口幂等性讨论

P274

讨论:多次点击 【提交订单】 按钮

幂等性:订单提交一次和提交多次结果是一致的

哪些情况要防止:

  • 用户多次点击按钮
  • 用户页面回退再次提交
  • 服务相互调用,由于网络间题,导致请求失败。feign触发重试机制
  • 其他业务情况

以SQL为例,有些操作是天然原子的。

  • SELECT FROM table WHERE id=? -- 无论执行多少次都不会改变状态,是天然的幂等
    UPDATE table SET col1=1 WHERE col2=2 -- 无论执行成功多少次状态都是一致的,也是幂等操作。
    delete from user where userid=l -- 多次操作,结果一样,具备幂等性
    insert into user (useridname) values(l,'a') -- 如userid为唯一主键,即重复操作上面的业务,只会插入一条用户数据,具备幂等性。
    
  • UPDATE table1 SET col=col+1 where col2= 22 -- 每执行的結果都会发生变化,不是幂等的。
    insert into user(userId,name)values(1,'a') -- 如userid不是主键,可以重复,那上面业务多次操作,都会新增多条,不具备幂等性
    

幂等解决方案

(1)、token机制

如12306选中座位后提交,带上验证码与后台该token对应的验证码一致才通过。如果通过了就删除,第二个即使带着验证码也匹配不到

前面我们返回订单页面时也在redis中设置了用户的uuid

  • 服务端提供了发送token的接囗。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取token,服务器会把token保存到redis中。

  • 然后调用业务接囗请求时,把token携带过去,一般放在请求头部。

  • 服务器判断token是否存在redis中,存在表示第一次请求,然后先删除token,继续执行业务。

    • 但要保证只能有一个去redis看,否则就可能都看到redis中有,删除两次

    • 【对比+删除】得是原子性的,所以就想到了用redis-luna脚本分布式锁

    • if redis.call('get',KEYS[1])==ARGV[1]
      then return redis.call('del',KEYS[1])
      else return 0
      end
      
  • 如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给client,这样就保证了业务代码,不重复执行。

(2)、各种锁

a、数据库悲观锁

select * from ×× where id=1 for update;
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。
另外要注意的是,id字段一定是主键或者唯一幸引,不然可能造成锁表的结果,处理起来会非常麻烦。

b、数据库乐观锁

这种方法适合在更新的场景中,
updatet _goods set count=count-1,version=version+1 where good_id=2 and version=1

根据version版本,也就是在操作库存前先获取当前商品的version版本号,然后操作的时候带上此version号。我们梳理下,我们第一次操作库存时,得到version为1,调用库存服务version变成了2;但返回给订单服务出现了间题,订单服务又一次发起调用库存,服务,当订单服务传如的version还是1,再执行上面的四!v语句时,就不会执行;因为version已经变为2了,where条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。

乐观锁主要使用于处理读多写少的问题

c、业务层分布式锁

(3)、各种唯一约束

a、数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。
我们在数据库层面防止重复。
这个机制是利用了数据库的主键唯一约束的特性,解决了在insert场景时幂等问题。但主键的要求不是自增的主,这样就需要业务生成全局唯一的主键。
如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。

b、redis set防重

很多数据需要处理,只能被处理一次,比如我们可以计算数据的MD5将其放入redis的set,每次处理数据,先看这个MD5是否已经存在,存在就不处理。

(4)、防重表

使用订单号orderNo做为去重表的唯一索引,把唯一幸引插入去重表,再进行业务操作,且他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。

之前说的redis防重也算

(5)、全局请求唯一id

调用接口时,生成一个唯一id,redis将数据保存到集合(去重),存在即处理过。

可以使用nginx设置每一个请求的唯一id

proxy_set_header X-Request-id $request_id;

九、订单提交

根据前面的幂等性知识,我们这里用token令牌机制解决幂等性

  • 准备好订单确认数据后,返回给用户看运费等信息,同时创建防重令牌redis.set('order:token:(userId)',uuid)
    • 也就是给服务器一个,给用户一个
  • 用户点击提交订单按钮,带着token。怎么带?设置个hidden元素带着就行
  • 渲染订单确认页,后台处理的时候确认请求带过来token的uuid和redis库中是否一致
    • 此处是重点,比对后立刻删除比对和删除要求具有原子性,通过redis-lua脚本完成
  • 提交订单时不要提交购买的商品,去购物车数据库重新获取即可,防止购物车变化和修改页面值
  • 但可以提交总额,防止商品金额变了还提交订单,用户不满意
  • 其他信息可以用token和session获取

(1)订单数据

从属性可以看出,订单提交时需要带的数据

@Data
public class OrderSubmitVo {

    /** 收获地址的id **/
    private Long addrId;

    /** 支付方式 **/
    private Integer payType;
    //无需提交要购买的商品,去购物车再获取一遍
    //优惠、发票

    /** 防重令牌 **/
    private String orderToken;

    /** 应付价格 **/
    private BigDecimal payPrice;

    /** 订单备注 **/
    private String remarks;

    //用户相关的信息,直接去session中取出即可
}

成功后转发至支付页面携带的数据

@Data
public class SubmitOrderResponseVo {

    // 该实体为order表的映射
    private OrderEntity order;

    /** 错误状态码 **/
    private Integer code;
}

(2)提交订单

  • 提交订单成功,则携带返回数据转发至支付页面
  • 提交订单失败,则携带错误信息重定向至确认页

在OrderWebController里接收到下单请求,然后去OrderServiceImpl里验证和下单,然后再返回到OrderWebController。相当于OrderWebController是封装了我们原来的OrderServiceImpl,用作web的

调用service,service返回了失败Code信息,可以看是什么原因引起的下单失败

@PostMapping("/submitOrder") // OrderWebController
public String submitOrder(OrderSubmitVo submitVo, Model model,
                          RedirectAttributes redirectAttributes){

    try {
        // 去OrderServiceImpl服务里验证和下单
        SubmitOrderResponseVo responseVo = orderService.submitOrder(submitVo);
        
        // 下单失败回到订单重新确认订单信息
        if(responseVo.getCode() == 0){
            // 下单成功去支付响应
            model.addAttribute("submitOrderResp", responseVo);
            // 支付页
            return "pay";
        }else{
            String msg = "下单失败";
            switch (responseVo.getCode()){
                case 1: msg += "订单信息过期,请刷新在提交";break;
                case 2: msg += "订单商品价格发送变化,请确认后再次提交";break;
                case 3: msg += "商品库存不足";break;
            }
            redirectAttributes.addFlashAttribute("msg", msg); 
            // 重定向
            return "redirect:http://order.gulimall.com/toTrade";
        }
    } catch (Exception e) {
        if (e instanceof NotStockException){
            String message = e.getMessage();
            redirectAttributes.addFlashAttribute("msg", message);
        }
        return "redirect:http://order.gulimall.com/toTrade";
    }

什么的逻辑其实是交给orderService.submitOrder(submitVo);

去做的,那么我们就接着往下看他是如何与令牌结合保证幂等性的

1)验证原子性令牌
  • 为防止在【获取令牌、对比值和删除令牌】之间发生错误导入令牌校验出错,我们必须使用lua脚本保证原子性操作
  • 改为先锁库存再生成订单
  • 库存服务后面讲
//	@GlobalTransactional
@Transactional
@Override // OrderServiceImpl
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
    // 当条线程共享这个对象
    confirmVoThreadLocal.set(vo);
    SubmitOrderResponseVo submitVo = new SubmitOrderResponseVo();
    // 0:正常
    submitVo.setCode(0);
    // 去服务器创建订单,验令牌,验价格,锁库存
    MemberRespVo MemberRespVo = LoginUserInterceptor.threadLocal.get();

    // 1. 验证令牌 [必须保证原子性] 返回 0 or 1
    // 0 令牌删除失败 1删除成功
    String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
    String orderToken = vo.getOrderToken();

    // 原子验证令牌 删除令牌
    Long result = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
                                              Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + MemberRespVo.getId()),
                                              orderToken);
    if (result == 0L) { // 令牌验证失败

        submitVo.setCode(1);
    } else {  // 令牌验证成功
        // 1 .创建订单等信息
        OrderCreateTo order = createOrder();
        // 2. 验价
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal voPayPrice = vo.getPayPrice();
        if (Math.abs(payAmount.subtract(voPayPrice).doubleValue()) < 0.01) {
            // 金额对比成功
            // 3.保存订单 挪到后面

            // 4.库存锁定
            WareSkuLockVo lockVo = new WareSkuLockVo();
            lockVo.setOrderSn(order.getOrder().getOrderSn());
            List<OrderItemVo> locks = order.getOrderItems().stream().map(item -> {
                OrderItemVo itemVo = new OrderItemVo();
                // 锁定的skuId 这个skuId要锁定的数量
                itemVo.setSkuId(item.getSkuId());
                itemVo.setCount(item.getSkuQuantity());
                itemVo.setTitle(item.getSkuName());
                return itemVo;
            }).collect(Collectors.toList());

            lockVo.setLocks(locks);
            // 远程锁库存
            R r = wmsFeignService.orderLockStock(lockVo);
            if (r.getCode() == 0) {
                // 库存足够 锁定成功 给MQ发送订单消息,到时为支付则取消
                submitVo.setOrderEntity(order.getOrder());
                rabbitTemplate.convertAndSend(this.eventExchange, this.createOrder, order.getOrder());
                saveOrder(order);
                //					int i = 10/0;
            } else {
                // 锁定失败
                String msg = (String) r.get("msg");
                throw new NotStockException(msg);
            }
        } else {
            // 价格验证失败
            submitVo.setCode(2);
        }
    }
    return submitVo;
}
2) 订单创建To

最终订单后要返回的数据

@Data
public class OrderCreateTo {

    private OrderEntity order;

    private List<OrderItemEntity> orderItems;

    /** 订单计算的应付价格 **/
    private BigDecimal payPrice;

    /** 运费 **/
    private BigDecimal fare;
}

创建订单、订单项

  • IdWorker生成订单号,是时间和本身对象的组合
  • 构建订单。此时还没商品
    • 用threadlocal保存一些当前线程的数据,就不用写形参了
  • 构建订单项。填入具体的商品,设计锁库存的问题
  • 计算价格
//2. 创建订单、订单项
OrderCreateTo order =createOrderTo(memberResponseVo,submitVo);

private OrderCreateTo createOrderTo(MemberResponseVo memberResponseVo, OrderSubmitVo submitVo) {
    //2.1 用IdWorker生成订单号
    String orderSn = IdWorker.getTimeId();
    //2.2 构建订单
    OrderEntity entity = buildOrder(memberResponseVo, submitVo,orderSn);
    //2.3 构建订单项
    List<OrderItemEntity> orderItemEntities = buildOrderItems(orderSn);
    //2.4 计算价格
    compute(entity, orderItemEntities);
    OrderCreateTo createTo = new OrderCreateTo();
    createTo.setOrder(entity);
    createTo.setOrderItems(orderItemEntities);
    return createTo;
}
构建订单
private OrderEntity buildOrder(MemberResponseVo memberResponseVo, OrderSubmitVo submitVo, String orderSn) {

    OrderEntity orderEntity =new OrderEntity();

    orderEntity.setOrderSn(orderSn);

    //1) 设置用户信息
    orderEntity.setMemberId(memberResponseVo.getId());
    orderEntity.setMemberUsername(memberResponseVo.getUsername());

    //2) 获取邮费和收件人信息并设置
    FareVo fareVo = wareFeignService.getFare(submitVo.getAddrId());
    BigDecimal fare = fareVo.getFare();
    orderEntity.setFreightAmount(fare);
    MemberAddressVo address = fareVo.getAddress();
    orderEntity.setReceiverName(address.getName());
    orderEntity.setReceiverPhone(address.getPhone());
    orderEntity.setReceiverPostCode(address.getPostCode());
    orderEntity.setReceiverProvince(address.getProvince());
    orderEntity.setReceiverCity(address.getCity());
    orderEntity.setReceiverRegion(address.getRegion());
    orderEntity.setReceiverDetailAddress(address.getDetailAddress());

    //3) 设置订单相关的状态信息
    orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
    orderEntity.setConfirmStatus(0);
    orderEntity.setAutoConfirmDay(7);

    return orderEntity;
}

构建订单项

订单项指的是订单里具体的商品

  • StringUtils.collectionToDelimitedString(list, ";分隔符")工具可以集合/数组转string
  • 订单项得算优惠后的价格
  • 用BigDecimal精确计算
// OrderServiceImpl
private List<OrderItemEntity> buildOrderItems(String orderSn) {
    // 这里是最后一次来确认购物项的价格 这个远程方法还会查询一次数据库
    List<OrderItemVo> cartItems = cartFeignService.getCurrentUserCartItems();
    List<OrderItemEntity> itemEntities = null;
    if(cartItems != null && cartItems.size() > 0){
        itemEntities = cartItems.stream().map(cartItem -> {
            OrderItemEntity itemEntity = buildOrderItem(cartItem);
            itemEntity.setOrderSn(orderSn);
            return itemEntity;
        }).collect(Collectors.toList());
    }
    return itemEntities;
}
/**
     * 构建某一个订单项
     */ // OrderServiceImpl
private OrderItemEntity buildOrderItem(OrderItemVo cartItem) {
    OrderItemEntity itemEntity = new OrderItemEntity();
    // 1.订单信息: 订单号
    // 已经在items里设置了

    // 2.商品spu信息
    Long skuId = cartItem.getSkuId();
    // 远程获取spu的信息
    R r = productFeignService.getSpuInfoBySkuId(skuId);
    SpuInfoVo spuInfo = r.getData(new TypeReference<SpuInfoVo>() {
    });
    itemEntity.setSpuId(spuInfo.getId());
    itemEntity.setSpuBrand(spuInfo.getBrandId().toString());
    itemEntity.setSpuName(spuInfo.getSpuName());
    itemEntity.setCategoryId(spuInfo.getCatalogId());

    // 3.商品的sku信息
    itemEntity.setSkuId(cartItem.getSkuId());
    itemEntity.setSkuName(cartItem.getTitle());
    itemEntity.setSkuPic(cartItem.getImage());
    itemEntity.setSkuPrice(cartItem.getPrice());
    // 把一个集合按照指定的字符串进行分割得到一个字符串
    // 属性list生成一个string
    String skuAttr = StringUtils.collectionToDelimitedString(cartItem.getSkuAttr(), ";");
    itemEntity.setSkuAttrsVals(skuAttr);
    itemEntity.setSkuQuantity(cartItem.getCount());
    // 4.积分信息 买的数量越多积分越多 成长值越多
    itemEntity.setGiftGrowth(cartItem.getPrice().multiply(new BigDecimal(cartItem.getCount())).intValue());
    itemEntity.setGiftIntegration(cartItem.getPrice().multiply(new BigDecimal(cartItem.getCount())).intValue());

    // 5.订单项的价格信息 优惠金额
    itemEntity.setPromotionAmount(new BigDecimal("0.0")); // 促销打折
    itemEntity.setCouponAmount(new BigDecimal("0.0")); // 优惠券
    itemEntity.setIntegrationAmount(new BigDecimal("0.0")); // 积分

    // 当前订单项的原价
    BigDecimal orign = itemEntity.getSkuPrice().multiply(new BigDecimal(itemEntity.getSkuQuantity().toString()));
    // 减去各种优惠的价格
    BigDecimal subtract =
        orign.subtract(itemEntity.getCouponAmount()) // 优惠券逻辑没有写,应该去coupon服务查用户的sku优惠券
        .subtract(itemEntity.getPromotionAmount()) // 官方促销
        .subtract(itemEntity.getIntegrationAmount()); // 京豆/积分
    itemEntity.setRealAmount(subtract);
    return itemEntity;
}

商品项价格计算完毕,接着去创建订单

private OrderCreateTo createOrder() {

    OrderCreateTo orderCreateTo = new OrderCreateTo();
    // 1. 生成一个订单号
    String orderSn = IdWorker.getTimeId();
    // 填充订单的各种基本信息,价格信息
    OrderEntity orderEntity = buildOrderSn(orderSn);

    // 2. 获取所有订单项   // 从里面已经设置好了用户该使用的价格
    List<OrderItemEntity> items = buildOrderItems(orderSn);

    // 3.根据订单项计算价格	传入订单 、订单项 计算价格、积分、成长值等相关信息
    computerPrice(orderEntity, items);
    orderCreateTo.setOrder(orderEntity);
    orderCreateTo.setOrderItems(items);
    return orderCreateTo;
}

计算总价

private void computerPrice(OrderEntity orderEntity, List<OrderItemEntity> items) {

    // 叠加每一个订单项的金额
    BigDecimal coupon = new BigDecimal("0.0");
    BigDecimal integration = new BigDecimal("0.0");
    BigDecimal promotion = new BigDecimal("0.0");
    BigDecimal gift = new BigDecimal("0.0");
    BigDecimal growth = new BigDecimal("0.0");

    // 总价
    BigDecimal totalPrice = new BigDecimal("0.0");
    for (OrderItemEntity item : items) {  // 这段逻辑不是特别合理,最重要的是累积总价,别的可以跳过
        // 优惠券的金额
        coupon = coupon.add(item.getCouponAmount());
        // 积分优惠的金额
        integration = integration.add(item.getIntegrationAmount());
        // 打折的金额
        promotion = promotion.add(item.getPromotionAmount());
        BigDecimal realAmount = item.getRealAmount();
        totalPrice = totalPrice.add(realAmount);

        // 购物获取的积分、成长值
        gift.add(new BigDecimal(item.getGiftIntegration().toString()));
        growth.add(new BigDecimal(item.getGiftGrowth().toString()));
    }
    // 1.订单价格相关 总额、应付总额
    orderEntity.setTotalAmount(totalPrice);
    orderEntity.setPayAmount(totalPrice.add(orderEntity.getFreightAmount()));

    orderEntity.setPromotionAmount(promotion);
    orderEntity.setIntegrationAmount(integration);
    orderEntity.setCouponAmount(coupon);

    // 设置积分、成长值
    orderEntity.setIntegration(gift.intValue());
    orderEntity.setGrowth(growth.intValue());

    // 设置订单的删除状态
    orderEntity.setDeleteStatus(OrderStatusEnum.CREATE_NEW.getCode());
}

3) 验价

计算完总价后,返回主逻辑

//	@GlobalTransactional
@Transactional
@Override // OrderServiceImpl
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {

    // 1. 验证令牌 [必须保证原子性] 返回 0 or 1

    if (result == 0L) { // 令牌验证失败
    } else {  // 令牌验证成功
        // 1 .创建订单等信息
        OrderCreateTo order = createOrder();
        // 2. 验价
        BigDecimal payAmount = order.getOrder().getPayAmount();
        BigDecimal voPayPrice = vo.getPayPrice();// 获取带过来的价格
        if (Math.abs(payAmount.subtract(voPayPrice).doubleValue()) < 0.01) {

"页面提交的价格""后台计算的价格"进行对比,若不同则提示用户商品价格发生变化

BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = submitVo.getPayPrice();
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
			/****************/
}else {
    //验价失败
    responseVo.setCode(2);
    return responseVo;
}

4) 保存订单到db

private void saveOrder(OrderCreateTo orderCreateTo) {
    OrderEntity order = orderCreateTo.getOrder();
    order.setCreateTime(new Date());
    order.setModifyTime(new Date());
    this.save(order);
    orderItemService.saveBatch(orderCreateTo.getOrderItems());
}

5) 锁定库存,发送延迟队列

  • 锁定库存失败要取消订单
// 在订单里的逻辑:
// 前面是创建订单、订单项、验价等逻辑...
// .....
// 
List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> {
    OrderItemVo orderItemVo = new OrderItemVo();
    orderItemVo.setSkuId(item.getSkuId());
    orderItemVo.setCount(item.getSkuQuantity());
    return orderItemVo;
}).collect(Collectors.toList());
// 去锁库存 @RequestMapping("/lock/order")
R r = wareFeignService.orderLockStock(orderItemVos);
//5.1 锁定库存成功
if (r.getCode()==0){
    responseVo.setOrder(order.getOrder());
    responseVo.setCode(0);
    return responseVo;
}else {
    //5.2 锁定库存失败
    String msg = (String) r.get("msg");
    throw new NoStockException(msg);
}

远程服务

@RequestMapping("/lock/order")
public R orderLockStock(@RequestBody List<OrderItemVo> itemVos) {
    try {
        Boolean lock = wareSkuService.orderLockStock(itemVos);
        return R.ok();
    } catch (NoStockException e) {
        return R.error(BizCodeEnum.NO_STOCK_EXCEPTION.getCode(), BizCodeEnum.NO_STOCK_EXCEPTION.getMsg());
    }
}
  • 找出所有库存大于商品数的仓库
  • 遍历所有满足条件的仓库,逐个尝试锁库存,若锁库存成功则退出遍历
<update id="lockSkuStock">
    UPDATE `wms_ware_sku` SET stock_locked = stock_locked + #{num}
    WHERE sku_id = #{skuId} 
    AND ware_id = #{wareId} 
    AND stock-stock_locked >= #{num}
update>
  • for遍历sku
    • for遍历仓库
      • wareSkuDao.lockSkuStock(skuId, wareId, hasStock.getNum());
      • cas所库存成功后发送延迟队列
@Transactional // 事务
@Override
public Boolean orderLockStock(List<OrderItemVo> itemVos) {
    List<SkuLockVo> lockVos = itemVos.stream().map((item) -> {
        SkuLockVo skuLockVo = new SkuLockVo();
        skuLockVo.setSkuId(item.getSkuId());
        skuLockVo.setNum(item.getCount());
        //找出所有库存大于商品数的仓库// 这个地方问题很大,后面得改
        List<Long> wareIds = baseMapper.listWareIdsHasStock(item.getSkuId(), item.getCount());
        skuLockVo.setWareIds(wareIds);
        return skuLockVo;
    }).collect(Collectors.toList());

    for (SkuLockVo lockVo : lockVos) {
        boolean lock = true;
        Long skuId = lockVo.getSkuId();
        List<Long> wareIds = lockVo.getWareIds();
        //如果没有满足条件的仓库,抛出异常
        if (wareIds == null || wareIds.size() == 0) {
            throw new NoStockException(skuId);
        }else {
        // 遍历仓库
            for (Long wareId : wareIds) {
            //  锁库存,更新sql用到了cas,如果返回非0代表更新对了
                Long count=baseMapper.lockWareSku(skuId, lockVo.getNum(), wareId);
                if (count==0){
                    lock=false;
                }else {
                    lock = true;
                    break;
                }
            }
        }
        if (!lock) throw new NoStockException(skuId);
    }
    return true;
}

这里通过异常机制控制事务回滚,如果在锁定库存失败则抛出NoStockExceptions,订单服务和库存服务都会回滚。

后面有消息队列后,会进行优化

优化逻辑为:锁库存后,把内容发到消息队列里

消息队列并不立刻消费,而是让其过期,过期后重新入队别的消息队列,别的消息队列拿到后验证订单是否被支付,没被支付的话还原到库存里。

十、分布式事务

订单服务下订单---------\
库存服务锁库存---------->分布式事务
用户服务扣减积分-------/

事务保证:

1、订单服务异常,库存锁定不运行,全部回滚,撤销操作
2、库存服务事务自治,锁定失败全部回滚,订单感受到,继续回滚
3、库存服务锁定成功了,但是网络原因返回数据途中问题?
4、库存服务锁定成功了,库存服务下面的逻辑发生故障,订单回滚了,怎么处理?

利用消息队列实现最终一致

库存服务锁定成功后发给消息队列消息(当前库存工作单),过段时间自动解锁,解锁时先查询订单的支付状态。解锁成功修改库存工作单详情项状态为已解锁

  • 1、远程服务假失败:远程服务其实成功了,由于网络故障等没有返回导致:订单回滚,库存却扣减
  • 2、远程服务执行完成,下面的其他方法出现问题导致:已执行的远程请求,肯定不能回滚

事务传播问题中,传播后事务设置还是原来的,如果不想用原来设置,必须new事务

注意同类中调用的话,被调用事务会失效,原因在于aop。事务基于代理,同对象的方法动态代理都是同一个。解决方案是使用代理对象调用。引用aop-starter后,使用aspectJ,开启AspectJ动态代理,原来默认使用的是jdk动态代理。

使用@EnableAspectJAutoProxy(exposeProxy=true)后,就取代了jdk动态代理。它没有接口也可以创建动态代理。设置true是为了对外暴露代理对象。
AopContext.currentProxy()然后强转,就是当前代理对象。

public interface AService {  
    public void a();  
    public void b();  
}  

@Service()  
public class AServiceImpl1 implements AService{  
    @Transactional(propagation = Propagation.REQUIRED)  
    public void a() {  
        this.b();  
    }  
    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    public void b() {  
    }  
} 
此处的this指向目标对象,因此调用this.b()将不会执行b事务切面,即不会执行事务增强,
    因此b方法的事务定义“@Transactional(propagation = Propagation.REQUIRES_NEW)”将不会实施,
    即结果是b和a方法的事务定义是一样的(我们可以看到事务切面只对a方法进行了事务增强,没有对b方法进行增强)
    
    
Q1:b中的事务会不会生效?
A1:不会,a的事务会生效,b中不会有事务,因为a中调用b属于内部调用,没有通过代理,所以不会有事务产生。
Q2:如果想要b中有事务存在,要如何做?
A2:<aop:aspectj-autoproxy expose-proxy=true> ,设置expose-proxy属性为true,将代理暴露出来,使用AopContext.currentProxy()获取当前代理,将this.b()改为((UserService)AopContext.currentProxy()).b()

解决方案:

public void a() {  
    ((AService) AopContext.currentProxy()).b();//即调用AOP代理对象的b方法即可执行事务切面进行事务增强  
} 

解决远程宕机

CAP理论

CAP原则又称CAP定理,指的是在一个分布式系统中

  • 一致性(consistency)
    在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  • 可用性(Availability)
    在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
  • 分区容惜性(Partitiontolerance)
    • 大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。
      分区容错的意思是,区间通信可能失败。比如,一台服务器放在中国,另一台服务器放在美国,这就是两个区,它们之间可能无法通信。

CAP原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾

  • CP要求一致性(有一个没同步好就不可用)
  • AP要求高可用
选举与同步理论

分布式一致性动画演示:http://thesecretlivesofdata.com/raft/

raft是一个实现分布式一致性的协议

结点的状态:

  • follower
  • candidate
  • leader

选举leader:

  • 默认都以follower状态启动,follower监听不到leader,就称为一个candidate
  • 投票给自己,然后告诉其他人,同时也收到别人的投票信息。根据投票信息和投票信息里带的信息(如那个节点里的数据)
  • 收到投票后,改选一个自己觉得最靠谱的。某一节点收到票数超过一半就变成leader

raft有两个超时时间控制领导选举:

  • 选举超时:从follower到candidate的时间,150ms-300ms(自旋时间),这个时间段内没收到leader的心跳就变为候选者。
    • 自旋时间结束后变成candidate,开始一轮新的选举(老师上课举的例子是)
    • 投出去票后重新计时自旋
    • leader就发送追加日志给follower,follower就正常
  • 消息发送的,心跳时间:如10ms,leader收到投票后,下一次心跳时就带上消息,follower收到消息后重置选举时间
    • leader宕机,follower收不到心跳,开始新的选举

写数据:

  • 接下来所有的数据都要先给leader,leader派发给follower
  • 比如领导收到信息5后,领导先在leader的log中写入变化set 5。(上面的动态红颜色代表没提交),此时5还没提交,而是改了leader的log后,
  • leader下一次心跳时,顺便带着信息让follower也去改变follower的log,follower写入日志成功后,发送确认ack 5给leader,
  • leader收到大多数的ack后,leader就自己正式写入数据,然后告诉follower提交写入硬盘/内存吧(这个过程和响应客户端是同时的)。这个过程叫做日志复制(也有过半机制)
  • 然后leader响应说集群写入好了

如果有的结点消息滞后了:

5台机器因为局域网隔离又分为3、2生成两个leader怎么办:

对于1,2结点那个leader,更新log后收不到大多数的ack(得超过1个ack),所以改log不成功,一直保存不成功

对于345结点的leader:收到消息后更新log并且收到ack过半且超过1个,成功保存。

此时网络又通了,以更高轮选举的leader为主,退位一个leader。那1,2结点日志都回滚,同步新leader的log。这样就都一致性了

另外注意:集群一般都是单数,因为有过半机制。比如原来集群6个机器,分为2半后,各3个,选leader时谁都拿不到6/2+1=4个投票,所以都没有leader

更多动画(可以自己选择宕机情况)raft.github.io

但一般都是保证AP,舍弃C

BASE理论

后续发现扣减不一致后,再恢复

BASE理论是对CAP理论的延伸,思想是即使无法做到强一致性(CAP的一致性就是强一致性),但可以采用弱一致性,即最终一致性

BASE是指:

  • 基本可用(BasicallyAvailable)
    基本可用是指分布式系统在出现故障的时候,允许损失部分可用性(例如响应时间、功能上的可用性),允许损失部分可用性。需要注意的是,基本可用绝不等价于系统不可用。
    • 响应时间上的损失:正常情况下搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障(比如系统部分机房发生断电或断网故障),查询结果的响应时间增加到了1~2秒。
    • 功能上的损失:购物网站在购物高峰(如双十一)时,为了保护系统的稳定性,部分消费者可能会被引导到一个降级页面。
  • 软状态(soft state)
    • 软状态是指允许系统存在中间状态,而该中间状态不会影响系统整体可用性。分布式存储中一般一份数据会有多个副本,允许不同副本同步的延时就是软状态的体现。mysql replication的异步复制也是一种体现。
  • 最终一致性(Eventual Consistency)
    • 最终一致性是指系统中的所有数据副本经过一定时间后,最终能够达到一致的状态。弱一致性和强一致性相反,最终一致性是弱一致性的一种特殊情况。(这也是分布式事务的想法)

从客户端角度,多进程并发访同时,更新过的数据在不同程如何获的不同策珞,决定了不同的一致性。

  • 对于关系型要求更新过据能后续的访同都能看到,这是强一致性。
  • 如果能容忍后经部分过者全部访问不到,则是弱一致性
  • 如果经过一段时间后要求能访问到更新后的数据,则是最终一致性

分布式事务几种方案

1) 2PC模式(XA事务)

数据库支持的2pc【2二阶段提交】,又叫做XA Transactions

支持情况:mysql从5.5版本开始支持,SQLserver2005开始支持,Oracle7开始支持。

其中,XA是一个两阶段提交协议,该协议分为以下两个阶段:

  • 第一阶段:事务协调器要求每个涉及到事务的数据库预提交(P090此操作,并反映是否可以提交,
  • 第二阶段:事务协调器要求每个数据库提交数据。
  • 其中,如果有任何一个数据库否决此次提交,那么所有数据库都会被要求回滚它们在此事务中的那部分信息

如图所示,如果有订单服务和库存服务要求分布式事务,要求有一个总的事务管理器

总的事务管理让事务分为两个阶段,

  • 第一个阶段是预备(log)。
  • 第二个阶段是正式提交(commit)

总事务管理器接收到两个服务都预备好了log(收到ack),就告诉他们commit

如果有一个没准备好,就回滚所有人。

总结2PC:

  • XA协议比较简单,而且一旦商业数据库实现了XA协议,使用分布式事务的成本也比较低。
  • 性能不理想,特别是在交易下单链路,往往并发量很高,XA无法满足高并发场景
  • XA目前在商业数据库支持的比较理想,在mysql数据库中支持的不太理想,mysql的XA实现,没有记录阶段日志,主备切换回导致主库与备库数据不一致。
  • 许多nosql没有支持XA,这让XA的应用场景变得非常狭隘。
  • 也有3PC,引入了超时机制(无论协调者还是参与者,在向对方发送请求后,若长时间未收到回应则做出相应处理)
2) 柔性事务-TCC事务补偿型方案
  • 刚性事务:遵循ACID原则,强一致性。
  • 柔性事务:遵循BASE理论,最终一致性

与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。

  • 一阶段prepare行为:调用自定义的prepare逻辑。
  • 二阶段commit行为:调用自定义的commit逻憬。
  • 二阶段rollback行为:调用自定义的rollback逻辑。

所TCC模式,是指支持 自定义的 分支事务纳入到全局事务的管理中。

3)柔性事务-最大努力通知型方案

按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接囗进行核对。这种方案主要用在与第三方系统通讯时,比如:调用微信或支付宝支付后的支付结果通知。这种方案也是结合MQ进行实现,例如:通过MQ发送就请求,设置最大通知次数。达到通知次数后即不再通知。

案例:银行涌知、商户通知等(各大交易业务平台间的商户涌知:多次通知、查询校对、对账文件),支付宝的支付成功异步回调

大业务调用订单,库存,积分。最后积分失败,则一遍遍通知他们回滚

让子业务监听消息队列

如果收不到就重新发

4)柔性事务=可靠消息+最终一致性方案(异步确保型)

实现:业务处理服务在业务事务提交之前,向实时消息服务请求发送捎息,实时捎息服务只记录消息数据,而不是真正的发送。业务处理服务在业务事务提交之后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才会真正发送。

seata解决分布式事务问题

体验2pc两阶段提交。继续正例前面订单出错的逻辑

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

快速开始:http://seata.io/zh-cn/docs/user/quickstart.html

TC (Transaction Coordinator) - 事务协调者

维护全局和分支事务的状态,驱动全局事务提交或回滚。

TM (Transaction Manager) - 事务管理器

定义全局事务的范围:开始全局事务、提交或回滚全局事务。

RM (Resource Manager) - 资源管理器

管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

【谷粒商城】分布式事务与下单_第7张图片

要执行下单,

  • TM告诉TC开启一个全局事务。
  • storage注册分支事务,实时向TC汇报分支状态
  • account失败,告诉TC失败了,TC回滚全部全局事务。

我们只需要使用一个 @GlobalTransactional 注解在业务方法上:

@GlobalTransactional
public void purchase(String userId, String commodityCode, int orderCount) {
    ......
}

我们有业务步骤,但是SEATA AT模式需要 UNDO_LOG 表,记录之前执行的操作。每个涉及的子系统对应的数据库都要新建表

-- 注意此处0.3.0+ 增加唯一索引 ux_undo_log
CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,
  `log_status` int(11) NOT NULL,
  `log_created` datetime NOT NULL,
  `log_modified` datetime NOT NULL,
  `ext` varchar(100) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
<dependency>
    <groupId>com.alibaba.cloudgroupId>
    <artifactId>spring-cloud-starter-alibaba-seataartifactId>
dependency>
带上版本号
开始应用
  • 从 https://github.com/seata/seata/archive/v0.7.1.zip 下载服务器软件包senta-server-0.7.1,将其解压缩。作为TC

  • 为了节省git资源,我们下载源码的项目自己编译。

  • 编译项目:

    • 下载后复制到guli项目下,然后在project structure–module中点击+号import module,选择项目里的seata

    • 会有报错,protobuf这个包找不到。在idea中安装proto buffer editor插件,重启idea(还找不到就重新编译一下,在mvn中找到seata-serializer子项目,点击protobuf里的compile选项。有个grpc的test报错,先全注释掉)

    • 有一个server项目,找到注册中心配置resource/registry.conf,修改启动的nacos信息。可以修改注册中心和配置中心(先不用管file.conf

      registry {
        # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
         # 修改这个
        type = "nacos"
      
        nacos {
          # 修改这个
          serverAddr = "localhost:8848"
          namespace = "public"
          cluster = "default"
        }
      
    • 启动server下的主类

    • 在nacos中看到一个serverAddr服务

@GlobalTransactional

在大事务的入口标记注解@GlobalTransactional开启全局事务,并且每个小事务标记注解@Transactional

@GlobalTransactional
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo submitVo) {
}

怎么用:https://github.com/seata/seata-samples/tree/master/springcloud-jpa-seata

注意

  • 注入 DataSourceProxy

因为 Seata 通过代理数据源实现分支事务,如果没有注入,事务无法成功回滚

@Configuration
public class DataSourceConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource")
    public DruidDataSource druidDataSource() {
        return new DruidDataSource();
    }

    /**
     * 需要将 DataSourceProxy 设置为主数据源,否则事务无法回滚
     *
     * @param druidDataSource The DruidDataSource
     */
    @Primary
    @Bean("dataSource")
    public DataSource dataSource(DruidDataSource druidDataSource) {
        return new DataSourceProxy(druidDataSource);
    }
}
  • file.conf 的 service.vgroup_mapping 配置必须和spring.application.name一致

GlobalTransactionAutoConfiguration类中,默认会使用 ${spring.application.name}-fescar-service-group作为服务名注册到 Seata Server上(每个小事务也要注册到tc上),如果和file.conf中的配置不一致,会提示 no available server to connect错误

也可以通过配置yaml的 spring.cloud.alibaba.seata.tx-service-group修改后缀,但是必须和file.conf中的配置保持一致

与上面配置数据源的方式等价,这么配置

@Configuration
public class MySeataConfig {
    @Autowired
    DataSourceProperties dataSourceProperties;

    @Bean
    public DataSource dataSource(DataSourceProperties dataSourceProperties) {

        HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
        if (StringUtils.hasText(dataSourceProperties.getName())) {
            dataSource.setPoolName(dataSourceProperties.getName());
        }
        return new DataSourceProxy(dataSource);
    }
}

在order、ware中都配置好上面的配置

然后它还要求每个微服务要有register.conffile.conf

register.conffile.conf复制到需要开启分布式事务的根目录,并修改file.conf

vgroup_mapping.${application.name}-fescar-service-group = "default"

service {
  #vgroup->rgroup
  vgroup_mapping.gulimall-ware-fescar-service-group = "default"
  #only support single node  
  default.grouplist = "127.0.0.1:8091"
  #degrade current not support
  enableDegrade = false
  #disable
  disable = false
  #unit ms,s,m,h,d represents milliseconds, seconds, minutes, hours, days, default permanent
  max.commit.retry.timeout = "-1"
  max.rollback.retry.timeout = "-1"
}

在大事务上@GlobalTransactional,小事务上@Transactional即可

tcc也可以看samples。

但是上面使用的是AT模式,2pc不适用高并发。

发生了几次远程调用。去保护spu,适合使用at模式。

高并发,如下单,at模式有很多锁,影响效率。所以不使用at tcc。使用消息方式
失败了之后发消息。库存服务本身也可以使用自动解锁模式。消息队列。
自动解锁:库存服务订阅消息队列,库存解锁发给消息队列

保存库存工作单和库存工作单详情,
锁定库存后数据库记录。后面的事务失败后看前面的库存,有没解锁的就解锁。
定期全部检索很麻烦,索引引入延迟队列。
锁库存后害怕订单失败,锁库存后发送给消息队列,只不过要暂存一会先别被消费。半小时以后再消费就可以知道大事务成功没有。

消息队列实现最终一致性

【谷粒商城】分布式事务与下单_第8张图片

上面这个流程并没有特别的设置,完全靠消息队列控制

(1) 延迟队列

场景:比如未付款订单,超过一定时间后,系统自动取消订单并释放占有物品

常用解决方案:

  • spring的schedule走时任务轮询数据库
    • 消耗系统内存、增加了数据库的压力、存在较大时间误差
  • 解决:rabbitmq的消息TTL和死信Exchange结合

订单关了之后40分钟后库存检查订单存在还是取消。

定时任务此外还有超时和检测时间段错开的情况(时效性问题)。最高等2倍的定时任务时间。

下订单延迟队列,

不要设置消息过期,要设置为队列过期方式。

节省一个交换机

使用bean方式创建交换机。
注意a

  • 定义:延迟队列存储的对象肯定是对应的延时消息,所谓"延时消息"是指当消息被发送以后,并不想让消费者立即拿到消息,而是等待指定时间后,消费者才拿到这个消息进行消费。

  • 实现:rabbitmq可以通过设置队列的TTL+死信路由实现延迟队列

    • TTL:RabbitMQ可以针对Queue设置x-expires 或者 针对Message设置 x-message-ttl,来控制消息的生存时间,如果超时(两者同时设置以最先到期的时间为准),则消息变为dead letter(死信)

    • 死信路由DLX:RabbitMQ的Queue可以配置x-dead-letter-exchange 和x-dead-letter-routing-key(可选)两个参数,如果队列内出现了dead letter,则按照这两个参数重新路由转发到指定的队列。

      • x-dead-letter-exchange:出现dead letter之后将dead letter重新发送到指定exchange
      • x-dead-letter-routing-key:出现dead letter之后将dead letter重新按照指定的routing-key发送

【谷粒商城】分布式事务与下单_第9张图片

针对订单模块创建以上消息队列,创建订单时消息会被发送至队列order.delay.queue,经过TTL的时间后消息会变成死信以order.release.order的路由键经交换机转发至队列order.release.order.queue,再通过监听该队列的消息来实现过期订单的处理

(2) 延迟队列使用场景

为什么不能用定时任务完成?

如果恰好在一次扫描后完成业务逻辑,那么就会等待两个扫描周期才能扫到过期的订单,不能保证时效性

(3) 订单分布式主体逻辑

  • 订单超时未支付触发订单过期状态修改与库存解锁

创建订单时消息会被发送至队列order.delay.queue,经过TTL的时间后消息会变成死信以order.release.order的路由键经交换机转发至队列order.release.order.queue,再通过监听该队列的消息来实现过期订单的处理

  • 如果该订单已支付,则无需处理
  • 否则说明该订单已过期,修改该订单的状态并通过路由键order.release.other发送消息至队列stock.release.stock.queue进行库存解锁
  • 库存锁定后延迟检查是否需要解锁库存

在库存锁定后通过路由键stock.locked发送至延迟队列stock.delay.queue,延迟时间到,死信通过路由键stock.release转发至stock.release.stock.queue,通过监听该队列进行判断当前订单状态,来确定库存是否需要解锁

  • 由于关闭订单库存解锁都有可能被执行多次,因此要保证业务逻辑的幂等性,在执行业务是重新查询当前的状态进行判断
  • 订单关闭和库存解锁都会进行库存解锁的操作,来确保业务异常或者订单过期时库存会被可靠解锁

(4) @Bean交换机和队列

在ware和order中配置好pom、yaml、@EnableRabbit

  • 订单模块
@Configuration
public class MyRabbitmqConfig {
    @Bean
    public Exchange orderEventExchange() {

        return new TopicExchange("order-event-exchange", // name
                                 true,// durable
                                 false); // autoDelete  
        // 还有一个参数是Map arguments
    }

    /**
     * 延迟队列
     */
    @Bean
    public Queue orderDelayQueue() {
        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); // 属性map
    }

    /**
     * 普通队列
     */
    @Bean
    public Queue orderReleaseQueue() {

        Queue queue = new Queue("order.release.order.queue", true, false, false);
        return queue;
    }

    /**
     * 创建订单的binding
     */
    @Bean
    public Binding orderCreateBinding() {

        return new Binding("order.delay.queue", // 目的地(队列名或者交换机名字)
                           Binding.DestinationType.QUEUE,  // 目的地类型(Queue、Exhcange)
                           "order-event-exchange", // 交换器
                           "order.create.order", // 路由key
                           null); // 参数map
    }

    @Bean
    public Binding orderReleaseBinding() {
        return new Binding("order.release.order.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.order",
                null);
    }

    @Bean
    public Binding orderReleaseOrderBinding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "order-event-exchange",
                "order.release.other.#",
                null);
    }
}
  • 库存模块
@Configuration
public class MyRabbitmqConfig {

    @Bean
    public Exchange stockEventExchange() {
        return new TopicExchange("stock-event-exchange", true, false);
    }

    /**
     * 延迟队列
     */
    @Bean
    public Queue stockDelayQueue() {
        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);
    }

    /**
     * 普通队列,用于解锁库存
     */
    @Bean
    public Queue stockReleaseStockQueue() {
        return new Queue("stock.release.stock.queue", true, false, false, null);
    }


    /**
     * 交换机和延迟队列绑定
     */
    @Bean
    public Binding stockLockedBinding() {
        return new Binding("stock.delay.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.locked",
                null);
    }

    /**
     * 交换机和普通队列绑定
     */
    @Bean
    public Binding stockReleaseBinding() {
        return new Binding("stock.release.stock.queue",
                Binding.DestinationType.QUEUE,
                "stock-event-exchange",
                "stock.release.#",
                null);
    }
}

(5) 库存回滚解锁

1)库存锁定

在库存锁定是添加以下逻辑

  • 由于可能订单回滚的情况,所以为了能够得到库存锁定的信息,在锁定时需要记录库存工作单,其中包括订单信息和锁定库存时的信息(仓库id,商品id,锁了几件…)
  • 在锁定成功后,向延迟队列发消息,带上库存锁定的相关信息

逻辑:

  • 遍历订单项,遍历每个订单项的每个库存,直到锁到库存

  • 发消息后库存回滚也没关系,用id是查不到数据库的

  • 锁库存的sql

    <update id="lockSkuStock">
        UPDATE `wms_ware_sku` SET stock_locked = stock_locked + #{num}
        WHERE sku_id = #{skuId} 
        AND ware_id = #{wareId}
        AND stock-stock_locked >= #{num}
    update>
    
@Transactional(rollbackFor = NotStockException.class) // 自定义异常
@Override
public Boolean orderLockStock(WareSkuLockVo vo) { // List

    // 锁库存之前先保存订单 以便后来消息撤回
    WareOrderTaskEntity taskEntity = new WareOrderTaskEntity();
    taskEntity.setOrderSn(vo.getOrderSn());
    orderTaskService.save(taskEntity);
    // [理论上]1. 按照下单的收获地址 找到一个就近仓库, 锁定库存
    // [实际上]1. 找到每一个商品在哪一个仓库有库存
    List<OrderItemVo> locks = vo.getLocks();//订单项
    List<SkuWareHasStock> lockVOs = locks.stream().map(item -> {
        // 创建订单项
        SkuWareHasStock hasStock = new SkuWareHasStock();
        Long skuId = item.getSkuId();
        hasStock.setSkuId(skuId);
        // 查询本商品在哪有库存
        List<Long> wareIds = wareSkuDao.listWareIdHasSkuStock(skuId);
        hasStock.setWareId(wareIds);
        hasStock.setNum(item.getCount());//购买数量
        return hasStock;
    }).collect(Collectors.toList());

    for (SkuWareHasStock hasStock : lockVOs) {
        Boolean skuStocked = true;
        Long skuId = hasStock.getSkuId();
        List<Long> wareIds = hasStock.getWareId();
        if(wareIds == null || wareIds.size() == 0){
            // 没有任何仓库有这个库存(注意可能会回滚之前的订单项,没关系)
            throw new NotStockException(skuId.toString());
        }
        // 1 如果每一个商品都锁定成功 将当前商品锁定了几件的工作单记录发送给MQ
        // 2 如果锁定失败 前面保存的工作单信息回滚了(发送了消息却回滚库存的情况,没关系,用数据库id查就可以)
        for (Long wareId : wareIds) {
            // 成功就返回 1 失败返回0  (有上下限)
            Long count = wareSkuDao.lockSkuStock(skuId, wareId, hasStock.getNum());//要锁定num个
            if (count==0){ // 没有更新对,说明锁当前库库存失败,去尝试其他库
                skuStocked=false;
            }else { // 即1
                // TODO 告诉MQ库存锁定成功 一个订单锁定成功 消息队列就会有一个消息
                // 订单项详情
                WareOrderTaskDetailEntity detailEntity = new WareOrderTaskDetailEntity(null,skuId,"",hasStock.getNum() ,taskEntity.getId(),
                                                                                       wareId, // 锁定的仓库号
                                                                                       1);
                // db保存订单sku项工作单详情,告诉商品锁的哪个库存
                orderTaskDetailService.save(detailEntity);
                // 发送库存锁定消息到延迟队列
                StockLockedTo stockLockedTo = new StockLockedTo();
                stockLockedTo.setId(taskEntity.getId());
                StockDetailTo detailTo = new StockDetailTo();
                BeanUtils.copyProperties(detailEntity, detailTo);
                // 防止回滚以后找不到数据 把详细信息页
                stockLockedTo.setDetailTo(detailTo);

                // 发送
                rabbitTemplate.convertAndSend(eventExchange, routingKey ,stockLockedTo);
                skuStocked = true;
                break;// 一定要跳出,防止重复发送多余消息
            }

        }
        if(!skuStocked){
            // 当前商品在所有仓库都没锁柱
            throw new NotStockException(skuId.toString());
        }
    }
    // 3.全部锁定成功
    return true;
}

什么编写了发送消息队列的逻辑,下面写接收消息队列后还原库存的逻辑

2)接收消息
  • 延迟队列会将过期的消息路由至"stock.release.stock.queue",通过监听该队列实现库存的解锁
  • 为保证消息的可靠到达,我们使用手动确认消息的模式,在解锁成功后确认消息,若出现异常则重新归队
@Component
@RabbitListener(queues = {"stock.release.stock.queue"})
public class StockReleaseListener {

    @Autowired
    private WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException {
        log.info("*************收到库存解锁的消息***************");
        try {
            wareSkuService.unlock(stockLockedTo);
            // 手动确认消息消费
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}
3)库存解锁
  • 如果工作单详情不为空,说明该库存锁定成功
    • 查询最新的订单状态,
    • 如果订单不存在,说明订单提交出现异常回滚,
    • 如果订单存在(但订单处于已取消的状态),我们都对已锁定的库存进行解锁
  • 如果工作单详情为空,说明库存未锁定,自然无需解锁
  • 为保证幂等性,我们分别对订单的状态和工作单的状态都进行了判断,只有当订单过期且工作单显示当前库存处于锁定的状态时,才进行库存的解锁

解锁库存核心sql

<update id="unlockStock">
    UPDATE `wms_ware_sku` 
    SET stock_locked = stock_locked - #{num}
    WHERE sku_id = #{skuId} 
    AND ware_id = #{wareId}
update>
@Override
public void unlockStock(StockLockedTo to) {
    log.info("\n收到解锁库存的消息");
    // 库存id
    Long id = to.getId();
    StockDetailTo detailTo = to.getDetailTo();
    Long detailId = detailTo.getId();
    /**
         * 解锁库存
         * 	查询数据库关系这个订单的详情
         * 		有: 证明库存锁定成功
         * 			1.没有这个订单, 必须解锁
         * 			2.有这个订单 不是解锁库存
         * 				订单状态:已取消,解锁库存
         * 				没取消:不能解锁	;
         * 		没有:就是库存锁定失败, 库存回滚了 这种情况无需回滚
         */
    WareOrderTaskDetailEntity byId = orderTaskDetailService.getById(detailId);
    if(byId != null){
        // 解锁
        WareOrderTaskEntity taskEntity = orderTaskService.getById(id);
        String orderSn = taskEntity.getOrderSn();
        // 根据订单号 查询订单状态 已取消才解锁库存
        R orderStatus = orderFeignService.getOrderStatus(orderSn);
        if(orderStatus.getCode() == 0){
            // 订单数据返回成功
            OrderVo orderVo = orderStatus.getData(new TypeReference<OrderVo>() {});
            // 订单不存在或订单已取消
            if(orderVo == null || orderVo.getStatus() == OrderStatusEnum.CANCLED.getCode()){
                // 订单已取消 状态1 已锁定  这样才可以解锁
                if(byId.getLockStatus() == 1){
                    unLock(detailTo.getSkuId(), detailTo.getWareId(), detailTo.getSkuNum(), detailId);
                }
            }
        }else{
            // 消息拒绝 重新放回队列 让别人继续消费解锁
            throw new RuntimeException("远程服务失败");
        }
    }else{
        // 无需解锁
    }
}

/**
     * 解锁库存
     */
private void unLock(Long skuId,Long wareId, Integer num, Long taskDeailId){
    // 更新库存
    wareSkuDao.unlockStock(skuId, wareId, num);
    // 更新库存工作单的状态
    WareOrderTaskDetailEntity detailEntity = new WareOrderTaskDetailEntity();
    detailEntity.setId(taskDeailId);
    detailEntity.setLockStatus(2);
    orderTaskDetailService.updateById(detailEntity);
}

注意远程调用还需要登录的问题,所以设置拦截器不拦截 order/order/status/{orderSn}。

boolean match = new AntPathMatcher().match("order/order/status/**", uri);

get方法,安全性还好,如果修改的url呢?前面主要是因为没带redis-key查询session,所以我们或许可以在远程调用中想办法传入redis-key

(6) 定时关单

1) 提交订单
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo submitVo) {

    //提交订单的业务处理。。。
    
    //发送消息到订单延迟队列,判断过期订单
    rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",order.getOrder());
               
}
2) 监听队列

创建订单的消息会进入延迟队列,最终发送至队列order.release.order.queue,因此我们对该队列进行监听,进行订单的关闭

@Component
@RabbitListener(queues = {"order.release.order.queue"})
public class OrderCloseListener {

    @Autowired
    private OrderService orderService;

    @RabbitHandler
    public void listener(OrderEntity orderEntity, Message message, Channel channel) throws IOException {
        System.out.println("收到过期的订单信息,准备关闭订单" + orderEntity.getOrderSn());
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        try {
            orderService.closeOrder(orderEntity);
            channel.basicAck(deliveryTag,false);
        } catch (Exception e){
            channel.basicReject(deliveryTag,true);
        }

    }
}
3) 关闭订单
  • 由于要保证幂等性,因此要查询最新的订单状态判断是否需要关单
  • 关闭订单后也需要解锁库存,因此发送消息进行库存、会员服务对应的解锁
@Override
public void closeOrder(OrderEntity orderEntity) {
    //因为消息发送过来的订单已经是很久前的了,中间可能被改动,因此要查询最新的订单
    OrderEntity newOrderEntity = this.getById(orderEntity.getId());
    //如果订单还处于新创建的状态,说明超时未支付,进行关单
    if (newOrderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()) {
        OrderEntity updateOrder = new OrderEntity();
        updateOrder.setId(newOrderEntity.getId());
        updateOrder.setStatus(OrderStatusEnum.CANCLED.getCode());
        this.updateById(updateOrder);

        //关单后发送消息通知其他服务进行关单相关的操作,如解锁库存
        OrderTo orderTo = new OrderTo();
        BeanUtils.copyProperties(newOrderEntity,orderTo);
        rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other",orderTo);
    }
}
4) 解锁库存
@Slf4j
@Component
@RabbitListener(queues = {"stock.release.stock.queue"})
public class StockReleaseListener {

    @Autowired
    private WareSkuService wareSkuService;

    @RabbitHandler
    public void handleStockLockedRelease(StockLockedTo stockLockedTo, Message message, Channel channel) throws IOException {
        log.info("************收到库存解锁的消息****************");
        try {
            wareSkuService.unlock(stockLockedTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }

    @RabbitHandler
    public void handleStockLockedRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
        log.info("************从订单模块收到库存解锁的消息********************");
        try {
            wareSkuService.unlock(orderTo);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        } catch (Exception e) {
            channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
        }
    }
}
@Override
public void unlock(OrderTo orderTo) {
    //为防止重复解锁,需要重新查询工作单
    String orderSn = orderTo.getOrderSn();
    WareOrderTaskEntity taskEntity = wareOrderTaskService.getBaseMapper().selectOne((new QueryWrapper<WareOrderTaskEntity>().eq("order_sn", orderSn)));
    //查询出当前订单相关的且处于锁定状态的工作单详情
    List<WareOrderTaskDetailEntity> lockDetails = wareOrderTaskDetailService.list(
        new QueryWrapper<WareOrderTaskDetailEntity>()
        .eq("task_id", taskEntity.getId())
        .eq("lock_status", WareTaskStatusEnum.Locked.getCode()));
    for (WareOrderTaskDetailEntity lockDetail : lockDetails) {
        unlockStock(lockDetail.getSkuId(),lockDetail.getSkuNum(),lockDetail.getWareId(),lockDetail.getId());
    }
}

十一、支付

(1) 支付宝加密原理

  • 支付宝加密采用RSA非对称加密,分别在商户端和支付宝端有两对公钥和私钥
  • 在发送订单数据时,直接使用明文,但会使用商户私钥加一个对应的签名,支付宝端会使用商户公钥对签名进行验签,只有数据明文和签名对应的时候才能说明传输正确
  • 支付成功后,支付宝发送支付成功数据之外,还会使用支付宝私钥加一个对应的签名,商户端收到支付成功数据之后也会使用支付宝公钥延签,成功后才能确认

(2) 配置支付宝沙箱环境

(3) 环境搭建

导入支付宝sdk

<dependency>
    <groupId>com.alipay.sdkgroupId>
    <artifactId>alipay-sdk-javaartifactId>
    <version>4.9.28.ALLversion>
dependency>

抽取支付工具类并进行配置

成功调用该接口后,返回的数据就是支付页面的html,因此后续会使用@ResponseBody

@ConfigurationProperties(prefix = "alipay")
@Component
@Data
public class AlipayTemplate {

    //在支付宝创建的应用的id
    private   String app_id = "2016102600763190";

    // 商户私钥,您的PKCS8格式RSA2私钥
    private String merchant_private_key = "MjXN6Hnj8k2GAriRFt0BS9gjihbl9Rt38VMNbBi3Vt3Cy6TOwANLLJ/DfnYjRqwCG81fkyKlDqdsamdfCiTysCa0gQKBgQDYQ45LSRxAOTyM5NliBmtev0lbpDa7FqXL0UFgBel5VgA1Ysp0+6ex2n73NBHbaVPEXgNMnTdzU3WF9uHF4Gj0mfUzbVMbj/YkkHDOZHBggAjEHCB87IKowq/uAH/++Qes2GipHHCTJlG6yejdxhOsMZXdCRnidNx5yv9+2JI37QKBgQCw0xn7ZeRBIOXxW7xFJw1WecUV7yaL9OWqKRHat3lFtf1Qo/87cLl+KeObvQjjXuUe07UkrS05h6ijWyCFlBo2V7Cdb3qjq4atUwScKfTJONnrF+fwTX0L5QgyQeDX5a4yYp4pLmt6HKh34sI5S/RSWxDm7kpj+/MjCZgp6Xc51g==";

    // 支付宝公钥,查看地址:https://openhome.alipay.com/platform/keyManage.htm 对应APPID下的支付宝公钥。
    private String alipay_public_key = "MIIBIjA74UKxt2F8VMIRKrRAAAuIMuawIsl4Ye+G12LK8P1ZLYy7ZJpgZ+Wv5nOs3DdoEazgCERj/ON8lM1KBHZOAV+TkrIcyi7cD1gfv4a1usikrUqm8/qhFvoiUfyHJFv1ymT7C4BI6aHzQ2zcUlSQPGoPl4C11tgnSkm3DlH2JZKgaIMcCOnNH+qctjNh9yIV9zat2qUiXbxmrCTtxAmiI3I+eVsUNwvwIDAQAB";

    // 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    // 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
    private  String notify_url="http://**.natappfree.cc/payed/notify";

    // 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //同步通知,支付成功,一般跳转到成功页
    private  String return_url="http://order.gulimall.com/memberOrder.html";

    // 签名方式
    private  String sign_type = "RSA2";

    // 字符编码格式
    private  String charset = "utf-8";

    // 支付宝网关; https://openapi.alipaydev.com/gateway.do
    private  String gatewayUrl = "https://openapi.alipaydev.com/gateway.do";

    public  String pay(PayVo vo) throws AlipayApiException {

        //AlipayClient alipayClient = new DefaultAlipayClient(AlipayTemplate.gatewayUrl, AlipayTemplate.app_id, AlipayTemplate.merchant_private_key, "json", AlipayTemplate.charset, AlipayTemplate.alipay_public_key, AlipayTemplate.sign_type);
        //1、根据支付宝的配置生成一个支付客户端
        AlipayClient alipayClient = new DefaultAlipayClient(gatewayUrl,
                app_id, merchant_private_key, "json",
                charset, alipay_public_key, sign_type);

        //2、创建一个支付请求 //设置请求参数
        AlipayTradePagePayRequest alipayRequest = new AlipayTradePagePayRequest();
        alipayRequest.setReturnUrl(return_url);
        alipayRequest.setNotifyUrl(notify_url);

        //商户订单号,商户网站订单系统中唯一订单号,必填
        String out_trade_no = vo.getOut_trade_no();
        //付款金额,必填
        String total_amount = vo.getTotal_amount();
        //订单名称,必填
        String subject = vo.getSubject();
        //商品描述,可空
        String body = vo.getBody();

        alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\","
                + "\"total_amount\":\""+ total_amount +"\","
                + "\"subject\":\""+ subject +"\","
                + "\"body\":\""+ body +"\","
                + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

        String result = alipayClient.pageExecute(alipayRequest).getBody();

        //会收到支付宝的响应,响应的是一个页面,只要浏览器显示这个页面,就会自动来到支付宝的收银台页面
        System.out.println("支付宝的响应:"+result);

        return result;

    }

(4) 订单支付与同步通知

点击支付跳转到支付接口

@ResponseBody
@GetMapping(value = "/aliPayOrder",produces = "text/html")
public String aliPayOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
    System.out.println("接收到订单信息orderSn:"+orderSn);
    //获取当前订单并设置支付订单相关信息
    PayVo payVo = orderService.getOrderPay(orderSn);
    String pay = alipayTemplate.pay(payVo);
    return pay;
}

@Override
public PayVo getOrderPay(String orderSn) {
    OrderEntity orderEntity = this.getOne(new QueryWrapper<OrderEntity>().eq("order_sn", orderSn));
    PayVo payVo = new PayVo();
    //交易号
    payVo.setOut_trade_no(orderSn);
    //支付金额设置为两位小数,否则会报错
    BigDecimal payAmount = orderEntity.getPayAmount().setScale(2, BigDecimal.ROUND_UP);
    payVo.setTotal_amount(payAmount.toString());

    List<OrderItemEntity> orderItemEntities = orderItemService.list(new QueryWrapper<OrderItemEntity>().eq("order_sn", orderSn));
    OrderItemEntity orderItemEntity = orderItemEntities.get(0);
    //订单名称
    payVo.setSubject(orderItemEntity.getSkuName());
    //商品描述
    payVo.setBody(orderItemEntity.getSkuAttrsVals());
    return payVo;
}

设置成功回调地址为订单详情页

  	// 页面跳转同步通知页面路径 需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
    //同步通知,支付成功,一般跳转到成功页
    private  String return_url="http://order.gulimall.com/memberOrder.html";

	  /**
     * 获取当前用户的所有订单
     * @return
     */
    @RequestMapping("/memberOrder.html")
    public String memberOrder(@RequestParam(value = "pageNum",required = false,defaultValue = "0") Integer pageNum,Model model){
        Map<String, Object> params = new HashMap<>();
        params.put("page", pageNum.toString());
        //分页查询当前用户的所有订单及对应订单项
        PageUtils page = orderService.getMemberOrderPage(params);
        model.addAttribute("pageUtil", page);
        //返回至订单详情页
        return "list";
    }

(5) 异步通知

  • 订单支付成功后支付宝会回调商户接口,这个时候需要修改订单状态
  • 由于同步跳转可能由于网络问题失败,所以使用异步通知
  • 支付宝使用的是最大努力通知方案,保障数据一致性,隔一段时间会通知商户支付成功,直到返回success
1)内网穿透设置异步通知地址
  • 将外网映射到本地的order.gulimall.com:80

  • 由于回调的请求头不是order.gulimall.com,因此nginx转发到网关后找不到对应的服务,所以需要对nginx进行设置

    /payed/notify异步通知转发至订单服务

设置异步通知的地址

// 服务器[异步通知]页面路径  需http://格式的完整路径,不能加?id=123这类自定义参数,必须外网可以正常访问
// 支付宝会悄悄的给我们发送一个请求,告诉我们支付成功的信息
private  String notify_url="http://****.natappfree.cc/payed/notify";
2)验证签名
@PostMapping("/payed/notify")
public String handlerAlipay(HttpServletRequest request, PayAsyncVo payAsyncVo) throws AlipayApiException {
    System.out.println("收到支付宝异步通知******************");
    // 只要收到支付宝的异步通知,返回 success 支付宝便不再通知
    // 获取支付宝POST过来反馈信息
    //TODO 需要验签
    Map<String, String> params = new HashMap<>();
    Map<String, String[]> requestParams = request.getParameterMap();
    for (String name : requestParams.keySet()) {
        String[] values = requestParams.get(name);
        String valueStr = "";
        for (int i = 0; i < values.length; i++) {
            valueStr = (i == values.length - 1) ? valueStr + values[i]
                    : valueStr + values[i] + ",";
        }
        //乱码解决,这段代码在出现乱码时使用
        // valueStr = new String(valueStr.getBytes("ISO-8859-1"), "utf-8");
        params.put(name, valueStr);
    }

    boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),
            alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名

    if (signVerified){
        System.out.println("支付宝异步通知验签成功");
        //修改订单状态
        orderService.handlerPayResult(payAsyncVo);
        return "success";
    }else {
        System.out.println("支付宝异步通知验签失败");
        return "error";
    }
}
3)修改订单状态与保存交易流水
@Override
public void handlerPayResult(PayAsyncVo payAsyncVo) {
    //保存交易流水
    PaymentInfoEntity infoEntity = new PaymentInfoEntity();
    String orderSn = payAsyncVo.getOut_trade_no();
    infoEntity.setOrderSn(orderSn);
    infoEntity.setAlipayTradeNo(payAsyncVo.getTrade_no());
    infoEntity.setSubject(payAsyncVo.getSubject());
    String trade_status = payAsyncVo.getTrade_status();
    infoEntity.setPaymentStatus(trade_status);
    infoEntity.setCreateTime(new Date());
    infoEntity.setCallbackTime(payAsyncVo.getNotify_time());
    paymentInfoService.save(infoEntity);

    //判断交易状态是否成功
    if (trade_status.equals("TRADE_SUCCESS") || trade_status.equals("TRADE_FINISHED")) {
        baseMapper.updateOrderStatus(orderSn, OrderStatusEnum.PAYED.getCode(), PayConstant.ALIPAY);
    }
4) 异步通知的参数
@PostMapping("/payed/notify")
public String handlerAlipay(HttpServletRequest request) {
    System.out.println("收到支付宝异步通知******************");
    Map<String, String[]> parameterMap = request.getParameterMap();
    for (String key : parameterMap.keySet()) {
        String value = request.getParameter(key);
        System.out.println("key:"+key+"===========>value:"+value);
    }
    return "success";
}
收到支付宝异步通知******************
key:gmt_create===========>value:2020-10-18 09:13:26
key:charset===========>value:utf-8
key:gmt_payment===========>value:2020-10-18 09:13:34
key:notify_time===========>value:2020-10-18 09:13:35
key:subject===========>value:华为
key:sign===========>value:aqhKWzgzTLE84Scy5d8i3f+t9f7t7IE5tK/s5iHf3SdFQXPnTt6MEVtbr15ZXmITEo015nCbSXaUFJvLiAhWpvkNEd6ysraa+2dMgotuHPIHnIUFwvdk+U4Ez+2A4DBTJgmwtc5Ay8mYLpHLNR9ASuEmkxxK2F3Ov6MO0d+1DOjw9c/CCRRBWR8NHSJePAy/UxMzULLtpMELQ1KUVHLgZC5yym5TYSuRmltYpLHOuoJhJw8vGkh2+4FngvjtS7SBhEhR1GvJCYm1iXRFTNgP9Fmflw+EjxrDafCIA+r69ZqoJJ2Sk1hb4cBsXgNrFXR2Uj4+rQ1Ec74bIjT98f1KpA==
key:buyer_id===========>value:2088622954825223
key:body===========>value:上市年份:2020;内存:64G
key:invoice_amount===========>value:6300.00
key:version===========>value:1.0
key:notify_id===========>value:2020101800222091334025220507700182
key:fund_bill_list===========>value:[{"amount":"6300.00","fundChannel":"ALIPAYACCOUNT"}]
key:notify_type===========>value:trade_status_sync
key:out_trade_no===========>value:12345523123
key:total_amount===========>value:6300.00
key:trade_status===========>value:TRADE_SUCCESS
key:trade_no===========>value:2020101822001425220501264292
key:auth_app_id===========>value:2016102600763190
key:receipt_amount===========>value:6300.00
key:point_amount===========>value:0.00
key:app_id===========>value:2016102600763190
key:buyer_pay_amount===========>value:6300.00
key:sign_type===========>value:RSA2
key:seller_id===========>value:2088102181115314

各参数详细意义见支付宝开放平台异步通知

(6) 收单

由于可能出现订单已经过期后,库存已经解锁,但支付成功后再修改订单状态的情况,需要设置支付有效时间,只有在有效期内才能进行支付

alipayRequest.setBizContent("{\"out_trade_no\":\""+ out_trade_no +"\","
        + "\"total_amount\":\""+ total_amount +"\","
        + "\"subject\":\""+ subject +"\","
        + "\"body\":\""+ body +"\","
        //设置过期时间为1m
        +"\"timeout_express\":\"1m\","
        + "\"product_code\":\"FAST_INSTANT_TRADE_PAY\"}");

超时后订单显示:“抱歉您的交易因超时已失败”’

十二、秒杀服务

0 定时任务

表达式:https://cron.qqe2.com/

此处定时任务用于定时查询秒杀活动

方法1 注解

基于注解@Scheduled默认为单线程,开启多个任务时,任务的执行时机会受上一个任务执行时间的影响。

@Configuration      //1.主要用于标记配置类,兼备Component的效果。
@EnableScheduling   // 2.开启定时任务
public class SaticScheduleTask {
    //3.添加定时任务
    @Scheduled(cron = "0/5 * * * * ?")
    //或直接指定时间间隔,例如:5秒
    //@Scheduled(fixedRate=5000)
    private void configureTasks() {
        System.err.println("执行静态定时任务时间: " + LocalDateTime.now());
    }
}

Cron表达式参数分别表示:

秒(0~59) 例如0/5表示每5秒
分(0~59)
时(0~23)
日(0~31)的某天,需计算
月(0~11)
周几( 可填1-7 或 SUN/MON/TUE/WED/THU/FRI/SAT)
@Scheduled:除了支持灵活的参数表达式cron之外,还支持简单的延时操作,例如 fixedDelay ,fixedRate 填写相应的毫秒数即可。
// Cron表达式范例:

每隔5秒执行一次:*/5 * * * * ?

每隔1分钟执行一次:0 */1 * * * ?

每天23点执行一次:0 0 23 * * ?

每天凌晨1点执行一次:0 0 1 * * ?

每月1号凌晨1点执行一次:0 0 1 1 * ?

每月最后一天23点执行一次:0 0 23 L * ?

每周星期天凌晨1点实行一次:0 0 1 ? * L

在26分、29分、33分执行一次:0 26,29,33 * * * ?

每天的0点、13点、18点、21点都执行一次:0 0 0,13,18,21 * * ?

显然,使用@Scheduled 注解很方便,但缺点是当我们调整了执行周期的时候,需要重启应用才能生效,这多少有些不方便。为了达到实时生效的效果,可以使用接口来完成定时任务。

方法2 接口
<parent>
    <groupId>org.springframework.bootgroupId>
    <artifactId>spring-boot-starterartifactId>
    <version>2.0.4.RELEASEversion>
parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-webartifactId>
    dependency>
    <dependency>
        <groupId>mysqlgroupId>
        <artifactId>mysql-connector-javaartifactId>
    dependency>
    <dependency>
        <groupId>org.mybatis.spring.bootgroupId>
        <artifactId>mybatis-spring-boot-starterartifactId>
        <version>1.3.1version>
    dependency>
    <dependency>
        <groupId>org.mybatisgroupId>
        <artifactId>mybatisartifactId>
        <version>3.4.5version>
        <scope>compilescope>
    dependency>
dependencies>
DROP DATABASE IF EXISTS `socks`;
CREATE DATABASE `socks`;
USE `SOCKS`;
DROP TABLE IF EXISTS `cron`;
CREATE TABLE `cron`  (
  `cron_id` varchar(30) NOT NULL PRIMARY KEY,
  `cron` varchar(30) NOT NULL  
);
INSERT INTO `cron` VALUES ('1', '0/5 * * * * ?');
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/socks
    username: root
    password: 123456
@Configuration      //1.主要用于标记配置类,兼备Component的效果。
@EnableScheduling   // 2.开启定时任务
public class DynamicScheduleTask implements SchedulingConfigurer {

    @Mapper
    public interface CronMapper {
        @Select("select cron from cron limit 1")
        public String getCron();
    }

    @Autowired      //注入mapper
    @SuppressWarnings("all")
    CronMapper cronMapper;

    /**
     * 执行定时任务.
     */
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {

        taskRegistrar.addTriggerTask(
                //1.添加任务内容(Runnable)
                () -> System.out.println("执行动态定时任务: " + LocalDateTime.now().toLocalTime()),
                //2.设置执行周期(Trigger)
                triggerContext -> {
                    //2.1 从数据库获取执行周期
                    String cron = cronMapper.getCron();
                    //2.2 合法性校验.
                    if (StringUtils.isEmpty(cron)) {
                        // Omitted Code ..
                    }
                    //2.3 返回执行周期(Date)
                    return new CronTrigger(cron).nextExecutionTime(triggerContext);
                }
        );
    }

}

多线程定时任务

//@Component注解用于对那些比较中立的类进行注释;
//相对与在持久层、业务层和控制层分别采用 @Repository、@Service 和 @Controller 对分层中的类进行注释
@Component
@EnableScheduling   // 1.开启定时任务
@EnableAsync        // 2.开启多线程
public class MultithreadScheduleTask {

    @Async
    @Scheduled(fixedDelay = 1000)  //间隔1秒
    public void first() throws InterruptedException {
        System.out.println("第一个定时任务开始 : " + LocalDateTime.now().toLocalTime() + "\r\n线程 : " + Thread.currentThread().getName());
        System.out.println();
        Thread.sleep(1000 * 10);
    }

    @Async
    @Scheduled(fixedDelay = 2000)
    public void second() {
        System.out.println("第二个定时任务开始 : " + LocalDateTime.now().toLocalTime() + "\r\n线程 : " + Thread.currentThread().getName());
        System.out.println();
    }
}

实战

@Slf4j
// 开启异步任务
//@EnableAsync
//@EnableScheduling
//@Component
public class HelloSchedule {

	/**
	 * 在Spring中 只允许6位 [* * * ? * 1] : 每周一每秒执行一次
	 * 						[* /5 * * ? * 1] : 每周一 每5秒执行一次
	 * 	方法1.定时任务不应阻塞 [默认是阻塞的]
	 * 	方法2.定时任务线程池 
	 *  定时任务自动配置类TaskScheduling    spring.task.scheduling.pool.size=5  自动配置类
	 * 	方法3.让定时任务异步执行   
	 *  异步任务自动配置类 TaskExecutionAutoConfiguration
	 */
	@Async
	@Scheduled(cron = "*/5 * * ? * 1")
	public void hello(){
		log.info("定时任务...");
		try {
			Thread.sleep(3000);
		} catch (InterruptedException e) { }
	}
}

这么做是spring的定时任务

1. 秒杀系统关注的问题

  • 1、服务单一职责+独立部署
    • 秒杀服务即使自己扛不住压力,挂掉。不要影响别人
  • 2、秒杀链接加密
    • 防止恶意攻击,模拟秒杀请求,1000次/s攻击
    • 防止链接暴露,自己工作人员,提前秒杀商品。
  • 3、库存预热+快速扣减
    • 秒杀读多写少。无需每次实时校验库存。我们库存预热,放到redis中。信号量控制进来秒杀的请求
  • 4、动静分离
    • nginx做好动静分离。保证秒杀和商品详情页的动态请求才打到后端的服务集群。
      使用cDN网络,分担本集群压力
  • 5、恶意请求拦截
    • 识别非法攻击请求并进行拦截,网关层
  • 6、流量错峰
    • 使用各种手段,将流量分担到更大宽度的时间点。比如验证码,加入购物车
  • 7、限流&熔断&降级
    • 前端限流+后端限流
    • 限制次数,限制总量,快速失败降级运行,熔断隔离防止雪崩
  • 8、队列削峰
    • 1万个商品,每个1000件秒杀。双11所有秒杀成功的请求,进入队列,慢慢创建订单,扣减库存即可。

后vue的后台管理系统里上架秒杀,打开F12看url然后去编写逻辑

  • 秒杀名称
  • 开始时间
  • 结束时间
  • 启用状态

点击关联商品可以添加秒杀里的商品。可以看sms数据库里的seckill_sky

2. 秒杀架构设计

(1) 秒杀架构

nginx–>gateway–>redis分布式信号了–> 秒杀服务

  • 项目独立部署,独立秒杀模块gulimall-seckill
  • 使用定时任务每天三点上架最新秒杀商品,削减高峰期压力
  • 秒杀链接加密,为秒杀商品添加唯一商品随机码,在开始秒杀时才暴露接口
  • 库存预热,先从数据库中扣除一部分库存以redisson 信号量的形式存储在redis中
  • 队列削峰,秒杀成功后立即返回,然后以发送消息的形式创建订单

秒杀活动:存在在scekill:sesssions这个redis-key里,。value为 skyIds[]

秒杀活动里具体商品项:是一个map,redis-key是seckill:skusmap-key是skuId+商品随机码

(2) redis存储模型设计

【谷粒商城】分布式事务与下单_第10张图片

  • 秒杀场次存储的List可以当做hash keySECKILL_CHARE_PREFIX中获得对应的商品数据

  • 随机码防止人在秒杀一开放就秒杀完,必须携带上随机码才能秒杀

  • 结束时间

  • 设置分布式信号量,这样就不会每个请求都访问数据库。seckill:stock:#(商品随机码)

  • session里存了session-sku[]的列表,而seckill:skus的key也是session-sku,不要只存储sku,不能区分场次

  • 存储后的效果

//存储的秒杀场次对应数据
//K: SESSION_CACHE_PREFIX + startTime + "_" + endTime
//V: sessionId+"-"+skuId的List
private final String SESSION_CACHE_PREFIX = "seckill:sessions:";

//存储的秒杀商品数据
//K: 固定值SECKILL_CHARE_PREFIX
//V: hash,k为sessionId+"-"+skuId,v为对应的商品信息SeckillSkuRedisTo
private final String SECKILL_CHARE_PREFIX = "seckill:skus";

//K: SKU_STOCK_SEMAPHORE+商品随机码
//V: 秒杀的库存件数
private final String SKU_STOCK_SEMAPHORE = "seckill:stock:";    //+商品随机码

用来存储的to

@Data
public class SeckillSkuRedisTo { // 秒杀sku项
    private Long id;
    /**
     * 活动id
     */
    private Long promotionId;
    /**
     * 活动场次id
     */
    private Long promotionSessionId;
    /**
     * 商品id
     */
    private Long skuId;
    /**
     * 秒杀价格
     */
    private BigDecimal seckillPrice;
    /**
     * 秒杀总量
     */
    private Integer seckillCount;
    /**
     * 每人限购数量
     */
    private Integer seckillLimit;
    /**
     * 排序
     */
    private Integer seckillSort;
    //以上都为SeckillSkuRelationEntity的属性

    //skuInfo
    private SkuInfoVo skuInfoVo;

    //当前商品秒杀的开始时间
    private Long startTime;

    //当前商品秒杀的结束时间
    private Long endTime;

    //当前商品秒杀的随机码
    private String randomCode;
}

3. 商品上架

(1) 定时上架

  • 开启对定时任务的支持

    @EnableAsync //开启对异步的支持,防止定时任务之间相互阻塞
    @EnableScheduling //开启对定时任务的支持
    @Configuration
    public class ScheduledConfig {
    }
    
  • 每天凌晨三点远程调用coupon服务上架最近三天的秒杀商品

  • 由于在分布式情况下该方法可能同时被调用多次,因此加入分布式锁,同时只有一个服务可以调用该方法

  • 上架后无需再次上架,用分布式锁做好幂等性

//秒杀商品上架功能的锁
private final String upload_lock = "seckill:upload:lock";

/**
     * 定时任务
     * 每天三点上架最近三天的秒杀商品
     */
@Async
@Scheduled(cron = "0 0 3 * * ?")
public void uploadSeckillSkuLatest3Days() {
    //为避免分布式情况下多服务同时上架的情况,使用分布式锁
    RLock lock = redissonClient.getLock(upload_lock);// "seckill:upload:lock";
    try {
        lock.lock(10, TimeUnit.SECONDS);//锁住
        secKillService.uploadSeckillSkuLatest3Days();
    }catch (Exception e){
        e.printStackTrace();
    }finally {
        lock.unlock();// 解锁
    }
}

@Override // SeckillServiceImpl
public void uploadSeckillSkuLatest3Days() {
    R r = couponFeignService.getSeckillSessionsIn3Days();
    if (r.getCode() == 0) {
        List<SeckillSessionWithSkusVo> sessions = r.getData(new TypeReference<List<SeckillSessionWithSkusVo>>() {
        });
        //在redis中分别保存秒杀场次信息和场次对应的秒杀商品信息
        saveSecKillSession(sessions);// 会判断有没有加错
        saveSecKillSku(sessions);
    }
}
@FeignClient("gulimall-coupon")
public interface CouponFeignService {

	@GetMapping("/coupon/seckillsession/lates3DaySession")
	R getLate3DaySession();
}

(2) 获取最近三天的秒杀信息

  • 获取最近三天的秒杀场次信息,再通过秒杀场次id查询对应的商品信息
  • 防止集群多次上架
@Override // SeckillSessionServiceImpl
public List<SeckillSessionEntity> getLate3DaySession() {
    // 获取最近3天的秒杀活动
    List<SeckillSessionEntity> list = this.list(
        new QueryWrapper<SeckillSessionEntity>()
        .between("start_time", startTime(), endTime()));
    // 设置秒杀活动里的秒杀商品
    if(list != null && list.size() > 0){
        return list.stream().map(session -> {
            // 给每一个活动写入他们的秒杀项
            Long id = session.getId();
            // 根据活动id获取每个sku项
            List<SeckillSkuRelationEntity> entities = 
                skuRelationService.list(new QueryWrapper<SeckillSkuRelationEntity>().eq("promotion_session_id", id));
            session.setRelationSkus(entities);
            return session;
        }).collect(Collectors.toList());
    }
    return null;
}

//当前天数的 00:00:00
private String getStartTime() {
    LocalDate now = LocalDate.now();
    LocalDateTime time = now.atTime(LocalTime.MIN);
    String format = time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    return format;
}

//当前天数+2 23:59:59..
private String getEndTime() {
    LocalDate now = LocalDate.now();
    LocalDateTime time = now.plusDays(2).atTime(LocalTime.MAX);
    String format = time.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
    return format;
}

(3) Redis保存秒杀场次信息

private void saveSessionInfo(List<SeckillSessionsWithSkus> sessions){
    if(sessions != null){
        sessions.stream().forEach(session -> {
            long startTime = session.getStartTime().getTime();

            long endTime = session.getEndTime().getTime();
            String key = SESSION_CACHE_PREFIX + startTime + "_" + endTime; // "seckill:sessions:";
            Boolean hasKey = stringRedisTemplate.hasKey(key);
            // 防止重复添加活动到redis中
            if(!hasKey){
                // 获取所有商品id // 格式:活动id-skuId
                List<String> skus = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "-" + item.getSkuId()).collect(Collectors.toList());
                // 缓存活动信息
                stringRedisTemplate.opsForList().leftPushAll(key, skus);
            }
        });
    }
}

(4) redis保存秒杀商品信息

前面已经缓存了sku项的活动信息,但是只有活动id和skuID,接下来我们要保存完整是sku信息到redis中

private void saveSessionSkuInfo(List<SeckillSessionsWithSkus> sessions){
    if(sessions != null){
        // 遍历session
        sessions.stream().forEach(session -> {
            BoundHashOperations<String, Object, Object> ops =
                stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX); // "seckill:skus:";
            // 遍历sku
            session.getRelationSkus().stream().forEach(seckillSkuVo -> {
                // 1.商品的随机码
                String randomCode = UUID.randomUUID().toString().replace("-", "");
                // 缓存中没有再添加
                if(!ops.hasKey(seckillSkuVo.getPromotionSessionId() + "-" + seckillSkuVo.getSkuId())){
                    // 2.缓存商品
                    SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
                    BeanUtils.copyProperties(seckillSkuVo, redisTo);
                    // 3.sku的基本数据 sku的秒杀信息
                    R info = productFeignService.skuInfo(seckillSkuVo.getSkuId());
                    if(info.getCode() == 0){
                        SkuInfoVo skuInfo = info.getData("skuInfo", new TypeReference<SkuInfoVo>() {});
                        redisTo.setSkuInfoVo(skuInfo);
                    }
                    // 4.设置当前商品的秒杀信息
                    redisTo.setStartTime(session.getStartTime().getTime());
                    redisTo.setEndTime(session.getEndTime().getTime());
                    // 设置随机码
                    redisTo.setRandomCode(randomCode);
                    // 活动id-skuID   秒杀sku信息
                    ops.put(seckillSkuVo.getPromotionSessionId() + "-" + seckillSkuVo.getSkuId(), JSON.toJSONString(redisTo));
                    // 5.使用库存作为分布式信号量  限流
                    RSemaphore semaphore = 
                        redissonClient.getSemaphore(SKUSTOCK_SEMAPHONE + randomCode);//"seckill:stock:";
                    // 在信号量中设置秒杀数量
                    semaphore.trySetPermits(seckillSkuVo.getSeckillCount().intValue());
                }
            });
        });
    }

4. 获取当前秒杀商品

前面已经在redis中缓存了秒杀活动的各种信息,现在写获取缓存中当前时间段在秒杀的sku,用户点击页面后发送请求

@GetMapping(value = "/getCurrentSeckillSkus")
@ResponseBody // 用户网页发请求
public R getCurrentSeckillSkus() {
    //获取到当前可以参加秒杀商品的信息
    List<SeckillSkuRedisTo> vos = secKillService.getCurrentSeckillSkus();

    return R.ok().setData(vos);
}

@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {

    // 1.确定当前时间属于那个秒杀场次
    long time = new Date().getTime();
    // 定义一段受保护的资源
    try (Entry entry = SphU.entry("seckillSkus")){
        Set<String> keys = stringRedisTemplate.keys(SESSION_CACHE_PREFIX + "*");
        for (String key : keys) {
            // seckill:sessions:1593993600000_1593995400000
            String replace = key.replace("seckill:sessions:", "");
            String[] split = replace.split("_");
            long start = Long.parseLong(split[0]);
            long end = Long.parseLong(split[1]);
            if(time >= start && time <= end){
                // 2.获取这个秒杀场次的所有商品信息
                List<String> range = stringRedisTemplate.opsForList().range(key, 0, 100);
                BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
                List<String> list = hashOps.multiGet(range);
                if(list != null){
                    return list.stream().map(item -> {
                        SeckillSkuRedisTo redisTo = JSON.parseObject(item, SeckillSkuRedisTo.class);
                        //						redisTo.setRandomCode(null);
                        return redisTo;
                    }).collect(Collectors.toList());
                }
                break;
            }
        }
    }catch (BlockException e){
        log.warn("资源被限流:" + e.getMessage());
    }
    return null;
}

首页获取并拼装数据

<div class="swiper-slide">
  
  <ul id="seckillSkuContent">ul>
div>

<script type="text/javascript">
  $.get("http://seckill.gulimall.com/getCurrentSeckillSkus", function (res) {
    if (res.data.length > 0) {
      res.data.forEach(function (item) {
        $("
  • "
    ).append($("")) .append($("

    "+item.skuInfoVo.skuTitle+"

    "
    )) .append($("" + item.seckillPrice + "")) .append($("" + item.skuInfoVo.price + ""
    )) .appendTo("#seckillSkuContent"); }) } }) function toDetail(skuId) { location.href = "http://item.gulimall.com/" + skuId + ".html"; }
    script>

    5. 获取当前商品的秒杀信息

    用户看到秒杀活动点击秒杀商品了,如果时间段正确,返回随机码。购买时带着

    • 注意不要redis-map中的key
    @ResponseBody
    @GetMapping(value = "/getSeckillSkuInfo/{skuId}")
    public R getSeckillSkuInfo(@PathVariable("skuId") Long skuId) {
        SeckillSkuRedisTo to = secKillService.getSeckillSkuInfo(skuId);
        return R.ok().setData(to);
    }
    
    @Override
    public SeckillSkuRedisTo getSeckillSkuInfo(Long skuId) {
        BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
        //获取所有商品的hash key
        Set<String> keys = ops.keys();
        for (String key : keys) {
            //通过正则表达式匹配 数字-当前skuid的商品
            if (Pattern.matches("\\d-" + skuId,key)) {
                String v = ops.get(key);
                SeckillSkuRedisTo redisTo = JSON.parseObject(v, SeckillSkuRedisTo.class);
                //当前商品参与秒杀活动
                if (redisTo!=null){
                    long current = System.currentTimeMillis();
                    //当前活动在有效期,暴露商品随机码返回
                    if (redisTo.getStartTime() < current && redisTo.getEndTime() > current) {
                        return redisTo;
                    }
                    //当前商品不再秒杀有效期,则隐藏秒杀所需的商品随机码
                    redisTo.setRandomCode(null);
                    return redisTo;
                }
            }
        }
        return null;
    }
    

    在查询商品详情页的接口中查询秒杀对应信息

    @Override // SkuInfoServiceImpl
    public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {
        ....;
        // 6.查询当前sku是否参与秒杀优惠
        CompletableFuture<Void> secKillFuture = CompletableFuture.runAsync(() -> {
            R skuSeckillInfo = seckillFeignService.getSkuSeckillInfo(skuId);
            if (skuSeckillInfo.getCode() == 0) {
                SeckillInfoVo seckillInfoVo = skuSeckillInfo.getData(new TypeReference<SeckillInfoVo>() {});
                skuItemVo.setSeckillInfoVo(seckillInfoVo);
            }
        }, executor);
    

    注意所有的时间都是距离1970的差值

    更改商品详情页的显示效果

    <li style="color: red" th:if="${item.seckillSkuVo != null}">
    
        <span th:if="${#dates.createNow().getTime() < item.seckillSkuVo.startTime}">
            商品将会在[[${#dates.format(new java.util.Date(item.seckillSkuVo.startTime),"yyyy-MM-dd HH:mm:ss")}]]进行秒杀
        span>
    
        <span th:if="${#dates.createNow().getTime() >= item.seckillSkuVo.startTime && #dates.createNow().getTime() <= item.seckillSkuVo.endTime}">
            秒杀价  [[${#numbers.formatDecimal(item.seckillSkuVo.seckillPrice,1,2)}]]
        span>
    
    li>
    
    <div class="box-btns-two"
         th:if="${item.seckillSkuVo == null }">
        <a class="addToCart" href="http://cart.gulimall.com/addToCart" th:attr="skuId=${item.info.skuId}">
            加入购物车
        a>
    div>
    
    <div class="box-btns-two"
         th:if="${item.seckillSkuVo != null && (#dates.createNow().getTime() >= item.seckillSkuVo.startTime && #dates.createNow().getTime() <= item.seckillSkuVo.endTime)}">
        <a class="seckill" href="#"
           th:attr="skuId=${item.info.skuId},sessionId=${item.seckillSkuVo.promotionSessionId},code=${item.seckillSkuVo.randomCode}">
            立即抢购
        a>
    div>
    

    6. 秒杀最终处理

    【谷粒商城】分布式事务与下单_第11张图片【谷粒商城】分布式事务与下单_第12张图片

    (1) 秒杀业务

    • 点击立即抢购时,会发送请求
    • 秒杀请求会对请求校验时效、商品随机码、当前用户是否已经抢购过当前商品、库存和购买量,通过校验的则秒杀成功,发送消息创建订单

    秒杀按钮:

    <div class="box-btns-two"
         th:if="${item.seckillInfoVo != null && (#dates.createNow().getTime() >= item.seckillInfoVo.startTime && #dates.createNow().getTime() <= item.seckillInfoVo.endTime)}">
        <a id="secKillA"
           th:attr="skuId=${item.info.skuId},sessionId=${item.seckillInfoVo.promotionSessionId},code=${item.seckillInfoVo.randomCode}">
            立即抢购
        a>
    div>
    <div class="box-btns-two"
         th:if="${item.seckillInfoVo == null || (#dates.createNow().getTime() < item.seckillInfoVo.startTime || #dates.createNow().ge`tTime() > item.seckillInfoVo.endTime)}">
        <a id="addToCartA" th:attr="skuId=${item.info.skuId}">
            加入购物车
        a>
    div>
    

    秒杀函数:

    $("#secKillA").click(function () {
        var isLogin = [[${session.loginUser != null}]]
        if (isLogin) {
            var killId = $(this).attr("sessionid") + "-" + $(this).attr("skuid");
            var num = $("#numInput").val();
            location.href = "http://seckill.gulimall.com/kill?killId=" + killId + "&key=" + $(this).attr("code") + "&num=" + num;
        } else {
            layer.msg("请先登录!")
        }
        return false;
    })
    

    【谷粒商城】分布式事务与下单_第13张图片

    秒杀方案:

    【谷粒商城】分布式事务与下单_第14张图片

    消息队列:

    【谷粒商城】分布式事务与下单_第15张图片

    【谷粒商城】分布式事务与下单_第16张图片

    秒杀controller:

    @GetMapping("/kill")
    public String secKill(@RequestParam("killId") String killId, // session_skuID
                          @RequestParam("key") String key,
                          @RequestParam("num") Integer num, Model model){
        String orderSn = seckillService.kill(killId,key,num);
        // 1.判断是否登录
        model.addAttribute("orderSn", orderSn);
        return "success";
    }
    
    秒杀service:创建订单、发消息
    @Override
    public String kill(String killId, String key, Integer num) {
    
        MemberRespVo MemberRespVo = LoginUserInterceptor.threadLocal.get();
    
        // 1.获取当前秒杀商品的详细信息
        BoundHashOperations<String, String, String> hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
        String json = hashOps.get(killId);
        if(StringUtils.isEmpty(json)){
            return null;
        }else{
            SeckillSkuRedisTo redisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
            // 校验合法性
            long time = new Date().getTime();
            if(time >= redisTo.getStartTime() && time <= redisTo.getEndTime()){
                // 1.校验随机码跟商品id是否匹配
                String randomCode = redisTo.getRandomCode();
                String skuId = redisTo.getPromotionSessionId() + "-" + redisTo.getSkuId();
    
                if(randomCode.equals(key) && killId.equals(skuId)){
                    // 2.说明数据合法
                    BigDecimal limit = redisTo.getSeckillLimit();
                    if(num <= limit.intValue()){
                        // 3.验证这个人是否已经购买过了
                        String redisKey = MemberRespVo.getId() + "-" + skuId;
                        // 让数据自动过期
                        long ttl = redisTo.getEndTime() - redisTo.getStartTime();
    
                        Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl<0?0:ttl, TimeUnit.MILLISECONDS);
                        if(aBoolean){
                            // 占位成功 说明从来没买过// 前面的逻辑是acquireSemaphore(S
                            RSemaphore semaphore = redissonClient.getSemaphore(SKUSTOCK_SEMAPHONE + randomCode);
                            boolean acquire = semaphore.tryAcquire(num);// acquire阻塞  tryAcquire非阻塞
                            if(acquire){
                                // 秒杀成功
                                // 快速下单 发送MQ
                                String orderSn = IdWorker.getTimeId() + UUID.randomUUID().toString().replace("-","").substring(7,8);
                                SecKillOrderTo orderTo = new SecKillOrderTo();
                                orderTo.setOrderSn(orderSn);
                                orderTo.setMemberId(MemberRespVo.getId());
                                orderTo.setNum(num);
                                orderTo.setSkuId(redisTo.getSkuId());
                                orderTo.setSeckillPrice(redisTo.getSeckillPrice());
                                orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
                                rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order", orderTo);
                                return orderSn;
                            }
                        }else {
                            return null;
                        }
                    }
                }else{
                    return null;
                }
            }else{
                return null;
            }
        }
        return null;
    }
    

    (2) 消息队列

    【谷粒商城】分布式事务与下单_第17张图片

    创建秒杀所需队列

    /**
         * 商品秒杀队列
     */
    @Bean
    public Queue orderSecKillOrrderQueue() {
        Queue queue = new Queue("order.seckill.order.queue", true, false, false);
        return queue;
    }
    
    @Bean
    public Binding orderSecKillOrrderQueueBinding() {
        //String destination, DestinationType destinationType, String exchange, String routingKey,
        // 			Map arguments
        Binding binding = new Binding(
            "order.seckill.order.queue",
            Binding.DestinationType.QUEUE,
            "order-event-exchange",
            "order.seckill.order",
            null);
    
        return binding;
    }
    

    (3) 接收消息

    监听队列

    @Component
    @RabbitListener(queues = "order.seckill.order.queue")
    public class SeckillOrderListener {
        @Autowired
        private OrderService orderService;
    
        @RabbitHandler
        public void createOrder(SeckillOrderTo orderTo, Message message, Channel channel) throws IOException {
            System.out.println("***********接收到秒杀消息");
            long deliveryTag = message.getMessageProperties().getDeliveryTag();
            try {
                orderService.createSeckillOrder(orderTo);
                channel.basicAck(deliveryTag, false);
            } catch (Exception e) {
                channel.basicReject(deliveryTag,true);
            }
        }
    }
    

    创建订单

    @Transactional
    @Override
    public void createSeckillOrder(SeckillOrderTo orderTo) {
        MemberResponseVo memberResponseVo = LoginInterceptor.loginUser.get();
        //1. 创建订单
        OrderEntity orderEntity = new OrderEntity();
        orderEntity.setOrderSn(orderTo.getOrderSn());
        orderEntity.setMemberId(orderTo.getMemberId());
        orderEntity.setMemberUsername(memberResponseVo.getUsername());
        orderEntity.setStatus(OrderStatusEnum.CREATE_NEW.getCode());
        orderEntity.setCreateTime(new Date());
        orderEntity.setPayAmount(orderTo.getSeckillPrice().multiply(new BigDecimal(orderTo.getNum())));
        this.save(orderEntity);
        //2. 创建订单项
        R r = productFeignService.info(orderTo.getSkuId());
        if (r.getCode() == 0) {
            SeckillSkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SeckillSkuInfoVo>() {
            });
            OrderItemEntity orderItemEntity = new OrderItemEntity();
            orderItemEntity.setOrderSn(orderTo.getOrderSn());
            orderItemEntity.setSpuId(skuInfo.getSpuId());
            orderItemEntity.setCategoryId(skuInfo.getCatalogId());
            orderItemEntity.setSkuId(skuInfo.getSkuId());
            orderItemEntity.setSkuName(skuInfo.getSkuName());
            orderItemEntity.setSkuPic(skuInfo.getSkuDefaultImg());
            orderItemEntity.setSkuPrice(skuInfo.getPrice());
            orderItemEntity.setSkuQuantity(orderTo.getNum());
            orderItemService.save(orderItemEntity);
        }
    }
    

    十三、Sentinel服务流控、熔断和降级

    个人之前的笔记:拉到最后就可以看到sentinel的内容:https://blog.csdn.net/hancoder/article/details/109063671

    1. 环境搭建

    导入依赖

    
    
    <dependency>
        <groupId>com.alibaba.cloudgroupId>
        <artifactId>spring-cloud-starter-alibaba-sentinelartifactId>
        <version>${spring-cloud-alibaba.version}version>
    dependency>
    <dependency>
        <groupId>org.springframework.bootgroupId>
        <artifactId>spring-boot-starter-actuatorartifactId>
        <version>${spring-cloud-alibaba.version}version>
    dependency>
    
    
    

    application.properties

    # 3项sentinel配置
    # sentinel控制台地址
    spring.cloud.sentinel.transport.dashboard=localhost:8333
    spring.cloud.sentinel.transport.port=8719
    # 暴露所有监控端点,使得sentinel可以实时监控
    management.endpoints.web.exposure.include=*
    
    sentinel服务端启动

    为了项目大小,我们的sentinel服务端还是不采用jar包,采用自己编译源码,dashboard的启动在sentinel-dashboard包下。(注意在project structure中import module)。在application.properties里添加server.port=8333

    sentinel-cluster-server-envoy-rls项目有些错误,需要在pom.xml文件中添加如下依赖:

    
    <dependency>
        <groupId>io.envoyproxy.controlplanegroupId>
        <artifactId>serverartifactId>
        <version>0.1.23version>
    dependency>
    

    还有个test错误,无关紧要,我直接在structure project里去掉test包

    2、sentinel流量规则解释

    • 资源名:唯一名称,默认请求路径

    • 针对来源:sentinel可以针对调用者进行限流,填写微服务名,默认default(不区分来源)

    • 阈值类型/单机值:

      • QPS(每秒钟的请求数量):当调用该api就QPS达到阈值的时候,进行限流
      • 线程数.当调用该api的线程数达到阈值的时候,进行限流
    • 单机/均摊阈值:和下面的选项有关

    • 集群阈值模式:

      • 单机均摊:前面设置的阈值是每台机器的阈值
      • 总体阈值:前面设置的阈值是集群总体的阈值
    • 流控模式:

      • 直接:api达到限流条件时,直接限流。分为QPS和线程数
      • 关联:当关联的资到阈值时,就限流自己。别人惹事,自己买单。当两个资源之间具有资源争抢或者依赖关系的时候,这两个资源便具有了关联。,举例来说,read_dbwrite_db 这两个资源分别代表数据库读写,我们可以给 read_db 设置限流规则来达到写优先的目的:设置 strategyRuleConstant.STRATEGY_RELATE 同时设置 refResourcewrite_db。这样当写库操作过于频繁时,读数据的请求会被限流。
      • 链路:只记录指定链路上的流量(指定资源从入口资源进来的流量,如果达到阈值,就进行限流)【api级别的针对来源】
    • 流控效果:

      • 快速失败:直接拒绝。当QPS超过任意规则的阈值后,新的请求就会被立即拒绝,拒绝方式为抛出FlowException
      • warm up:若干秒后才能达到阈值。当系统长期处于低水位的情况下,当流量突然增加时,直接把系统拉升到高水位可能瞬间把系统压垮。通过"冷启动",让通过的流量缓慢增加,在一定时间内逐渐增加到阈值上限,给冷系统一个预热的时间,避免冷系统被压垮
      • 排队等待:让请求以均匀的速度通过
        【谷粒商城】分布式事务与下单_第18张图片

    3、自定义被限流响应

    设置被限流后看到的页面

    @Component
    public class GulimallSentinelConfig 
        implements UrlBlockHandler{
        @Override
        public void blocked(HttpServletRequest request, 
                            HttpServletResponse response, 
                            BlockException ex) throws IOException {
            R r = R.error(BizCodeEnum.SECKILL_EXCEPTION.getCode(),BizCodeEnum.SECKILL_EXCEPTION.getMsg());
            response.setContentType("application/json;charset=utf-8");
            response.getWriter().write(JSON.toJSONString(r)+"servlet用法");
        }
    }
    

    4、网关流控

    https://hub.fastgit.org/alibaba/Sentinel/wiki/网关限流

    如果能在网关层就进行流控,可以避免请求流入业务,减小服务压力

    
    <dependency>
        <groupId>com.alibaba.cloudgroupId>
        <artifactId>spring-cloud-alibaba-sentinel-gatewayartifactId>
        <version>2.1.0.RELEASEversion>
    dependency>
    

    5、 feign的流控和降级

    默认情况下,sentinel是不会对feign远程调用进行监控的,需要开启配置

    feign:
      sentinel:
        enabled: true
    

    feign的降级,在远程调用的类配置限流处理规则

    在product中配置

    @FeignClient设置fallback属性

    @FeignClient(value = "gulimall-seckill",
                 fallback = SeckillFallbackService.class) // 被限流后的处理类
    public interface SeckillFeignService {
        @ResponseBody
        @GetMapping(value = "/getSeckillSkuInfo/{skuId}")
        R getSeckillSkuInfo(@PathVariable("skuId") Long skuId);
    }
    

    在降级类中实现对应的feign接口,并重写降级方法

    @Component
    public class SeckillFallbackService implements SeckillFeignService {
        @Override
        public R getSeckillSkuInfo(Long skuId) {
            return R.error(BizCodeEnum.READ_TIME_OUT_EXCEPTION.getCode(), BizCodeEnum.READ_TIME_OUT_EXCEPTION.getMsg());
        }
    }
    

    降级效果

    当远程服务被限流或者不可用时,会触发降级效

    十四、Zipkin链路追踪

    由于微服务项目模块众多,相互之间的调用关系十分复杂,因此为了分析工作过程中的调用关系,需要使用zipkin来进行链路追踪

    1. 环境搭建

    下载jar包并运行

    https://dl.bintray.com/openzipkin/maven/io/zipkin/java/zipkin-server/

    导入依赖

    
    <dependency>
        <groupId>org.springframework.cloudgroupId>
        <artifactId>spring-cloud-starter-zipkinartifactId>
    dependency>
    

    配置

    spring:
        zipkin:
          base-url: http://localhost:9411
          sender:
            type: web
          # 取消nacos对zipkin的服务发现
          discovery-client-enabled: false
        #采样取值介于 0到1之间,1则表示全部收集
        sleuth:
          sampler:
            probability: 1
    

    2. 查询调用链路

    【谷粒商城】分布式事务与下单_第19张图片

    其中可以看到请求的方式,请求时间,异步等信息

    【谷粒商城】分布式事务与下单_第20张图片

    3. 查询依赖

    【谷粒商城】分布式事务与下单_第21张图片

    笔记不易:

    离线笔记均为markdown格式,图片也是云图,10多篇笔记20W字,压缩包仅500k,推荐使用typora阅读。也可以自己导入有道云笔记等软件中

    点个毫无卵用的赞吧~

    你可能感兴趣的:(谷粒项目笔记)