目录
分布式事务与下单
一、gulimall-cart
二、购物车
1、购物车需求
2、购物车VO
3、 ThreadLocal用户身份鉴别
3. 添加商品到购物车
4. 展示购物车
5. 选中购物车项
6. 修改购物项数量
7. 删除购物车项
三、消息队列
四、Session共享
五、订单模型
六、服务通信数据共享问题
订单登录拦截
七、订单确认页
八、接口幂等性讨论
九、订单提交
十、分布式事务
seata解决分布式事务问题
消息队列实现最终一致性
十一、支付
十二、秒杀服务
0 定时任务
1. 秒杀系统关注的问题
2. 秒杀架构设计
3. 商品上架
4. 获取当前秒杀商品
5. 获取当前商品的秒杀信息
6. 秒杀最终处理
十三、Sentinel服务流控、熔断和降级
1. 环境搭建
2、sentinel流量规则解释
3、自定义被限流响应
4、网关流控
5、 feign的流控和降级
十四、Zipkin链路追踪
1. 环境搭建
2. 查询调用链路
3. 查询依赖
版权
笔记-基础篇-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的内容。请点击标题下面分栏查看系列笔记
声明:
sql:https://github.com/FermHan/gulimall/sql文件
本项目其他笔记见专栏:https://blog.csdn.net/hancoder/category_10822407.html
本篇2.5W字,请直接ctrl+F搜索内容
构建gulimall-cart,复制静态资源到nginx,修改网关
购物车分为离线购物车和登录购物车
离线购物车重启浏览器了也还有
特点:读多写少,放入数据库并不合适
登录状态:登录购物车
未登录状态:离线购物车
(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为数据
(2) 购物项vo
public class CartItem {
private Long skuId;
/*** 是否被选中*/
private Boolean check = true;
private String title;
private String image;
private List skuAttr;
/*** 价格*/
private BigDecimal price;
/*** 数量*/
private Integer count;
(3) 购物车vo
public class Cart {
private List 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());
}
(1) threadlocal说明
threadlocal的效果是其中存储的内容只有当前线程能访问的
如果想了解更多threadlocal知识可以查看:https://blog.csdn.net/hancoder/article/details/107853513
threadlocal的原理是每个线程都有一个map,key为threadlocal对象,value为对象所对应的值
参考京东,在点击购物车时,会为临时用户生成一个name
为user-key
的cookie
临时标识,过期时间为一个月,如果手动清除user-key
,那么临时购物车的购物项也被清除,所以user-key
是用来标识和存储临时购物车数据的
(2) 使用ThreadLocal进行用户身份鉴别信息传递
但是注意的是tomcat中线程可以复用,所以线程和会话不是一对一的关系。但是没有关系,会在拦截器中先判断会话有没有用户信息(cookie),
拦截器拦截会话
购物车拦截器的配置
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
//拦截所有请求
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor()).addPathPatterns("/**");
}
}
购物车拦截器
public class CartInterceptor implements HandlerInterceptor {
// 静态,
public static ThreadLocal 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);
}
}
}
需要的服务: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 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
@Override // CartServiceImpl
public CartItem addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
// 获取当前用户的map
BoundHashOperations cartOps = getCartOps();
// 查看该用户购物车里是否有指定的skuId
String res = (String) cartOps.get(skuId.toString());
// 查看用户购物车里是否已经有了该sku项
if(StringUtils.isEmpty(res)){
CartItem cartItem = new CartItem();
// 异步编排
CompletableFuture getSkuInfo = CompletableFuture.runAsync(() -> {
// 1. 远程查询当前要添加的商品的信息
R skuInfo = productFeignService.SkuInfo(skuId);
SkuInfoVo sku = skuInfo.getData("skuInfo", new TypeReference() {});
// 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 getSkuSaleAttrValues = CompletableFuture.runAsync(() -> {
List 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;
}
}
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 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 cartItems = getCartItems(cartKey);
cart.setItems(cartItems);
}else {
// 没登录 获取临时购物车的所有购物项
cart.setItems(getCartItems(tempCartKey));
}
return cart;
}
/**
* 获取购物车所有项
*/
private List getCartItems(String cartKey){
BoundHashOperations hashOps = stringRedisTemplate.boundHashOps(cartKey);
// key不重要,拿到值即可
List
更改购物项选中状态
@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 cartOps = getCartOps();
cartOps.put(skuId.toString(), JSON.toJSONString(cartItem));
}
@Override
public CartItem getCartItem(Long skuId) {
BoundHashOperations cartOps = getCartOps();
String o = (String) cartOps.get(skuId.toString());
return JSON.parseObject(o, CartItem.class);
}
@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 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));
}
@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 ops = getCartItemOps();
ops.delete(skuId.toString());
}
https://blog.csdn.net/hancoder/article/details/114297652
这部分的内容请去认证服务笔记里看https://blog.csdn.net/hancoder/article/details/114242184
思想就是用redis存储session,并且cookie的作用域跨大到*.gulimall.com
如果域名不同可以用单点登录解决,思想为创建登录服务器,去登录服务器获取用户的redis-key,然后在自己的服务里请求redis对应的用户后保存到自己的session里
资料源码中等待付款是订单详情页;订单页是用户订单列表;结算页是订单确认页;收银页是支付页cd
在nginx中新建目录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
192.168.56.10 order.gulimall.com
/static/order/confirm
订单概念
订单中心:
电商系统涉及到3流,分别是信息流,资金流,物流,而订单系统作为中枢将三者有机的集合起来。
订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。
订单状态
用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支付,需要汪意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超时后将自动取消订单,订单变更关闭状态。
用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调拨,配货,分拣,出库等操作。
仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物品物流状态
付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。
用户在付款后申请退款,或商家发货后用户申请退换货。
售后也同样存在各种状态,
订单流程
订单流程是指从订单产生到完成整个流转的过程,从而行程了一套标准流程规则。而不同的产品类型或业务类型在系统中的流程会千差万别,比如上面提到的线上实物订单和虚拟订单的流程,线上实物订单与O2O订单等,所以需要根据不同的类型进行构建订单流程。
不管类型如何订单都包括正向流程和逆向流程,对应的场景就是购买商品和退换货流程,正向流程就是一个正常的网购步骤:
订单生成一>支付订单一>卖家发货一>确认收货一>交易成功。
而每个步骤的背后,订单是如何在多系统之间交互流转的,可概括如下图
因为订单系统必然涉及到用户信息,因此进入订单系统的请求必须是已经登录的,所以我们需要通过拦截器对未登录订单请求进行拦截
WebMvcConfigurer接口.addInterceptor()
方法@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal 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);
session.setAttribute(AuthServerConstant.LOGIN_USER,data);
1)新线程没有用户数据的问题RequestContextHolder
RequestContextHolder可以解决的问题:
RequestContextHolder
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);
,每个请求来了都会执行,
2)远程调用丢失用户信息
feign
远程调用的请求头中没有含有JSESSIONID
的cookie
,所以也就不能得到服务端的session
数据,也就没有用户数据,cart认为没登录,获取不了用户信息
我们追踪远程调用的源码,可以在SynchronousMethodHandler.targetRequest()方法中看到他会遍历容器中的RequestInterceptor
进行封装
Request targetRequest(RequestTemplate template) {
for (RequestInterceptor interceptor : requestInterceptors) {
interceptor.apply(template);
}
return target.apply(template);
}
根据追踪源码,我们可以知道我们可以通过给容器中注入RequestInterceptor,从而给远程调用转发时带上cookie
但是在feign
的调用过程中,会使用容器中的RequestInterceptor
对RequestTemplate
进行处理,因此我们可以通过向容器中导入定制的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
了
在这种情况下,我们需要在开启异步的时候将老请求的RequestContextHolder
的数据设置进去
OrderServiceImpl.confirmOrder()代码
// 从主线程获取用户数据 放到局部变量中
RequestAttributes attributes = RequestContextHolder.getRequestAttributes();
CompletableFuture getAddressFuture = CompletableFuture.runAsync(() -> {
// 把旧RequestAttributes放到新线程的RequestContextHolder中
RequestContextHolder.setRequestAttributes(attributes);
// 远程查询所有的收获地址列表
List address;
try {
address = memberFeignService.getAddress(MemberRespVo.getId());
此外远程获取价格的时候应该用R
1)订单确认页VO
点击"去结算"就会跳到订单确认页
跳转到确认页时需要携带的数据模型。
public class OrderConfirmVo { // 跳转到确认页时需要携带的数据模型。
@Getter
@Setter
/** 会员收获地址列表 **/
private List memberAddressVos;
@Getter @Setter
/** 所有选中的购物项 **/
private List items;
/** 发票记录 **/
@Getter @Setter
/** 优惠券(会员积分) **/
private Integer integration;
/** 防止重复提交的令牌 **/
@Getter @Setter
private String orderToken;
@Getter @Setter
Map 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)订单确认页数据获取
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 getAddressFuture = CompletableFuture.runAsync(() -> {
// 因为异步线程需要新的线程,而新的线程里没有request数据,所以我们自己设置进去
RequestContextHolder.setRequestAttributes(attributes);
List address;
try {
address = memberFeignService.getAddress(MemberRespVo.getId());
confirmVo.setAddress(address);
} catch (Exception e) {
log.warn("\n远程调用会员服务失败 [会员服务可能未启动]");
}
}, executor);
// 2. 远程查询购物车服务,并得到每个购物项是否有库存
CompletableFuture cartFuture = CompletableFuture.runAsync(() -> {
// 异步线程共享 RequestContextHolder.getRequestAttributes()
RequestContextHolder.setRequestAttributes(attributes);
// feign在远程调用之前要构造请求 调用很多拦截器
// 远程获取用户的购物项
List items = cartFeignService.getCurrentUserCartItems();
confirmVo.setItems(items);
}, executor).thenRunAsync(() -> {
RequestContextHolder.setRequestAttributes(attributes);
List items = confirmVo.getItems();
// 获取所有商品的id
List skus = items.stream().map(item -> item.getSkuId()).collect(Collectors.toList());
R hasStock = wmsFeignService.getSkuHasStock(skus);
List data = hasStock.getData(new TypeReference>() {});
if (data != null) {
// 各个商品id 与 他们库存状态的映射map // 学习下收集成map的用法
Map 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”);`
点击提交订单时计算总额,而不是用当前页面的值,或者比对一下值,不一致让用户重新看订单
[[${addr.name}]]
[[${addr.name}]] [[${addr.detailAddress}]] [[${addr.phone}]]
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() {
});
fareVo.setAddress(address);
String phone = address.getPhone();
//取电话号的最后两位作为邮费
String fare = phone.substring(phone.length() - 2, phone.length());
fareVo.setFare(new BigDecimal(fare));
}
return fareVo;
}
P274
讨论:多次点击 【提交订单】 按钮
幂等性:订单提交一次和提交多次结果是一致的
哪些情况要防止:
以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)
(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)验证原子性令牌
// @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 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 orderItems;
/** 订单计算的应付价格 **/
private BigDecimal payPrice;
/** 运费 **/
private BigDecimal fare;
}
创建订单、订单项
IdWorker
生成订单号,是时间和本身对象的组合//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 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// OrderServiceImpl
private List buildOrderItems(String orderSn) {
// 这里是最后一次来确认购物项的价格 这个远程方法还会查询一次数据库
List cartItems = cartFeignService.getCurrentUserCartItems();
List 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() {
});
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 items = buildOrderItems(orderSn);
// 3.根据订单项计算价格 传入订单 、订单项 计算价格、积分、成长值等相关信息
computerPrice(orderEntity, items);
orderCreateTo.setOrder(orderEntity);
orderCreateTo.setOrderItems(items);
return orderCreateTo;
}
计算总价
private void computerPrice(OrderEntity orderEntity, List 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 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 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 `wms_ware_sku` SET stock_locked = stock_locked + #{num}
WHERE sku_id = #{skuId}
AND ware_id = #{wareId}
AND stock-stock_locked >= #{num}
@Transactional // 事务
@Override
public Boolean orderLockStock(List itemVos) {
List lockVos = itemVos.stream().map((item) -> {
SkuLockVo skuLockVo = new SkuLockVo();
skuLockVo.setSkuId(item.getSkuId());
skuLockVo.setNum(item.getCount());
//找出所有库存大于商品数的仓库// 这个地方问题很大,后面得改
List 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 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;
}
这里通过异常机制控制事务回滚,如果在锁定库存失败则抛出NoStockException
s,订单服务和库存服务都会回滚。
后面有消息队列后,会进行优化
优化逻辑为:锁库存后,把内容发到消息队列里
消息队列并不立刻消费,而是让其过期,过期后重新入队别的消息队列,别的消息队列拿到后验证订单是否被支付,没被支付的话还原到库存里。
订单服务下订单---------\
库存服务锁库存---------->分布式事务
用户服务扣减积分-------/
事务保证:
1、订单服务异常,库存锁定不运行,全部回滚,撤销操作
2、库存服务事务自治,锁定失败全部回滚,订单感受到,继续回滚
3、库存服务锁定成功了,但是网络原因返回数据途中问题?
4、库存服务锁定成功了,库存服务下面的逻辑发生故障,订单回滚了,怎么处理?
利用消息队列实现最终一致
库存服务锁定成功后发给消息队列消息(当前库存工作单),过段时间自动解锁,解锁时先查询订单的支付状态。解锁成功修改库存工作单详情项状态为已解锁
事务传播问题中,传播后事务设置还是原来的,如果不想用原来设置,必须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: ,设置expose-proxy属性为true,将代理暴露出来,使用AopContext.currentProxy()获取当前代理,将this.b()改为((UserService)AopContext.currentProxy()).b()
解决方案:
public void a() {
((AService) AopContext.currentProxy()).b();//即调用AOP代理对象的b方法即可执行事务切面进行事务增强
}
解决远程宕机
CAP理论
CAP原则又称CAP定理,指的是在一个分布式系统中
CAP原则指的是,这三个要素最多只能同时实现两点,不可能三者兼顾
选举与同步理论
分布式一致性动画演示:http://thesecretlivesofdata.com/raft/
raft是一个实现分布式一致性的协议
结点的状态:
选举leader:
raft有两个超时时间控制领导选举:
- 选举超时:从follower到candidate的时间,150ms-300ms(自旋时间),这个时间段内没收到leader的心跳就变为候选者。
- 自旋时间结束后变成candidate,开始一轮新的选举(老师上课举的例子是)
- 投出去票后重新计时自旋
- leader就发送追加日志给follower,follower就正常
- 消息发送的,心跳时间:如10ms,leader收到投票后,下一次心跳时就带上消息,follower收到消息后重置选举时间
- leader宕机,follower收不到心跳,开始新的选举
写数据:
如果有的结点消息滞后了:
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是指:
从客户端角度,多进程并发访同时,更新过的数据在不同程如何获的不同策珞,决定了不同的一致性。
分布式事务几种方案
1) 2PC模式(XA事务)
数据库支持的2pc
【2二阶段提交】,又叫做XA Transactions
支持情况:mysql从5.5版本开始支持,SQLserver2005开始支持,Oracle7开始支持。
其中,XA是一个两阶段提交协议,该协议分为以下两个阶段:
如图所示,如果有订单服务和库存服务要求分布式事务,要求有一个总的事务管理器
总的事务管理让事务分为两个阶段,
总事务管理器接收到两个服务都预备好了log(收到ack),就告诉他们commit
如果有一个没准备好,就回滚所有人。
总结2PC:
2) 柔性事务-TCC事务补偿型方案
与刚性事务不同,柔性事务允许一定时间内,不同节点的数据不一致,但要求最终一致。
prepare
行为:调用自定义的prepare逻辑。commit
行为:调用自定义的commit逻憬。rollback
行为:调用自定义的rollback逻辑。所TCC模式,是指支持 自定义的 分支事务纳入到全局事务的管理中。
3)柔性事务-最大努力通知型方案
按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接囗进行核对。这种方案主要用在与第三方系统通讯时,比如:调用微信或支付宝支付后的支付结果通知。这种方案也是结合MQ进行实现,例如:通过MQ发送就请求,设置最大通知次数。达到通知次数后即不再通知。
案例:银行涌知、商户通知等(各大交易业务平台间的商户涌知:多次通知、查询校对、对账文件),支付宝的支付成功异步回调
大业务调用订单,库存,积分。最后积分失败,则一遍遍通知他们回滚
让子业务监听消息队列
如果收不到就重新发
4)柔性事务=可靠消息+最终一致性方案(异步确保型)
实现:业务处理服务在业务事务提交之前,向实时消息服务请求发送捎息,实时捎息服务只记录消息数据,而不是真正的发送。业务处理服务在业务事务提交之后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才会真正发送。
体验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交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
要执行下单,
我们只需要使用一个 @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;
com.alibaba.cloud
spring-cloud-starter-alibaba-seata
带上版本号
开始应用
从 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); } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 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.conf
和file.conf
将register.conf
和file.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。使用消息方式
失败了之后发消息。库存服务本身也可以使用自动解锁模式。消息队列。
自动解锁:库存服务订阅消息队列,库存解锁发给消息队列
保存库存工作单和库存工作单详情,
锁定库存后数据库记录。后面的事务失败后看前面的库存,有没解锁的就解锁。
定期全部检索很麻烦,索引引入延迟队列。
锁库存后害怕订单失败,锁库存后发送给消息队列,只不过要暂存一会先别被消费。半小时以后再消费就可以知道大事务成功没有。
上面这个流程并没有特别的设置,完全靠消息队列控制
(1) 延迟队列
场景:比如未付款订单,超过一定时间后,系统自动取消订单并释放占有物品
常用解决方案:
订单关了之后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,则按照这两个参数重新路由转发到指定的队列。
针对订单模块创建以上消息队列,创建订单时消息会被发送至队列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 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 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是查不到数据库的
锁库存的sql
UPDATE `wms_ware_sku` SET stock_locked = stock_locked + #{num}
WHERE sku_id = #{skuId}
AND ware_id = #{wareId}
AND stock-stock_locked >= #{num}
@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 locks = vo.getLocks();//订单项
List lockVOs = locks.stream().map(item -> {
// 创建订单项
SkuWareHasStock hasStock = new SkuWareHasStock();
Long skuId = item.getSkuId();
hasStock.setSkuId(skuId);
// 查询本商品在哪有库存
List 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 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 `wms_ware_sku`
SET stock_locked = stock_locked - #{num}
WHERE sku_id = #{skuId}
AND ware_id = #{wareId}
@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() {});
// 订单不存在或订单已取消
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().eq("order_sn", orderSn)));
//查询出当前订单相关的且处于锁定状态的工作单详情
List lockDetails = wareOrderTaskDetailService.list(
new QueryWrapper()
.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) 支付宝加密原理
商户私钥
加一个对应的签名,支付宝端会使用商户公钥
对签名进行验签,只有数据明文和签名对应的时候才能说明传输正确支付宝私钥
加一个对应的签名,商户端收到支付成功数据之后也会使用支付宝公钥
延签,成功后才能确认(2) 配置支付宝沙箱环境
(3) 环境搭建
导入支付宝sdk
com.alipay.sdk
alipay-sdk-java
4.9.28.ALL
抽取支付工具类并进行配置
成功调用该接口后,返回的数据就是支付页面的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().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 orderItemEntities = orderItemService.list(new QueryWrapper().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 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 params = new HashMap<>();
Map 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 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\"}");
超时后订单显示:“抱歉您的交易因超时已失败”’
表达式: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 接口
org.springframework.boot
spring-boot-starter
2.0.4.RELEASE
org.springframework.boot
spring-boot-starter-web
mysql
mysql-connector-java
org.mybatis.spring.boot
mybatis-spring-boot-starter
1.3.1
org.mybatis
mybatis
3.4.5
compile
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的定时任务
后vue的后台管理系统里上架秒杀,打开F12看url然后去编写逻辑
点击关联商品可以添加秒杀里的商品。可以看sms数据库里的seckill_sky
(1) 秒杀架构
nginx–>gateway–>redis分布式信号了–> 秒杀服务
gulimall-seckill
redisson 信号量
的形式存储在redis中秒杀活动:存在在scekill:sesssions
这个redis-key里,。value为 skyIds[]
秒杀活动里具体商品项:是一个map,redis-key是seckill:skus
,map-key是skuId+商品随机码
(2) redis存储模型设计
秒杀场次存储的List
可以当做hash key
在SECKILL_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;
}
(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 sessions = r.getData(new TypeReference>() {
});
//在redis中分别保存秒杀场次信息和场次对应的秒杀商品信息
saveSecKillSession(sessions);// 会判断有没有加错
saveSecKillSku(sessions);
}
}
@FeignClient("gulimall-coupon")
public interface CouponFeignService {
@GetMapping("/coupon/seckillsession/lates3DaySession")
R getLate3DaySession();
}
(2) 获取最近三天的秒杀信息
@Override // SeckillSessionServiceImpl
public List getLate3DaySession() {
// 获取最近3天的秒杀活动
List list = this.list(
new QueryWrapper()
.between("start_time", startTime(), endTime()));
// 设置秒杀活动里的秒杀商品
if(list != null && list.size() > 0){
return list.stream().map(session -> {
// 给每一个活动写入他们的秒杀项
Long id = session.getId();
// 根据活动id获取每个sku项
List entities =
skuRelationService.list(new QueryWrapper().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 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 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 sessions){
if(sessions != null){
// 遍历session
sessions.stream().forEach(session -> {
BoundHashOperations 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() {});
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());
}
});
});
}
前面已经在redis中缓存了秒杀活动的各种信息,现在写获取缓存中当前时间段在秒杀的sku,用户点击页面后发送请求
@GetMapping(value = "/getCurrentSeckillSkus")
@ResponseBody // 用户网页发请求
public R getCurrentSeckillSkus() {
//获取到当前可以参加秒杀商品的信息
List vos = secKillService.getCurrentSeckillSkus();
return R.ok().setData(vos);
}
@Override
public List getCurrentSeckillSkus() {
// 1.确定当前时间属于那个秒杀场次
long time = new Date().getTime();
// 定义一段受保护的资源
try (Entry entry = SphU.entry("seckillSkus")){
Set 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 range = stringRedisTemplate.opsForList().range(key, 0, 100);
BoundHashOperations hashOps = stringRedisTemplate.boundHashOps(SKUKILL_CACHE_PREFIX);
List 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;
}
首页获取并拼装数据
用户看到秒杀活动点击秒杀商品了,如果时间段正确,返回随机码。购买时带着
@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 ops = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
//获取所有商品的hash key
Set 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 secKillFuture = CompletableFuture.runAsync(() -> {
R skuSeckillInfo = seckillFeignService.getSkuSeckillInfo(skuId);
if (skuSeckillInfo.getCode() == 0) {
SeckillInfoVo seckillInfoVo = skuSeckillInfo.getData(new TypeReference() {});
skuItemVo.setSeckillInfoVo(seckillInfoVo);
}
}, executor);
注意所有的时间都是距离1970的差值
更改商品详情页的显示效果
商品将会在[[${#dates.format(new java.util.Date(item.seckillSkuVo.startTime),"yyyy-MM-dd HH:mm:ss")}]]进行秒杀
秒杀价 [[${#numbers.formatDecimal(item.seckillSkuVo.seckillPrice,1,2)}]]
(1) 秒杀业务
时效、商品随机码、当前用户是否已经抢购过当前商品、库存和购买量
,通过校验的则秒杀成功,发送消息创建订单秒杀按钮:
秒杀函数:
$("#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;
})
秒杀方案:
消息队列:
秒杀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 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) 消息队列
创建秒杀所需队列
/**
* 商品秒杀队列
*/
@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() {
});
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的内容:https://blog.csdn.net/hancoder/article/details/109063671
导入依赖
com.alibaba.cloud
spring-cloud-starter-alibaba-sentinel
${spring-cloud-alibaba.version}
org.springframework.boot
spring-boot-starter-actuator
${spring-cloud-alibaba.version}
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.propertie
s里添加server.port=8333
sentinel-cluster-server-envoy-rls
项目有些错误,需要在pom.xml文件中添加如下依赖:
io.envoyproxy.controlplane
server
0.1.23
还有个test错误,无关紧要,我直接在structure project里去掉test包
资源名:唯一名称,默认请求路径
针对来源:sentinel可以针对调用者进行限流,填写微服务名,默认default(不区分来源)
阈值类型/单机值:
单机/均摊阈值:和下面的选项有关
集群阈值模式:
每台机器
的阈值集群总体
的阈值流控模式:
read_db
和 write_db
这两个资源分别代表数据库读写,我们可以给 read_db
设置限流规则来达到写优先的目的:设置 strategy
为 RuleConstant.STRATEGY_RELATE
同时设置 refResource
为 write_db
。这样当写库操作过于频繁时,读数据的请求会被限流。流控效果:
FlowException
设置被限流后看到的页面
@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用法");
}
}
https://hub.fastgit.org/alibaba/Sentinel/wiki/网关限流
如果能在网关层就进行流控,可以避免请求流入业务,减小服务压力
com.alibaba.cloud
spring-cloud-alibaba-sentinel-gateway
2.1.0.RELEASE
默认情况下,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来进行链路追踪
下载jar包并运行
https://dl.bintray.com/openzipkin/maven/io/zipkin/java/zipkin-server/
导入依赖
org.springframework.cloud
spring-cloud-starter-zipkin
配置
spring:
zipkin:
base-url: http://localhost:9411
sender:
type: web
# 取消nacos对zipkin的服务发现
discovery-client-enabled: false
#采样取值介于 0到1之间,1则表示全部收集
sleuth:
sampler:
probability: 1
其中可以看到请求的方式,请求时间,异步等信息