文章目录
七、购物车(redis实现)
1.游客购物车(京东取消了)
1.未登录状态下加入购物车的商品
2.关闭浏览器后再打开,商品仍然存在
3.采用redis【很好的高并发性能,强于MongoDB】
4.使用user-key【相当于UUID,存在于cookie中】成为临时用户
【如果没有user-key,第一次访问购物车时,会自动分配一个user-key(临时用户身份)】
逻辑:
1)第一次使用购物车功能,创建user-key(分配临时用户身份)
2)访问购物车时,判断当前是否登录状态(session是否存在用户信息)
登录状态则获取用户购物车信息
3)未登录状态,则获取临时用户身份,获取游客购物车
2.用户购物车
1.会将游客状态下的购物车,整合到登录用户名下的购物车
2.游客购物车被清空(此时退出登录游客购物车已被清空)
3.采用redis
因为要获取用户登录状态,所以需要整合springsession
3.环境搭建
1.搭建模块
name:gulimall-cart
group:com.atguigu
Artifact:gulimall-cart
Package Name:com.atguigu.gulimall.cart
2.上传静态资源到nginx上
/mydata/nginx/html/static/cart
3.配置网关
- id: gulimall_cart_route
uri: lb://gulimall-cart
predicates:
- Host=cart.gulimall.com
4.配置DNS
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
192.168.56.10 auth.gulimall.com
192.168.56.10 cart.gulimall.com
5.新增依赖
<!--公共模块-->
<dependency>
<groupId>com.atguigu.gulimall</groupId>
<artifactId>gulimall-common</artifactId>
<version>0.0.1-SNAPSHOT</version>
<exclusions>
<exclusion>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
6.添加redis配置
spring.redis.host=192.168.56.10
spring.redis.port=6379
7.启动类增加注解
@EnableRedisHttpSession
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallCartApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallCartApplication.class, args);
}
}
8.springsession配置类
/**
* springsession配置类
* @Author: wanzenghui
* @Date: 2021/11/30 22:21
*/
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");// 放大作用域
cookieSerializer.setCookieName("GULISESSION");
cookieSerializer.setCookieMaxAge(60 * 60 * 24 * 7);// 指定cookie有效期7天,会话级关闭浏览器后cookie即失效
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
// 指定session序列化到redis的序列化器
return new GenericJackson2JsonRedisSerializer();
}
}
4.购物车数据结构与VO
购物车商品列表截图:
数据结构:
Map<String k1, Map<String k2, CartItemInfo>>
key:用户标示
登录态:gulimall:cart:userId
非登录态:gulimall:cart:userKey
value:
存储一个Hash结构的值,其中该hash结构的key是SkuId,hash结构的value是商品信息,以json字符串格式存储
购物车VO:
/**
* 购物车VO
* 需要计算的属性需要重写get方法,保证每次获取属性都会进行计算
*/
public class CartVO {
private List<CartItemVO> items; // 购物项集合
private Integer countNum; // 商品件数(汇总购物车内商品总件数)
private Integer countType; // 商品数量(汇总购物车内商品总个数)
private BigDecimal totalAmount; // 商品总价
private BigDecimal reduce = new BigDecimal("0.00");// 减免价格
public List<CartItemVO> getItems() {
return items;
}
public void setItems(List<CartItemVO> items) {
this.items = items;
}
public Integer getCountNum() {
int count = 0;
if (items != null && items.size() > 0) {
for (CartItemVO item : items) {
count += item.getCount();
}
}
return count;
}
public Integer getCountType() {
return CollectionUtils.isEmpty(items) ? 0 : items.size();
}
public BigDecimal getTotalAmount() {
BigDecimal amount = new BigDecimal("0");
// 1、计算购物项总价
if (!CollectionUtils.isEmpty(items)) {
for (CartItemVO cartItem : items) {
if (cartItem.getCheck()) {
amount = amount.add(cartItem.getTotalPrice());
}
}
}
// 2、计算优惠后的价格
return amount.subtract(getReduce());
}
public BigDecimal getReduce() {
return reduce;
}
public void setReduce(BigDecimal reduce) {
this.reduce = reduce;
}
}
/**
* 购物项VO(购物车内每一项商品内容)
*/
public class CartItemVO {
private Long skuId; // skuId
private Boolean check = true; // 是否选中
private String title; // 标题
private String image; // 图片
private List<String> skuAttrValues; // 销售属性
private BigDecimal price; // 单价
private Integer count; // 商品件数
private BigDecimal totalPrice; // 总价
public Long getSkuId() {
return skuId;
}
public void setSkuId(Long skuId) {
this.skuId = skuId;
}
public Boolean getCheck() {
return check;
}
public void setCheck(Boolean check) {
this.check = check;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public List<String> getSkuAttrValues() {
return skuAttrValues;
}
public void setSkuAttrValues(List<String> skuAttrValues) {
this.skuAttrValues = skuAttrValues;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public Integer getCount() {
return count;
}
public void setCount(Integer count) {
this.count = count;
}
/**
* 计算当前购物项总价
*/
public BigDecimal getTotalPrice() {
return this.price.multiply(new BigDecimal("" + this.count));
}
public void setTotalPrice(BigDecimal totalPrice) {
this.totalPrice = totalPrice;
}
}
5.拦截器
业务逻辑:
1)第一次使用购物车功能,创建user-key(分配临时用户身份)
2)访问购物车时,判断当前是否登录状态(session是否存在用户信息)
登录状态则获取用户购物车信息
3)未登录状态,则获取临时用户身份,获取游客购物车
拦截器功能:
过滤器(URL拦截)=》拦截器(URL拦截)=》切面(方法拦截)
1.preHandle
1)获取用户登录信息userId,封装到ThreadLocal中,controller可以拿到
2)用户未登录,分配userKey封装到ThreadLocal中,controller可以拿到
2.postHandle
1)判断客户端是否存在游客用户标识
不存在则创建cookie,命令客户端保存游客信息user-key
ThreadLocal共享登录用户信息
public class CartInterceptor implements HandlerInterceptor {
public static ThreadLocal<UserInfoTO> threadLocal = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 获取会话信息,获取登录用户信息
HttpSession session = request.getSession();
MemberResponseVO attribute = (MemberResponseVO) session.getAttribute(AuthConstant.LOGIN_USER);
// 判断是否登录,并封装User对象给controller使用
UserInfoTO user = new UserInfoTO();
if (attribute != null) {
// 登录状态,封装用户ID,供controller使用
user.setUserId(attribute.getId());
}
// 获取当前请求游客用户标识user-key
Cookie[] cookies = request.getCookies();
if (ArrayUtils.isNotEmpty(cookies)) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(CartConstant.TEMP_USER_COOKIE_NAME)) {
// 获取user-key值封装到user,供controller使用
user.setUserKey(cookie.getValue());
user.setTempUser(true);// 不需要重新分配
break;
}
}
}
// 判断当前是否存在游客用户标识
if (StringUtils.isBlank(user.getUserKey())) {
// 无游客标识,分配游客标识
user.setUserKey(UUID.randomUUID().toString());
}
// 封装用户信息(登录状态userId非空,游客状态userId空)
threadLocal.set(user);
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
UserInfoTO user = threadLocal.get();
if (user != null && !user.isTempUser()) {
// 需要为客户端分配游客信息
Cookie cookie = new Cookie(CartConstant.TEMP_USER_COOKIE_NAME, user.getUserKey());
cookie.setDomain("gulimall.com");// 作用域
cookie.setMaxAge(CartConstant.TEMP_USER_COOKIE_TIMEOUT);// 过期时间
response.addCookie(cookie);
}
}
}
6.接口API
6.1.添加商品到购物车
商品详情页(gulimall-product),点击添加购物车, 跳转(gulimall-cart)success.html
/**
* 添加商品到购物车
* @param skuId 商品ID
* @param num 商品数量
* @param attributes 重定向数据域
*/
@GetMapping(value = "/addCartItem")
public String addCartItem(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num,
RedirectAttributes attributes) throws ExecutionException, InterruptedException {
// 添加sku商品到购物车
cartService.addToCart(skuId, num);
attributes.addAttribute("skuId", skuId);// 会在url后面拼接参数
// 请求重定向给addToCartSuccessPage.html,防刷
return "redirect:http://cart.gulimall.com/addToCartSuccessPage.html";
}
/**
* 添加sku商品到购物车
*/
@Override
public CartItemVO addToCart(Long skuId, Integer num) throws ExecutionException, InterruptedException {
// 获取购物车redis操作对象
BoundHashOperations<String, Object, Object> operations = getCartOps();
// 获取商品
String cartItemJSONString = (String) operations.get(skuId.toString());
if (StringUtils.isEmpty(cartItemJSONString)) {
// 购物车不存在此商品,需要将当前商品添加到购物车中
CartItemVO cartItem = new CartItemVO();
CompletableFuture<Void> getSkuInfoFuture = CompletableFuture.runAsync(() -> {
// 远程查询当前商品信息
R r = productFeignService.getInfo(skuId);
SkuInfoVO skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVO>() {
});
cartItem.setSkuId(skuInfo.getSkuId());// 商品ID
cartItem.setTitle(skuInfo.getSkuTitle());// 商品标题
cartItem.setImage(skuInfo.getSkuDefaultImg());// 商品默认图片
cartItem.setPrice(skuInfo.getPrice());// 商品单价
cartItem.setCount(num);// 商品件数
cartItem.setCheck(true);// 是否选中
}, executor);
CompletableFuture<Void> getSkuAttrValuesFuture = CompletableFuture.runAsync(() -> {
// 远程查询attrName:attrValue信息
List<String> skuSaleAttrValues = productFeignService.getSkuSaleAttrValues(skuId);
cartItem.setSkuAttrValues(skuSaleAttrValues);
}, executor);
CompletableFuture.allOf(getSkuInfoFuture, getSkuAttrValuesFuture).get();
operations.put(skuId.toString(), JSON.toJSONString(cartItem));
return cartItem;
} else {
// 当前购物车已存在此商品,修改当前商品数量
CartItemVO cartItem = JSON.parseObject(cartItemJSONString, CartItemVO.class);
cartItem.setCount(cartItem.getCount() + num);
operations.put(skuId.toString(), JSON.toJSONString(cartItem));
return cartItem;
}
}
Hash数据类型操作对象
/**
* 根据用户信息获取购物车redis操作对象
*/
private BoundHashOperations<String, Object, Object> getCartOps() {
// 获取用户登录信息
UserInfoTO userInfo = CartInterceptor.threadLocal.get();
String cartKey = "";
if (userInfo.getUserId() != null) {
// 登录态,使用用户购物车
cartKey = CartConstant.CART_PREFIX + userInfo.getUserId();
} else {
// 非登录态,使用游客购物车
cartKey = CartConstant.CART_PREFIX + userInfo.getUserKey();
}
// 绑定购物车的key操作Redis
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
return operations;
}
接口防刷
如果刷新cart.gulimall.com/addToCart?skuId=7&num=1该页面,会导致购物车中此商品的数量无限新增
解决方案:
/addToCart请求使用重定向给/addToCartSuccessPage.html
由/addToCartSuccessPage.html这个请求跳转"商品已成功加入购物车页面"(浏览器url请求已更改),达到防刷的目的
/**
* 商品添加购物车成功页(防刷)
*/
@GetMapping(value = "/addToCartSuccessPage.html")
public String addToCartSuccessPage(@RequestParam("skuId") Long skuId, Model model) {
//重定向到成功页面。再次查询购物车数据即可
CartItemVO cartItemVo = cartService.getCartItem(skuId);
model.addAttribute("cartItem",cartItemVo);
return "success";
}
/**
* 根据skuId获取购物车商品信息
*/
@Override
public CartItemVO getCartItem(Long skuId) {
// 获取购物车redis操作对象
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
String cartItemJSONString = (String) cartOps.get(skuId.toString());
CartItemVO cartItemVo = JSON.parseObject(cartItemJSONString, CartItemVO.class);
return cartItemVo;
}
达到防刷目的的重定向请求:
6.2.购物车列表
/**
* 购物车列表页
* 1.拦截器封装用户信息
* 1)已登录状态:封装userId+userKey到ThreadLocal中
* 2)未登录状态:
* 2-1)已分配游客标识,封装userKey到ThreadLocal中
* 2-2)未分配游客标识,命令客户端保存cookie(user-key),并封装userKey到ThreadLocal中
* 2.根据用户标识获取购物车信息
* 1)已登录状态
* 使用userId作为key获取购物车
* 使用userKey作为key获取游客购物车,如果非空则与用户购物车合并
* 2)未登录状态
* 使用userKey作为key获取游客购物车
* 3.返回cartList列表页
*/
@GetMapping("/cart.html")
public String cartListPage(Model model) throws ExecutionException, InterruptedException {
CartVO cartVO = cartService.getCart();
model.addAttribute("cart", cartVO);
return "cartList";
}
/**
* 购物车列表
*/
@Override
public CartVO getCart() throws ExecutionException, InterruptedException {
CartVO cart = new CartVO();
// 获取用户登录信息
UserInfoTO userInfo = CartInterceptor.threadLocal.get();
// 获取游客购物车
List<CartItemVO> touristItems = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserKey());
if (userInfo.getUserId() != null) {
// 登录状态
if (!CollectionUtils.isEmpty(touristItems)) {
// 游客购物车非空,需要整合到用户购物车
for (CartItemVO item : touristItems) {
// 将商品逐个放到用户购物车
addToCart(item.getSkuId(), item.getCount());
}
// 清楚游客购物车
clearCart(CartConstant.CART_PREFIX + userInfo.getUserKey());
}
// 获取用户购物车(已经合并后的购物车)
List<CartItemVO> items = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserId());
cart.setItems(items);
} else {
// 未登录状态,返回游客购物车
cart.setItems(touristItems);
}
return cart;
}
/**
* 根据购物车的key获取
*/
private List<CartItemVO> getCartItems(String cartKey) {
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
List<Object> values = operations.values();
if (!CollectionUtils.isEmpty(values)) {
// 购物车非空,反序列化成商品并封装成集合返回
return values.stream()
.map(jsonString -> JSONObject.parseObject((String) jsonString, CartItemVO.class))
.collect(Collectors.toList());
}
return null;
}
/**
* 清空购物车
*/
@Override
public void clearCart(String cartKey) {
redisTemplate.delete(cartKey);
}
6.3.更改购物车商品选中状态
/**
* 更改购物车商品选中状态
*/
@GetMapping(value = "/checkItem")
public String checkItem(@RequestParam(value = "skuId") Long skuId,
@RequestParam(value = "checked") Integer check) {
cartService.checkItem(skuId, check);
return "redirect:http://cart.gulimall.com/cart.html";
}
/**
* 更改购物车商品选中状态
*/
@Override
public void checkItem(Long skuId, Integer check) {
// 查询购物车商品信息
CartItemVO cartItem = getCartItem(skuId);
// 修改商品选中状态
cartItem.setCheck(ObjectConstant.BooleanIntEnum.YES.getCode().equals(check) ? true : false);
// 更新到redis中
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
cartOps.put(skuId.toString(), JSONObject.toJSONStringWithDateFormat(cartItem, DateUtils.DATATIMEF_TIME_STR));
}
6.4.更改商品数量
/**
* 改变商品数量
*/
@GetMapping(value = "/countItem")
public String countItem(@RequestParam(value = "skuId") Long skuId,
@RequestParam(value = "num") Integer num) {
cartService.changeItemCount(skuId,num);
return "redirect:http://cart.gulimall.com/cart.html";
}
/**
* 改变商品数量
*/
@Override
public void changeItemCount(Long skuId, Integer num) {
// 查询购物车商品信息
CartItemVO cartItem = getCartItem(skuId);
// 修改商品数量
cartItem.setCount(num);
// 更新到redis中
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
cartOps.put(skuId.toString(), JSONObject.toJSONStringWithDateFormat(cartItem, DateUtils.DATATIMEF_TIME_STR));
}
6.5.删除购物车商品
/**
* 删除商品信息
*/
@GetMapping(value = "/deleteItem")
public String deleteItem(@RequestParam("skuId") Integer skuId) {
cartService.deleteIdCartInfo(skuId);
return "redirect:http://cart.gulimall.com/cart.html";
}
/**
* 删除购物项
*/
@Override
public void deleteIdCartInfo(Integer skuId) {
BoundHashOperations<String, Object, Object> operations = getCartOps();
operations.delete(skuId.toString());
}
6.6.购物车列表页选中商品
注意:
1、全局异常处理的原理
2、需求解析:购物车列表页选中指定商品获取商品价格信息
/**
* 获取当前用户的购物车所有商品项
* 订单服务调用:【购物车列表页面点击确认订单时】
* 从redis中获取所有选中的商品项
* 并且要获取最新的商品价格信息,替换redis中的价格信息
*/
@GetMapping(value = "/currentUserCartItems")
@ResponseBody
public List<CartItemVO> getCurrentCartItems() {
List<CartItemVO> cartItemVoList = cartService.getUserCartItems();
return cartItemVoList;
}
/**
* 获取购物车,最新价格
*/
@Override
public List<CartItemVO> getUserCartItems() {
List<CartItemVO> cartItemVoList = new ArrayList<>();
// 获取当前用户登录的信息
UserInfoTO userInfo = CartInterceptor.threadLocal.get();
if (userInfo.getUserId() == null) {
// 未登录
return null;
} else {
// 已登录,获取用户购物车
List<CartItemVO> items = getCartItems(CartConstant.CART_PREFIX + userInfo.getUserId());
if (CollectionUtils.isEmpty(items)) {
throw new CartExceptionHandler();
}
// 筛选所有选中的sku
Map<Long, CartItemVO> itemMap = items.stream().filter(item -> item.getCheck())
.collect(Collectors.toMap(CartItemVO::getSkuId, val -> val));
// 调用远程获取最新价格
Map<Long, BigDecimal> priceMap = productFeignService.getPrice(itemMap.keySet());
// 封装价格返回
items = itemMap.entrySet().stream().map(entry -> {
CartItemVO item = entry.getValue();
item.setPrice(priceMap.get(entry.getKey()));
return item;
}).collect(Collectors.toList());
return items;
}
}
八、订单模块
1.环境搭建
1.1.整合环境
等待付款(订单详情页):
订单页(订单确认页):
结算页:
收银页:
1.拷贝静态资源到nginx中,html页面的请求资源修改为/static/order/xxx开头
1)等待付款(订单详情页)
静态资源拷贝=》/mydata/nginx/html/static/order/detail
html文件拷贝至order模块,更名为detail.html
2)订单页(订单列表、订单确认收货页)
静态资源拷贝=》/mydata/nginx/html/static/order/list
html文件拷贝至order模块,更名为list.html
3)结算页(订单提交页)
静态资源拷贝=》/mydata/nginx/html/static/order/confirm
html文件拷贝至order模块,更名为confirm.html
4)收银页(收银台、选择支付方式)
静态资源拷贝=》/mydata/nginx/html/static/order/pay
html文件拷贝至order模块,更名为pay.html
2.本地DNS解析配置域名
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
192.168.56.10 auth.gulimall.com
192.168.56.10 cart.gulimall.com
192.168.56.10 order.gulimall.com
3.配置好nginx转发规则(前面配置其他模块的时候已经配置)
静态资源请求:/usr/share/nginx/html(已经映射上了/mydata/nginx/html)
动态资源请求:转发至192.168.56.1:88(网关)
4.配置网关转发
- id: gulimall_order_route
uri: lb://gulimall-order
predicates:
- Host=order.gulimall.com
5.引入thymeleaf依赖,并在开发期间禁用缓存
<!--thymeleaf模板引擎-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
6.启动类
// 开启rabbit
@EnableRabbit
// 开启SpringSession
@EnableRedisHttpSession
// 开启服务注册功能
@EnableDiscoveryClient
@MapperScan("com.atguigu.gulimall.order.dao")
@SpringBootApplication
public class GulimallOrderApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallOrderApplication.class, args);
}
}
7.配置:
server:
port: 9000
spring:
application:
name: gulimall-order
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
# 开发期间禁用缓存
thymeleaf:
cache: false
1.2.整合springsession
1.在各服务添加springsession依赖(服务自治)【auth、product、search、member、order、】
<!--redis-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--整合springsession,实现session共享-->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
2.属性配置
server:
servlet:
session:
timeout: 30m
spring:
redis:
host: 192.168.56.10
port: 6379
session:
store-type: redis
3.启动类添加配置
@EnableRedisHttpSession
6.添加以下配置,放大作用域 + 指定redis序列化器【否则使用默认的jdk序列化器】
/**
* springsession配置类
*/
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
cookieSerializer.setDomainName("gulimall.com");// 放大作用域
cookieSerializer.setCookieName("GULISESSION");
cookieSerializer.setCookieMaxAge(60 * 60 * 24 * 7);// 指定cookie有效期7天,会话级关闭浏览器后cookie即失效
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
// 指定session序列化到redis的序列化器
return new GenericJackson2JsonRedisSerializer();
}
}
7.修改product模块gulimall首页,去除session中的loginUser
<li>
<a th:if="${session.loginUser != null}">欢迎, [[${session.loginUser.nickname}]]</a>
<a th:if="${session.loginUser == null}" href="http://auth.gulimall.com/login.html">你好,请登录</a>
</li>
8.测试
=》进入auth.gulimall.com并社交登录
=》进入gulimall.com查看cookie作用域是否修改成功
=》查看redis,session是否存储成功
=》查看gulimall首页nickname是否取到值
1.3.整合线程池
@EnableConfigurationProperties(MyThreadConfig.ThreadPoolConfigProperties.class)
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool) {
return new ThreadPoolExecutor(
pool.getCoreSize(),
pool.getMaxSize(),
pool.getKeepAliveTime(),
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
@ConfigurationProperties(prefix = "gulimall.thread")
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
}
gulimall:
thread:
core-size: 20
max-size: 200
keep-alive-time: 10
1.4.application.yml
server:
port: 9000
servlet:
session:
timeout: 30m
spring:
application:
name: gulimall-order
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
datasource:
username: root
password: root
url: jdbc:mysql://192.168.56.10:3306/gulimall_oms?useUnicode=true&characterEncoding=UTF-8&useSSL=false&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
redis:
host: 192.168.56.10
port: 6379
session:
store-type: redis
rabbitmq:
host: 192.168.56.10
port: 5672
# 虚拟主机
virtual-host: /
# 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】
publisher-confirm-type: correlated
# 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】
publisher-returns: true
# 消息在没有被队列接收时是否强行退回
template:
mandatory: true
# 消费者手动确认模式,关闭自动确认,否则会消息丢失
listener:
simple:
acknowledge-mode: manual
# 开发期间禁用缓存
thymeleaf:
cache: false
mybatis-plus:
# 扫描依赖的jar包下的所有mapper.xml
mapper-locations: classpath:/mapper/**/*.xml
global-config:
db-config:
id-type: auto
gulimall:
thread:
core-size: 20
max-size: 200
keep-alive-time: 10
logging:
level:
com.atguigu.gulimall: debug
2.订单服务拆析
2.1.构成
电商系统涉及到3流,分别是信息流,资金流,物流,而订单系统作为中枢将三者有机的集合起来。
三流:
1、信息流:商品信息、优惠信息
2、资金流:退款、付款
3、物流:发送、退货
订单模块是电商系统的枢纽,在订单这个环节上需求获取多个模块的数据和信息,同时对这些信息进行加工处理后流向下个环节,这一系列就构成了订单的信息流通。
2.2.状态
1、代付款
用户提交订单后,订单进行预下单,目前主流电商网站都会唤起支付,便于用户快速完成支付,需要注意的是待付款状态下可以对库存进行锁定,锁定库存需要配置支付超时时间,超时后将自动取消订单,订单变更关闭状态。
2、已付款/待发货
用户完成订单支付,订单系统需要记录支付时间,支付流水单号便于对账,订单下放到WMS系统,仓库进行调拨,配货,分拣,出库等操作。
3、待收货/已发货
仓储将商品出库后,订单进入物流环节,订单系统需要同步物流信息,便于用户实时知悉物品物流状态
4、已完成
用尸确认收员后,订单交易完成。后续支付侧进行结算,如果订单存在问题进入售后状态
5、已取消
付款之前取消订单。包括超时未付款或用户商户取消订单都会产生这种订单状态。
6.售后中
用户在付款后申请退款,或商家发货后用户申请退换货。售后也同样存在各种状态,当发起售后申请后生成售后订单,售后订单状态为待审核,等待商家审核,商家审核通过后订单状态变更为待退货,等待用户将商品寄回,商家收货后订单状态更新为待退款状态,退款到用户原账户后订单状态更新为售后成功。
2.3.订单流程
订单流程是指从订单产生到完成整个流转的过程,从而行程了一套标准流程规则。而不同的产品类型或业务类型在系统中的流程会千差万别,比如上面提到的线上实物订单和虚拟订单的流程,线上实物订单与o20订单等,所以需要根据不同的类型进行构建订单流程。不管类型如何订单都包括正向流程和逆向流程,对应的场景就是购买商品和退换货流程,正向流程就是一个正常的网购步骤,订单生成->支付订单->卖家发货-→>确认收货->交易成功。而每个步骤的背后,订单是如何在多系统之间交互流转的,可概括如下图
1、订单创建与支付
1)、订单创建前需要预览订单,选择收货信息等
(2)、订单创建需要锁定库存,库存有才可创建,否则不能创建
(3)、订单创建后超时未支付需要解锁库存
(4)、支付成功后,需要进行拆单,根据商品打包方式,所在仓库,物流等进行拆单
(5)、支付的每笔流水都需要记录,以待查账
(6)、订单创建,支付成功等状态都需要给MQ发送消息,方便其他系统感知订阅
2、逆向流程
(1)、修改订单,用户没有提交订单,可以对订单一些信息进行修改,比如配送信息,
优惠信息,及其他一些订单可修改范围的内容,此时只需对数据进行变更即可。
(2)、订单取消,用户主动取消订单和用户超时未支付,两种情况下订单都会取消订
单,而超时情况是系统自动关闭订单,所以在订单支付的响应机制上面要做支付的
3.登录拦截器
订单模块需要用户登录后操作
步骤:
1.添加拦截器
2.添加配置使拦截器生效
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Autowired
private LoginUserInterceptor loginUserInterceptor;
/**
* 配置拦截器生效
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginUserInterceptor).addPathPatterns("/**");// 访问任何订单请求需要拦截校验登录
}
}
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberResponseVO> threadLocal = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// TODO 待解释
String uri = request.getRequestURI();
AntPathMatcher antPathMatcher = new AntPathMatcher();
boolean match = antPathMatcher.match("/order/order/status/**", uri);
boolean match1 = antPathMatcher.match("/payed/notify", uri);
if (match || match1) {
return true;
}
// 获取登录用户信息
MemberResponseVO attribute = (MemberResponseVO) request.getSession().getAttribute(AuthConstant.LOGIN_USER);
if (attribute != null) {
// 已登录,放行
// 封装用户信息到threadLocal
threadLocal.set(attribute);
return true;
} else {
// 未登录,跳转登录页面
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
return false;
}
}
}
4.结算页(由购物车页跳转)
购物车商品列表页,点击去结算跳转结算页:根据页面所选商品查询商品相关信息返回(金额、优惠等等)
购物车页:
结算页:
bug1_feign丢失登录状态
原因:
浏览器请求时会带上Cookie: GULISESSION
默认使用feign调用时,会根据拦截器构造请求参数RequestTemplate,而此时请求头没有带上Cookie,导致springsession无法获取用户信息
解决:
拦截器构造请求头
/**
* feign配置类
**/
@Configuration
public class GuliFeignConfig {
/**
* 注入拦截器
* feign调用时根据拦截器构造请求头,封装cookie解决远程调用时无法获取springsession
*/
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor() {
// 创建拦截器
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
System.out.println("feign远程调用,拦截器封装请求头...RequestInterceptor.apply");
// 1、使用RequestContextHolder拿到原生请求的请求头信(上下文环境保持器)
// 从ThreadLocal中获取请求头(要保证feign调用与controller请求处在同一线程环境)
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
HttpServletRequest request = requestAttributes.getRequest();// 获取controller请求对象
if (request != null) {
//2、同步请求头的数据(cookie)
String cookie = request.getHeader("Cookie");// 获取Cookie
template.header("Cookie", cookie);// 同步Cookie
}
}
}
};
}
}
bug2_异步丢失登录状态
原因:
使用异步编排时,非同一线程无法取到RequestContextHolder(上下文环境保持器)
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = requestAttributes.getRequest();// 获取controller请求对象
空指针异常
解决:
获取主线程ServletRequestAttributes,给每个异步线程复制一份
/**
* 获取结算页(confirm.html)VO数据
*/
@Override
public OrderConfirmVO OrderConfirmVO() throws ExecutionException, InterruptedException {
OrderConfirmVO result = new OrderConfirmVO();
// 获取当前用户
MemberResponseVO member = LoginUserInterceptor.loginUser.get();
// 获取当前线程上下文环境器
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
// 1.查询封装当前用户收货列表
// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVO> address = memberFeignService.getAddress(member.getId());
result.setMemberAddressVos(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
// 2.查询购物车所有选中的商品
// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
RequestContextHolder.setRequestAttributes(requestAttributes);
// 请求头应该放入GULIMALLSESSION(feign请求会根据requestInterceptors构建请求头)
List<OrderItemVO> items = cartFeignService.getCurrentCartItems();
result.setItems(items);
}, executor);
// 3.查询用户积分
Integer integration = member.getIntegration();// 积分
result.setIntegration(integration);
// 4.金额数据自动计算
// 5.TODO 防重令牌
// 阻塞等待所有异步任务返回
CompletableFuture.allOf(addressFuture, cartFuture).get();
return result;
}
计算运费
/**
* 获取运费
* @param addrId 会员收货地址ID
*/
@Override
public FareVO getFare(Long addrId) {
FareVO fareVo = new FareVO();
//收获地址的详细信息
R addrInfo = memberFeignService.info(addrId);
MemberAddressVO memberAddressVo = addrInfo.getData("memberReceiveAddress", new TypeReference<MemberAddressVO>() {
});
if (memberAddressVo != null) {
String phone = memberAddressVo.getPhone();
//截取用户手机号码最后一位作为我们的运费计算
//1558022051
String fare = phone.substring(phone.length() - 1);
BigDecimal bigDecimal = new BigDecimal(fare);
fareVo.setFare(bigDecimal);
fareVo.setAddress(memberAddressVo);
return fareVo;
}
return null;
}
5.幂等性处理
哪些情况需要防止:
用户多次点击按钮
用户页面回退再次提交
微服务互相调用,由于网络问题,导致请求失败。feign触发重试机制
其他业务情况
例如update tab1 set col1=col1+1 where col2 = 2,每次执行结果不一样
天然幂等性:
1.查询接口
2.更新接口update tab1 set col1=1 where col2=2
3.delete from user where userId = 1
4.insert user(userId, name) values(1, 'wan'),其中userId为主键
5.1.token机制
服务器存储了一个令牌,页面请求时要带上令牌,服务器接收请求后会匹配令牌,匹配成功则删除令牌(再次提交则匹配失败,服务器已删除令牌。但是F5刷新的话就不一样了,会有新的token产生)
注意:
1.删除令牌要在执行业务代码之前
2.获取redis令牌、令牌匹配、令牌删除要保证原子性(lua脚本)
5.2.各种锁机制
1.数据库悲观锁
使用select* from xxx where id = 1 for update;查询的时候锁定该条数据
注意:
悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。
id字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会非常麻烦。
2.数据库乐观锁【带上版本号】
这种方法适合在更新的场景中
update t_goods set count = count-1,version =version + 1 where good_id=2 and version = 1
根据version版本,也就是在操作库存前先获取当前商品的version版本号,然后操作的时候带上此version号。
第一次操作库存时,得到version为1,调用库存服务version变成了2﹔但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订单服务传的version还是1,再执行上面的sal语句时,就不会执行﹔因为version已经变为2了,where条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。
乐观锁主要使用于处理读多写少的问题
3.分布式锁:
例如集群下多个定时器处理相同的数据,可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过(double check)
5.3.各种唯一约束
1.数据库唯一约束 order_sn字段【数据库层面】
2.redis set防重【百度网盘妙传功能】
需要处理的数据 计算MD5放入redis的set,每次处理数据,先看MD5是否存在,存在就不处理
5.4.防重表
数据库创建防重表,插入成功才可以操作【不采用,DB慢】
使用订单号orderNo作为去重表唯一索引,然后将数据插入去重表+业务操作 放在同一事物中,如果插入失败事物回滚导致业务操作也同时回滚,(如果业务操作失败也会导致插入去重表回滚)保证了数据一致性
5.5.全局唯一id
调用接口时,生成一个唯一ID,redis将数据保存到集合中(去重),存在即处理过
情景1:feign调用
生成一个请求唯一ID,A调用B时带上唯一ID,B处理feign请求时判断此唯一ID是否已处理(feign重试时会带上相同ID)
情景2:页面请求
可以使用nginx设置每一个请求的唯一id,proxy_set_header X-Request-ld $request_id; 【链路追踪】
但是没办法保证请求幂等性,因为每次请求nginx都会生成一个新的ID
6.结算页
流程图:
结算页数据:
获取当时购物车选中商品并计算价格
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
@Autowired
MemberFeignService memberFeignService;
@Autowired
CartFeignService cartFeignService;
@Autowired
WmsFeignService wmsFeignService;
@Autowired
ThreadPoolExecutor executor;
@Autowired
StringRedisTemplate redisTemplate;
/**
* 获取结算页(confirm.html)VO数据
*/
@Override
public OrderConfirmVO OrderConfirmVO() throws ExecutionException, InterruptedException {
OrderConfirmVO result = new OrderConfirmVO();
// 获取当前用户
MemberResponseVO member = LoginUserInterceptor.loginUser.get();
// 获取当前线程上下文环境器
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(() -> {
// 1.查询封装当前用户收货列表
// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
RequestContextHolder.setRequestAttributes(requestAttributes);
List<MemberAddressVO> address = memberFeignService.getAddress(member.getId());
result.setMemberAddressVos(address);
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
// 2.查询购物车所有选中的商品
// 同步上下文环境器,解决异步无法从ThreadLocal获取RequestAttributes
RequestContextHolder.setRequestAttributes(requestAttributes);
// 请求头应该放入GULIMALLSESSION(feign请求会根据requestInterceptors构建请求头)
List<OrderItemVO> items = cartFeignService.getCurrentCartItems();
result.setItems(items);
}, executor).thenRunAsync(() -> {
// 3.批量查询库存(有货/无货)
List<Long> skuIds = result.getItems().stream().map(item -> item.getSkuId()).collect(Collectors.toList());
R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);
List<SkuHasStockTO> skuHasStocks = skuHasStock.getData(new TypeReference<List<SkuHasStockTO>>() {
});
Map<Long, Boolean> stocks = skuHasStocks.stream().collect(Collectors.toMap(key -> key.getSkuId(), val -> val.getHasStock()));
result.setStocks(stocks);
});
// 4.查询用户积分
Integer integration = member.getIntegration();// 积分
result.setIntegration(integration);
// 5.金额数据自动计算
// 6.防重令牌
String token = tokenUtil.createToken();
result.setUniqueToken(token);
// 阻塞等待所有异步任务返回
CompletableFuture.allOf(addressFuture, cartFuture).get();
return result;
}
}
7.提交订单+幂等性处理
队列业务规则截图
7.1.第一版(无事务)
7.1.1.生成订单
/**
* @Author: wanzenghui
* @Date: 2021/12/20 21:59
*/
@Controller
public class OrderWebController {
@Autowired
private OrderService orderService;
/**
* 创建订单
* 创建成功,跳转订单支付页
* 创建失败,跳转结算页
* 无需提交要购买的商品,提交订单时会实时查询最新的购物车商品选中数据提交
*/
@TokenVerify
@PostMapping(value = "/submitOrder")
public String submitOrder(OrderSubmitVO vo, Model model, RedirectAttributes attributes) {
try {
SubmitOrderResponseVO orderVO = orderService.submitOrder(vo);
// 创建订单成功,跳转收银台
model.addAttribute("submitOrderResp", orderVO);// 封装VO订单数据,供页面解析[订单号、应付金额]
return "pay";
} catch (Exception e) {
// 下单失败回到订单结算页
if (e instanceof VerifyPriceException) {
String message = ((VerifyPriceException) e).getMessage();
attributes.addFlashAttribute("msg", "下单失败" + message);
} else if (e instanceof NoStockException) {
String message = ((NoStockException) e).getMessage();
attributes.addFlashAttribute("msg", "下单失败" + message);
}
return "redirect:http://order.gulimall.com/toTrade";
}
}
}
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
// 提交订单共享提交数据
private ThreadLocal<OrderSubmitVO> confirmVoThreadLocal = new ThreadLocal<>();
@Autowired
MemberFeignService memberFeignService;
@Autowired
CartFeignService cartFeignService;
@Autowired
WmsFeignService wmsFeignService;
@Autowired
ProductFeignService productFeignService;
@Autowired
OrderItemServiceImpl orderItemService;
@Autowired
ThreadPoolExecutor executor;
@Autowired
TokenUtil tokenUtil;
/**
* 创建订单
* GlobalTransactional:seata分布式事务,不适合高并发场景(默认基于AT实现)
* @param vo 收货地址、发票信息、使用的优惠券、备注、应付总额、令牌
*/
//@GlobalTransactional
@Transactional
@Override
public SubmitOrderResponseVO submitOrder(OrderSubmitVO orderSubmitVO) throws Exception {
SubmitOrderResponseVO result = new SubmitOrderResponseVO();// 返回值
// 创建订单线程共享提交数据
confirmVoThreadLocal.set(orderSubmitVO);
// 1.生成订单实体对象(订单 + 订单项)
OrderCreateTO order = createOrder();
// 2.验价应付金额(允许0.01误差,前后端计算不一致)
if (Math.abs(orderSubmitVO.getPayPrice().subtract(order.getPayPrice()).doubleValue()) >= 0.01) {
// 验价不通过
throw new VerifyPriceException();
}
// 验价成功
// 3.保存订单
saveOrder(order);
// 4.库存锁定(wms_ware_sku)
// 封装待锁定商品项TO
WareSkuLockTO lockTO = new WareSkuLockTO();
lockTO.setOrderSn(order.getOrder().getOrderSn());
List<OrderItemVO> locks = order.getOrderItems().stream().map((item) -> {
OrderItemVO lock = new OrderItemVO();
lock.setSkuId(item.getSkuId());
lock.setCount(item.getSkuQuantity());
lock.setTitle(item.getSkuName());
return lock;
}).collect(Collectors.toList());
lockTO.setLocks(locks);// 待锁定订单项
R response = wmsFeignService.orderLockStock(lockTO);
if (response.getCode() == 0) {
// 锁定成功
// TODO 5.远程扣减积分
// 封装响应数据返回
System.out.println(10 / 0);
result.setOrder(order.getOrder());
return result;
} else {
// 锁定失败
throw new NoStockException("");
}
}
/**
* 封装订单实体类对象
* 订单 + 订单项
*/
private OrderCreateTO createOrder() throws Exception {
OrderCreateTO result = new OrderCreateTO();// 订单
// 1.生成订单号
String orderSn = IdWorker.getTimeId();
// 2.生成订单实体对象
OrderEntity orderEntity = buildOrder(orderSn);
// 3.生成订单项实体对象
List<OrderItemEntity> orderItemEntities = buildOrderItems(orderSn);
// 4.汇总封装(封装订单价格[订单项价格之和]、封装订单积分、成长值[订单项积分、成长值之和])
summaryFillOrder(orderEntity, orderItemEntities);
// 5.封装TO返回
result.setOrder(orderEntity);
result.setOrderItems(orderItemEntities);
result.setFare(orderEntity.getFreightAmount());
result.setPayPrice(orderEntity.getPayAmount());// 设置应付金额
return result;
}
/**
* 生成订单实体对象
*
* @param orderSn 订单号
*/
private OrderEntity buildOrder(String orderSn) {
OrderEntity orderEntity = new OrderEntity();// 订单实体类
// 1.封装会员ID
MemberResponseVO member = LoginUserInterceptor.loginUser.get();// 拦截器获取登录信息
orderEntity.setMemberId(member.getId());
// 2.封装订单号
orderEntity.setOrderSn(orderSn);
// 3.封装运费
OrderSubmitVO orderSubmitVO = confirmVoThreadLocal.get();
R fare = wmsFeignService.getFare(orderSubmitVO.getAddrId());// 获取地址
FareVO fareVO = fare.getData(new TypeReference<FareVO>() {
});
orderEntity.setFreightAmount(fareVO.getFare());
// 4.封装收货地址信息
orderEntity.setReceiverName(fareVO.getAddress().getName());// 收货人名字
orderEntity.setReceiverPhone(fareVO.getAddress().getPhone());// 收货人电话
orderEntity.setReceiverProvince(fareVO.getAddress().getProvince());// 省
orderEntity.setReceiverCity(fareVO.getAddress().getCity());// 市
orderEntity.setReceiverRegion(fareVO.getAddress().getRegion());// 区
orderEntity.setReceiverDetailAddress(fareVO.getAddress().getDetailAddress());// 详细地址
orderEntity.setReceiverPostCode(fareVO.getAddress().getPostCode());// 收货人邮编
// 5.封装订单状态信息
orderEntity.setStatus(OrderConstant.OrderStatusEnum.CREATE_NEW.getCode());
// 6.设置自动确认时间
orderEntity.setAutoConfirmDay(OrderConstant.autoConfirmDay);// 7天
// 7.设置未删除状态
orderEntity.setDeleteStatus(ObjectConstant.BooleanIntEnum.NO.getCode());
// 8.设置时间
Date now = new Date();
orderEntity.setCreateTime(now);
orderEntity.setModifyTime(now);
return orderEntity;
}
/**
* 生成订单项实体对象
* 购物车每项选中商品产生一个订单项
*/
private List<OrderItemEntity> buildOrderItems(String orderSn) throws Exception {
// 封装订单项(最后确定的价格,不会再改变)
List<OrderItemVO> currentCartItems = cartFeignService.getCurrentCartItems();// 获取当前用户购物车所有商品
if (!CollectionUtils.isEmpty(currentCartItems)) {
// 遍历购物车商品,循环构建每个订单项
List<OrderItemEntity> itemEntities = currentCartItems.stream()
.filter(cartItem -> cartItem.getCheck())
.map(cartItem -> buildOrderItem(orderSn, cartItem))
.collect(Collectors.toList());
return itemEntities;
} else {
throw new Exception();
}
}
/**
* 生成单个订单项实体对象
*/
private OrderItemEntity buildOrderItem(String orderSn, OrderItemVO cartItem) {
OrderItemEntity itemEntity = new OrderItemEntity();
// 1.封装订单号
itemEntity.setOrderSn(orderSn);
// 2.封装SPU信息
R spuInfo = productFeignService.getSpuInfoBySkuId(cartItem.getSkuId());// 查询SPU信息
SpuInfoTO spuInfoTO = spuInfo.getData(new TypeReference<SpuInfoTO>() {
});
itemEntity.setSpuId(spuInfoTO.getId());
itemEntity.setSpuName(spuInfoTO.getSpuName());
itemEntity.setSpuBrand(spuInfoTO.getSpuName());
itemEntity.setCategoryId(spuInfoTO.getCatalogId());
// 3.封装SKU信息
itemEntity.setSkuId(cartItem.getSkuId());
itemEntity.setSkuName(cartItem.getTitle());
itemEntity.setSkuPic(cartItem.getImage());// 商品sku图片
itemEntity.setSkuPrice(cartItem.getPrice());// 这个是最新价格,购物车模块查询数据库得到
itemEntity.setSkuQuantity(cartItem.getCount());// 当前商品数量
String skuAttrsVals = String.join(";", cartItem.getSkuAttrValues());
itemEntity.setSkuAttrsVals(skuAttrsVals);// 商品销售属性组合["颜色:星河银","版本:8GB+256GB"]
// 4.优惠信息【不做】
// 5.积分信息
int num = cartItem.getPrice().multiply(new BigDecimal(cartItem.getCount())).intValue();// 分值=单价*数量
itemEntity.setGiftGrowth(num);// 成长值
itemEntity.setGiftIntegration(num);// 积分
// 6.价格信息
itemEntity.setPromotionAmount(BigDecimal.ZERO);// 促销金额
itemEntity.setCouponAmount(BigDecimal.ZERO);// 优惠券金额
itemEntity.setIntegrationAmount(BigDecimal.ZERO);// 积分优惠金额
BigDecimal realAmount = itemEntity.getSkuPrice().multiply(new BigDecimal(itemEntity.getSkuQuantity()))
.subtract(itemEntity.getPromotionAmount())
.subtract(itemEntity.getCouponAmount())
.subtract(itemEntity.getIntegrationAmount());
itemEntity.setRealAmount(realAmount);// 实际金额,减去所有优惠金额
return itemEntity;
}
/**
* 汇总封装订单
* 1.计算订单总金额
* 2.汇总积分、成长值
* 3.汇总应付总额 = 订单总金额 + 运费
*
* @param orderEntity 订单
* @param orderItemEntities 订单项
*/
private void summaryFillOrder(OrderEntity orderEntity, List<OrderItemEntity> orderItemEntities) {
// 1.订单总额、促销总金额、优惠券总金额、积分优惠总金额
BigDecimal total = new BigDecimal(0);
BigDecimal coupon = new BigDecimal(0);
BigDecimal promotion = new BigDecimal(0);
BigDecimal integration = new BigDecimal(0);
// 2.积分、成长值
Integer giftIntegration = 0;
Integer giftGrowth = 0;
for (OrderItemEntity itemEntity : orderItemEntities) {
total = total.add(itemEntity.getRealAmount());// 订单总额
coupon = coupon.add(itemEntity.getCouponAmount());// 促销总金额
promotion = promotion.add(itemEntity.getPromotionAmount());// 优惠券总金额
integration = integration.add(itemEntity.getIntegrationAmount());// 积分优惠总金额
giftIntegration = giftIntegration + itemEntity.getGiftIntegration();// 积分
giftGrowth = giftGrowth + itemEntity.getGiftGrowth();// 成长值
}
orderEntity.setTotalAmount(total);
orderEntity.setCouponAmount(coupon);
orderEntity.setPromotionAmount(promotion);
orderEntity.setIntegrationAmount(integration);
orderEntity.setIntegration(giftIntegration);// 积分
orderEntity.setGrowth(giftGrowth);// 成长值
// 3.应付总额
orderEntity.setPayAmount(orderEntity.getTotalAmount().add(orderEntity.getFreightAmount()));// 订单总额 + 运费
}
/**
* 保存订单
* 将封装生成的订单对象 + 订单项对象持久化到DB
* @param order
*/
private void saveOrder(OrderCreateTO order) {
// 1.持久化订单对象
OrderEntity orderEntity = order.getOrder();
save(orderEntity);
// 2.持久化订单项对象
List<OrderItemEntity> itemEntities = order.getOrderItems();
orderItemService.saveBatch(itemEntities);
}
}
7.1.2锁定库存
描述:
所有商品锁定成功即成功,任一商品锁定失败创建订单回滚
这里没有给出代码,可以查看【第二版】
7.2.第二版(柔性事务)
7.2.1.实现方案
创建订单是高并发场景,不采用Seata(默认Seata是采用AT模式【2PC模式的变种】,性能低)
采用方案:【柔性事务】
保证AP,采用本地事务+延时队列+监听死信队列解锁库存 的方案实现最终一致性
订单模块一个延时队列+死信队列,用于30min关闭订单
库存模块一个延时队列+死信队列,用于40min解锁库存
优化:
可靠消息+最终一致性:
锁库存时,往队列发送一条库存解锁消息(在队列中设置超时时间而不是在消息中设置,具体查看MQ.md)
消息超时后经过死信路由到达延时队列,解锁库存service监听延时队列,查询订单状态判断是否需要解锁库存
7.2.2.实现步骤
延时队列、死信队列
order模块队列
1.order创建关闭订单的延时队列、死信队列、交换机、绑定关系
/**
* 创建队列,交换机,延时队列,绑定关系 的configuration
* Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖
*/
@Configuration
public class MyRabbitMQConfig {
/**
* 延时队列
*/
@Bean
public Queue orderDelayQueue() {
/**
* Queue(String name, 队列名字
* boolean durable, 是否持久化
* boolean exclusive, 是否排他
* boolean autoDelete, 是否自动删除
* Map<String, Object> arguments) 属性【TTL、死信路由、死信路由键】
*/
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", "order-event-exchange");// 死信路由
arguments.put("x-dead-letter-routing-key", "order.release.order");// 死信路由键
arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟
Queue queue = new Queue("order.delay.queue", true, false, false, arguments);
return queue;
}
/**
* 交换机(死信路由)
*/
@Bean
public Exchange orderEventExchange() {
return new TopicExchange("order-event-exchange", true, false);
}
/**
* 死信队列
*/
@Bean
public Queue orderReleaseQueue() {
Queue queue = new Queue("order.release.order.queue", true, false, false);
return queue;
}
/**
* 绑定:交换机与延迟队列
*/
@Bean
public Binding orderCreateBinding() {
/**
* String destination, 目的地(队列名或者交换机名字)
* DestinationType destinationType, 目的地类型(Queue、Exhcange)
* String exchange,
* String routingKey,
* Map<String, Object> arguments
**/
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
/**
* 绑定:交换机与死信队列
*/
@Bean
public Binding orderReleaseBinding() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
/**
* 绑定:订单释放直接和库存释放进行
*/
@Bean
public Binding orderReleaseOtherBinding() {
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.other.#",
null);
}
}
ware模块队列
1.ware模块导入mq依赖
<!--amqp高级消息队列协议,rabbitmq实现-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2.ware模块导入配置
spring:
rabbitmq:
host: 192.168.56.10
port: 5672
# 虚拟主机
virtual-host: /
# 开启发送端发送确认,无论是否到达broker都会触发回调【发送端确认机制+本地事务表】
publisher-confirm-type: correlated
# 开启发送端抵达队列确认,消息未被队列接收时触发回调【发送端确认机制+本地事务表】
publisher-returns: true
# 消息在没有被队列接收时是否强行退回
template:
mandatory: true
# 消费者手动确认模式,关闭自动确认,否则会消息丢失
listener:
simple:
acknowledge-mode: manual
3.添加注解
// 开启rabbit
@EnableRabbit
4.创建配置类
/**
* @Author: wanzenghui
* @Date: 2021/12/15 0:04
*/
@Configuration
public class MyRabbitConfig {
@Autowired
RabbitTemplate rabbitTemplate;
@Bean
public MessageConverter messageConverter() {
// 使用json序列化器来序列化消息,发送消息时,消息对象会被序列化成json格式
return new Jackson2JsonMessageConverter();
}
/**
* 定制RabbitTemplate
* 1、服务收到消息就会回调
* 1、spring.rabbitmq.publisher-confirms: true
* 2、设置确认回调
* 2、消息正确抵达队列就会进行回调
* 1、spring.rabbitmq.publisher-returns: true
* spring.rabbitmq.template.mandatory: true
* 2、设置确认回调ReturnCallback
* <p>
* 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)
*/
@PostConstruct // (MyRabbitConfig对象创建完成以后,执行这个方法)
public void initRabbitTemplate() {
/**
* 发送消息触发confirmCallback回调
* @param correlationData:当前消息的唯一关联数据(如果发送消息时未指定此值,则回调时返回null)
* @param ack:消息是否成功收到(ack=true,消息抵达Broker)
* @param cause:失败的原因
*/
rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
System.out.println("发送消息触发confirmCallback回调" +
"\ncorrelationData ===> " + correlationData +
"\nack ===> " + ack + "" +
"\ncause ===> " + cause);
System.out.println("=================================================");
});
/**
* 消息未到达队列触发returnCallback回调
* 只要消息没有投递给指定的队列,就触发这个失败回调
* @param message:投递失败的消息详细信息
* @param replyCode:回复的状态码
* @param replyText:回复的文本内容
* @param exchange:接收消息的交换机
* @param routingKey:接收消息的路由键
*/
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
// 需要修改数据库 消息的状态【后期定期重发消息】
System.out.println("消息未到达队列触发returnCallback回调" +
"\nmessage ===> " + message +
"\nreplyCode ===> " + replyCode +
"\nreplyText ===> " + replyText +
"\nexchange ===> " + exchange +
"\nroutingKey ===> " + routingKey);
System.out.println("==================================================");
});
}
}
5.创建ware解锁库存的延时队列、死信队列、交换机、绑定关系
/**
* 创建队列,交换机,延时队列,绑定关系 的configuration
* 1.Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖
* 2.懒加载,只有第一次使用的时候才会创建(例如监听队列)
*/
@Configuration
public class MyRabbitMQConfig {
/**
* 用于首次创建队列、交换机、绑定关系的监听
* @param message
*/
@RabbitListener(queues = "stock.release.stock.queue")
public void handle(Message message) {
}
/**
* 交换机
* Topic,可以绑定多个队列
*/
@Bean
public Exchange stockEventExchange() {
//String name, boolean durable, boolean autoDelete, Map<String, Object> arguments
return new TopicExchange("stock-event-exchange", true, false);
}
/**
* 死信队列
*/
@Bean
public Queue stockReleaseStockQueue() {
//String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments
return new Queue("stock.release.stock.queue", true, false, false);
}
/**
* 延时队列
*/
@Bean
public Queue stockDelay() {
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", "stock-event-exchange");
arguments.put("x-dead-letter-routing-key", "stock.release");
// 消息过期时间 2分钟
arguments.put("x-message-ttl", 120000);
return new Queue("stock.delay.queue", true, false, false,arguments);
}
/**
* 绑定:交换机与死信队列
*/
@Bean
public Binding stockLocked() {
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.release.#",
null);
}
/**
* 绑定:交换机与延时队列
*/
@Bean
public Binding stockLockedBinding() {
return new Binding("stock.delay.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.locked",
null);
}
}
7.2.3.解锁场景
场景:
1.下订单成功,用户手动取消 || 订单过期未支付
2.其他业务调用失败,订单回滚,但库存锁定成功(最终一致性,需要解锁库存)
7.2.4.锁定库存
1.锁定库存
2.往库存工作单存储当前锁定(本地事务表)
3.往延时队列发送库存锁定成功消息
/**
* 库存锁定,sql执行锁定锁定
*
* @param lockTO
* @return 锁定结果
* @Transactional(rollbackFor = NoStockException.class):指定的异常出现会导致回滚
* 未指定异常,任何运行时异常都会导致回滚,可以省略rollbackFor
*/
@Transactional
@Override
public Boolean orderLockStock(WareSkuLockTO lockTO) {
// 按照收货地址找到就近仓库,锁定库存(暂未实现)
// 采用方案:获取每项商品在哪些仓库有库存,轮询尝试锁定,任一商品锁定失败回滚
// 1.往库存工作单存储当前锁定(本地事务表)
WareOrderTaskEntity taskEntity = new WareOrderTaskEntity();
taskEntity.setOrderSn(lockTO.getOrderSn());
orderTaskService.save(taskEntity);
// 2.封装待锁定库存项Map
Map<Long, OrderItemVO> lockItemMap = lockTO.getLocks().stream().collect(Collectors.toMap(key -> key.getSkuId(), val -> val));
// 3.查询(库存 - 库存锁定 >= 待锁定库存数)的仓库
List<WareSkuEntity> wareEntities = baseMapper.selectListHasSkuStock(lockItemMap.keySet()).stream().filter(entity -> entity.getStock() - entity.getStockLocked() >= lockItemMap.get(entity.getSkuId()).getCount()).collect(Collectors.toList());
// 判断是否查询到仓库
if (CollectionUtils.isEmpty(wareEntities)) {
// 匹配失败,所有商品项没有库存
Set<Long> skuIds = lockItemMap.keySet();
throw new NoStockException(skuIds);
}
// 将查询出的仓库数据封装成Map,key:skuId val:wareId
Map<Long, List<WareSkuEntity>> wareMap = wareEntities.stream().collect(Collectors.groupingBy(key -> key.getSkuId()));
// 4.判断是否为每一个商品项至少匹配了一个仓库
List<WareOrderTaskDetailEntity> taskDetails = new ArrayList<>();// 库存锁定工作单详情
Map<Long, StockLockedTO> lockedMessageMap = new HashMap<>();// 库存锁定工作单消息
if (wareMap.size() < lockTO.getLocks().size()) {
// 匹配失败,部分商品没有库存
Set<Long> skuIds = lockItemMap.keySet();
skuIds.removeAll(wareMap.keySet());// 求商品项差集
throw new NoStockException(skuIds);
} else {
// 所有商品都存在有库存的仓库
// 5.锁定库存
for (Map.Entry<Long, List<WareSkuEntity>> entry : wareMap.entrySet()) {
Boolean skuStocked = false;
Long skuId = entry.getKey();// 商品
OrderItemVO item = lockItemMap.get(skuId);
Integer count = item.getCount();// 待锁定个数
List<WareSkuEntity> hasStockWares = entry.getValue();// 有足够库存的仓库
for (WareSkuEntity ware : hasStockWares) {
Long num = baseMapper.lockSkuStock(skuId, ware.getWareId(), count);
if (num == 1) {
// 锁定成功,跳出循环
skuStocked = true;
// 创建库存锁定工作单详情(每一件商品锁定详情)
WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity(null, skuId,
item.getTitle(), count, taskEntity.getId(), ware.getWareId(),
WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode());
taskDetails.add(taskDetail);
// 创建库存锁定工作单消息(每一件商品一条消息)
StockDetailTO detailMessage = new StockDetailTO();
BeanUtils.copyProperties(taskDetail, detailMessage);
StockLockedTO lockedMessage = new StockLockedTO(taskEntity.getId(), detailMessage);
lockedMessageMap.put(skuId, lockedMessage);
break;
}
}
if (!skuStocked) {
// 匹配失败,当前商品所有仓库都未锁定成功
throw new NoStockException(skuId);
}
}
}
// 6.往库存工作单详情存储当前锁定(本地事务表)
orderTaskDetailService.saveBatch(taskDetails);
// 7.发送消息
for (WareOrderTaskDetailEntity taskDetail : taskDetails) {
StockLockedTO message = lockedMessageMap.get(taskDetail.getSkuId());
message.getDetail().setId(taskDetail.getId());// 存储库存详情ID
rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", message);
}
return true;
}
7.2.5.生成订单
下单成功,往订单解锁延时队列发送消息
// 发送创建订单到延时队列
/**
* 创建订单
* GlobalTransactional:seata分布式事务,不适合高并发场景(默认基于AT实现)
*
* @param vo 收货地址、发票信息、使用的优惠券、备注、应付总额、令牌
*/
//@GlobalTransactional
@Transactional
@Override
public SubmitOrderResponseVO submitOrder(OrderSubmitVO orderSubmitVO) throws Exception {
SubmitOrderResponseVO result = new SubmitOrderResponseVO();// 返回值
// 创建订单线程共享提交数据
confirmVoThreadLocal.set(orderSubmitVO);
// 1.生成订单实体对象(订单 + 订单项)
OrderCreateTO order = createOrder();
// 2.验价应付金额(允许0.01误差,前后端计算不一致)
if (Math.abs(orderSubmitVO.getPayPrice().subtract(order.getPayPrice()).doubleValue()) >= 0.01) {
// 验价不通过
throw new VerifyPriceException();
}
// 验价成功
// 3.保存订单
saveOrder(order);
// 4.库存锁定(wms_ware_sku)
// 封装待锁定商品项TO
WareSkuLockTO lockTO = new WareSkuLockTO();
lockTO.setOrderSn(order.getOrder().getOrderSn());
List<OrderItemVO> locks = order.getOrderItems().stream().map((item) -> {
OrderItemVO lock = new OrderItemVO();
lock.setSkuId(item.getSkuId());
lock.setCount(item.getSkuQuantity());
lock.setTitle(item.getSkuName());
return lock;
}).collect(Collectors.toList());
lockTO.setLocks(locks);// 待锁定订单项
R response = wmsFeignService.orderLockStock(lockTO);
if (response.getCode() == 0) {
// 锁定成功
// TODO 5.远程扣减积分
// 封装响应数据返回
result.setOrder(order.getOrder());
//System.out.println(10 / 0); // 模拟订单回滚,库存不会滚
// 6.发送创建订单到延时队列
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", order.getOrder());
return result;
} else {
// 锁定失败
throw new NoStockException("");
}
}
bug_解锁订单晚于解锁库存执行
bug:
订单解锁晚于库存解锁执行导致库存永远不会被解锁
bug重现:
机器卡顿,订单解锁的消息延迟抵达,造成订单解锁晚于库存解锁执行,此时库存解锁失败,因为订单还处于未支付状态,导致库存未解锁,并且消息已经确认
解决方案:
方案一:
库存解锁消息重新入队(不建议,因为无法判断消息延迟的具体时间,造成消息空转浪费资源)
方案二:
消费订单解锁消息时,往库存解锁的死信队列丢一条消息(同时是消费者和生产者)
7.2.6.解锁订单
场景:
1.订单过期未支付
实现:
生成订单时创建消息放入延时队列
解锁订单方法监听死信队列
解锁订单时为了防止订单解锁晚于库存解锁的BUG,此时主动往解锁库存的死信队列发送一条消息
/**
* 创建队列,交换机,延时队列,绑定关系 的configuration
* 1.Broker中的Queue、Exchange、Binding不存在的情况下,会自动创建(在RabbitMQ),不会重复创建覆盖
* 2.懒加载,只有第一次使用的时候才会创建(例如监听队列)
*/
@Configuration
public class MyRabbitMQConfig {
/**
* 延时队列
*/
@Bean
public Queue orderDelayQueue() {
/**
* Queue(String name, 队列名字
* boolean durable, 是否持久化
* boolean exclusive, 是否排他
* boolean autoDelete, 是否自动删除
* Map<String, Object> arguments) 属性【TTL、死信路由、死信路由键】
*/
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", "order-event-exchange");// 死信路由
arguments.put("x-dead-letter-routing-key", "order.release.order");// 死信路由键
arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟
return new Queue("order.delay.queue", true, false, false, arguments);
}
/**
* 交换机(死信路由)
*/
@Bean
public Exchange orderEventExchange() {
return new TopicExchange("order-event-exchange", true, false);
}
/**
* 死信队列
*/
@Bean
public Queue orderReleaseQueue() {
return new Queue("order.release.order.queue", true, false, false);
}
/**
* 绑定:交换机与订单解锁延迟队列
*/
@Bean
public Binding orderCreateBinding() {
/**
* String destination, 目的地(队列名或者交换机名字)
* DestinationType destinationType, 目的地类型(Queue、Exhcange)
* String exchange,
* String routingKey,
* Map<String, Object> arguments
**/
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
/**
* 绑定:交换机与订单解锁死信队列
*/
@Bean
public Binding orderReleaseBinding() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
/**
* 绑定:交换机与库存解锁
*/
@Bean
public Binding orderReleaseOtherBinding() {
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.other.#",
null);
}
}
/**
* 定时关单,监听死信队列
* @Author: wanzenghui
* @Date: 2022/1/3 17:24
*/
@Slf4j
@RabbitListener(queues = "order.release.order.queue")
@Component
public class OrderCloseListener {
@Autowired
OrderService orderService;
@RabbitHandler
public void handleOrderRelease(OrderEntity order, Message message, Channel channel) throws IOException {
log.debug("订单解锁,订单号:" + order.getOrderSn());
try {
orderService.closeOrder(order);
// 手动删除消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
// 解锁失败 将消息重新放回队列,让别人消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
}
}
}
@Service("orderService")
public class OrderServiceImpl extends ServiceImpl<OrderDao, OrderEntity> implements OrderService {
/**
* 关闭订单
*/
@Override
public void closeOrder(OrderEntity order) {
OrderEntity _order = getById(order.getId());
if (OrderConstant.OrderStatusEnum.CREATE_NEW.getCode().equals(_order.getStatus())) {
// 待付款状态允许关单
OrderEntity temp = new OrderEntity();
temp.setId(order.getId());
temp.setStatus(OrderConstant.OrderStatusEnum.CANCLED.getCode());
updateById(temp);
// 发送消息给MQ
OrderTO orderTO = new OrderTO();
BeanUtils.copyProperties(_order, orderTO);
//TODO 确保每个消息发送成功,给每个消息做好日志记录,(给数据库保存每一个详细信息)保存每个消息的详细信息
//TODO 定期扫描数据库,重新发送失败的消息
rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTO);
}
}
}
/**
* 解锁库存,监听死信队列
*
* @author: wanzenghui
**/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Component
public class StockReleaseListener {
@Autowired
private WareSkuService wareSkuService;
/**
* 客户取消订单,监听到消息
*/
@RabbitHandler
public void handleOrderCloseRelease(OrderTO orderTo, Message message, Channel channel) throws IOException {
log.debug("订单关闭准备解锁库存,订单号:" + orderTo.getOrderSn());
try {
wareSkuService.unLockStock(orderTo);
// 手动删除消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
// 解锁失败 将消息重新放回队列,让别人消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
}
}
}
@Slf4j
@Service("wareSkuService")
public class WareSkuServiceImpl extends ServiceImpl<WareSkuDao, WareSkuEntity> implements WareSkuService {
/**
* 库存解锁
* 订单解锁触发,防止库存解锁消息优先于订单解锁消息到期,导致库存无法解锁
*/
@Transactional
@Override
public void unLockStock(OrderTO order) {
String orderSn = order.getOrderSn();// 订单号
// 1.根据订单号查询库存锁定工作单
WareOrderTaskEntity task = orderTaskService.getOrderTaskByOrderSn(orderSn);
// 2.按照工作单查询未解锁的库存,进行解锁
List<WareOrderTaskDetailEntity> taskDetails = orderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>()
.eq("task_id", task.getId())
.eq("lock_status", WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode()));// 并发问题
// 3.解锁库存
for (WareOrderTaskDetailEntity taskDetail : taskDetails) {
unLockStock(taskDetail.getSkuId(), taskDetail.getWareId(), taskDetail.getSkuNum(), taskDetail.getId());
}
}
/**
* 库存解锁
* 1.sql执行释放锁定
* 2.更新库存工作单状态为已解锁
*
* @param skuId
* @param wareId
* @param count
*/
public void unLockStock(Long skuId, Long wareId, Integer count, Long taskDetailId) {
// 1.库存解锁
baseMapper.unLockStock(skuId, wareId, count);
// 2.更新工作单的状态 已解锁
WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity();
taskDetail.setId(taskDetailId);
taskDetail.setLockStatus(WareOrderTaskConstant.LockStatusEnum.UNLOCKED.getCode());
orderTaskDetailService.updateById(taskDetail);
}
}
7.2.7.解锁库存
场景:
1.下订单成功,用户手动取消 || 订单过期未支付
2.订单回滚,其他业务调用失败,但库存锁定成功(最终一致性,解锁库存)
实现:
监听死信队列,拿到库存锁定工作单解锁库存(解锁时判断是否允许解锁)
/**
* 监听死信队列,解锁库存
* 库存解锁,监听
*
* @author: wanzenghui
**/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Component
public class StockReleaseListener {
@Autowired
private WareSkuService wareSkuService;
/**
* 库存解锁(监听死信队列)
* 场景:
* 1.下订单成功【需要解锁】(订单过期未支付、被用户手动取消、其他业务调用失败(订单回滚))
* 2.下订单失败【无需解锁】(库存锁定失败(库存锁定已回滚,但消息已发出))
* <p>
* 注意:需要开启手动确认,不要删除消息,当前解锁失败需要重复解锁
*/
@RabbitHandler
public void handleStockLockedRelease(StockLockedTO locked, Message message, Channel channel) throws IOException {
log.debug("收到解锁库存消息");
//当前消息是否重新派发过来
// Boolean redelivered = message.getMessageProperties().getRedelivered();
try {
// 解锁库存
wareSkuService.unLockStock(locked);
// 解锁成功,手动确认
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
} catch (Exception e) {
// 解锁失败,消息入队
channel.basicReject(message.getMessageProperties().getDeliveryTag(), true);
}
}
}
@Slf4j
@Service("wareSkuService")
public class WareSkuServiceImpl extends ServiceImpl<WareSkuDao, WareSkuEntity> implements WareSkuService {
@Autowired
WareOrderTaskServiceImpl orderTaskService;
@Autowired
WareOrderTaskDetailServiceImpl orderTaskDetailService;
@Autowired
OrderFeignService orderFeignService;
/**
* 库存解锁
*/
@Override
public void unLockStock(StockLockedTO locked) throws Exception {
StockDetailTO taskDetailTO = locked.getDetail();// 库存工作单详情TO
WareOrderTaskDetailEntity taskDetail = orderTaskDetailService.getById(taskDetailTO.getId());// 库存工作单详情Entity
if (taskDetail != null) {
// 1.工作单未回滚,需要解锁
WareOrderTaskEntity task = orderTaskService.getById(locked.getId());// 库存工作单Entity
R r = orderFeignService.getOrderByOrderSn(task.getOrderSn());// 订单Entity
if (r.getCode() == 0) {
// 订单数据返回成功
OrderTO order = r.getData(new TypeReference<OrderTO>() {
});
if (order == null || OrderConstant.OrderStatusEnum.CANCLED.getCode().equals(order.getStatus())) {
// 2.订单已回滚 || 订单未回滚已取消状态
if (WareOrderTaskConstant.LockStatusEnum.LOCKED.getCode().equals(taskDetail.getLockStatus())) {
// 订单已锁定状态,需要解锁(消息确认)
unLockStock(taskDetailTO.getSkuId(), taskDetailTO.getWareId(), taskDetailTO.getSkuNum(), taskDetailTO.getId());
} else {
// 订单其他状态,不可解锁(消息确认)
}
}
} else {
// 订单远程调用失败(消息重新入队)
throw new Exception();
}
} else {
// 3.无库存锁定工作单记录,已回滚,无需解锁(消息确认)
}
}
/**
* 库存解锁
* 1.sql执行释放锁定
* 2.更新库存工作单状态为已解锁
*
* @param skuId
* @param wareId
* @param count
*/
@Override
public void unLockStock(Long skuId, Long wareId, Integer count, Long taskDetailId) {
// 1.库存解锁
baseMapper.unLockStock(skuId, wareId, count);
// 2.更新工作单的状态 已解锁
WareOrderTaskDetailEntity taskDetail = new WareOrderTaskDetailEntity();
taskDetail.setId(taskDetailId);
taskDetail.setLockStatus(WareOrderTaskConstant.LockStatusEnum.UNLOCKED.getCode());
orderTaskDetailService.updateById(taskDetail);
}
}
bug_ware远程调用订单被登录拦截
// ware远程调用订单,请求头没有登录消息被拦截,应该放行
/**
* 登录拦截器
* 从session中获取了登录信息(redis中),封装到了ThreadLocal中
*
* @Author: wanzenghui
* @Date: 2021/12/20 22:29
*/
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberResponseVO> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行无需登录的请求
String uri = request.getRequestURI();
AntPathMatcher antPathMatcher = new AntPathMatcher();// 匹配器
boolean match = antPathMatcher.match("/order/order/status/**", uri);// 查询订单消息
boolean match1 = antPathMatcher.match("/payed/notify", uri);// 支付回调
if (match || match1) {
return true;
}
// 获取登录用户信息
MemberResponseVO attribute = (MemberResponseVO) request.getSession().getAttribute(AuthConstant.LOGIN_USER);
if (attribute != null) {
// 已登录,放行
// 封装用户信息到threadLocal
loginUser.set(attribute);
return true;
} else {
// 未登录,跳转登录页面
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
// session.setAttribute("msg", "请先进行登录");
// response.sendRedirect("http://auth.gulimall.com/login.html");
return false;
}
}
}
7.3.消息丢失、消息重复、消息积压
-
消息丢失:
- 情况1:网络连接失败,消息未抵达Broker
- 解决:发送消息时同时将消息持久化到MQ中并插入DB(DB消息状态为已抵达)
当出现异常时在catch处修改消息状态为错误抵达 - 情况2:消息抵达Broker,但为抵达queue,消息会丢失(只有抵达了queue消息才会持久化)
- 解决:开启生产者确认机制,将触发returnCallback.returnedMessage的消息DB状态修改为错误抵达
- 情况3:消费者未ack时宕机,导致消息丢失
- 解决:开启消费者手动ack
- 消息重复
- 情况1:业务逻辑已经执行,但是ack时宕机,消息由unack变为ready,消息重新入队
- 解决:将接口设计成幂等性,例如库存解锁时判断工作单的状态,已解锁则无操作
- 解决2:防重表
- 消息积压
-
情况1:生产者流量太大
-
解决:减慢发送消息速率(验证码、防刷、重定向、削峰)
-
情况2:消费者能力不足或宕机
-
解决:上线更多消费者
-
解决2:上线专门的队列消费服务,批量取出消息入库,离线处理业务慢慢处理
-
1.网络宕机修改mq_message消息状态
/**
* 关闭订单
*/
@Override
public void closeOrder(OrderEntity order) {
OrderEntity _order = getById(order.getId());
if (OrderConstant.OrderStatusEnum.CREATE_NEW.getCode().equals(_order.getStatus())) {
// 代付款状态允许关单
OrderEntity temp = new OrderEntity();
temp.setId(order.getId());
temp.setStatus(OrderConstant.OrderStatusEnum.CANCLED.getCode());
updateById(temp);
try {
// 发送消息给MQ
OrderTO orderTO = new OrderTO();
BeanUtils.copyProperties(_order, orderTO);
//TODO 持久化消息到mq_message表中,并设置消息状态为3-已抵达(保存日志记录)
rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTO);
} catch (Exception e) {
// TODO 消息为抵达Broker,修改mq_message消息状态为2-错误抵达
}
}
}
2.消息未抵达queue时修改mq_message消息状态
@Configuration
public class MyRabbitConfig {
/**
* 定制RabbitTemplate
* 1、服务收到消息就会回调
* 1、spring.rabbitmq.publisher-confirms: true
* 2、设置确认回调
* 2、消息正确抵达队列就会进行回调
* 1、spring.rabbitmq.publisher-returns: true
* spring.rabbitmq.template.mandatory: true
* 2、设置确认回调ReturnCallback
* <p>
* 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)
*/
@PostConstruct // (MyRabbitConfig对象创建完成以后,执行这个方法)
public void initRabbitTemplate() {
/**
* 消息未到达队列触发returnCallback回调
* 只要消息没有投递给指定的队列,就触发这个失败回调
* @param message:投递失败的消息详细信息
* @param replyCode:回复的状态码
* @param replyText:回复的文本内容
* @param exchange:接收消息的交换机
* @param routingKey:接收消息的路由键
*/
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
System.out.println("消息未到达队列触发returnCallback回调" +
"\nmessage ===> " + message +
"\nreplyCode ===> " + replyCode +
"\nreplyText ===> " + replyText +
"\nexchange ===> " + exchange +
"\nroutingKey ===> " + routingKey);
// TODO 修改mq_message,设置消息状态为2-错误抵达【后期定时器重发消息】
});
}
}
3.开启消费者手动确认,详见之前属性配置处
4.将消费者接口设计成幂等性防止重复消费
优化方案
可以添加一个消息服务,各模块调用发送消息API即可
实现消息存库+异常修改状态
思考:如果feign调用失败不会出现问题,做好本地事务(feign失败即回滚)+接口幂等性即可
8.支付
参考
支付.md
8.1.member模块
支付成功回调地址member模块,所以这里要作member的相关配置
1.上传静态资源(订单列表页,用于支付成功同步回调)
将订单页 文件夹下静态资源拷贝到=>/mydata/nginx/html/static/member
将orderList.html拷贝到member模块下
2.member模块增加登录拦截器 LoginUserInterceptor
增加拦截器配置类,将登录拦截器注册进去 WebMvcConfigurer
3.网关配置转发
- id: gulimall_member_route
uri: lb://gulimall-member
predicates:
- Host=member.gulimall.com
4.增加本地host映射
# gulimall
192.168.56.10 gulimall.com
192.168.56.10 search.gulimall.com
192.168.56.10 item.gulimall.com
192.168.56.10 auth.gulimall.com
192.168.56.10 cart.gulimall.com
192.168.56.10 order.gulimall.com
192.168.56.10 member.gulimall.com
5.Feign请求头丢失问题
订单支付成功回调:http://member.gulimall.com/memberOrder.html
此时浏览器访问时member是带了cookie的,但远程请求order查询订单数据时,请求头丢失
添加feign请求拦截器,封装请求头:GuliFeignConfig
8.1.1.同步回调
不建议在同步回调直接修改订单状态,推荐在异步回调的时候修改订单状态
8.1.2.异步回调
1.推荐在异步回调时修改订单状态
2.修改订单状态前要验签sign
程序执行完后必须打印输出“success”(不包含引号)。如果商户反馈给支付宝的字符不是 success 这7个字符,支付宝服务器会不断重发通知,直到超过 24 小时 22 分钟。一般情况下,25 小时以内完成 8 次通知(通知的间隔频率一般是:4m,10m,10m,1h,2h,6h,15h)。
8.2.内网穿透联调BUG
8.2.1.内网穿透
参考nps、npc相关文档搭建内网穿透
- bug1:使用nps作内网穿透,无法使用域名必须使用IP:PORT,所以会造成nginx无法根据访问的域名gulimall.com来匹配请求
- 解决:
- 方案一:修改nginx配置文件gulimall.conf监听server_name 124.223.7.41
- 解决:
- bug2:添加以上域名监听后,访问124.223.7.41:8888出现404异常
- 原因:网关88未拦截到请求
- 解决:
- 方案一:在网关增加拦截规则,拦截124.223.7.41,将请求发送到order.gulimall.com
- 方案二:在nginx转发时,设置host=order.gulimall.com,使网关可以正确拦截【推荐】
- 方案三:内网穿透的地址直接配成192.168.56.1:9000【缺点:没有负载均衡了】
bug1:
修改gulimall.conf
server {
listen 80;
server_name gulimall.com *.gulimall.com 124.223.7.41;
location /static/ {
root /usr/share/nginx/html;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
bug2:
方案一:
- id: gulimall_order_route2
uri: lb://gulimall-order
predicates:
- Host=124.223.7.41
方案二:
修改gulimall.conf
server {
listen 80;
server_name gulimall.com *.gulimall.com 124.223.7.41;
location /static/ {
root /usr/share/nginx/html;
}
location /payed/ {
proxy_set_header Host order.gulimall.com;
proxy_pass http://gulimall;
}
location / {
proxy_set_header Host $host;
proxy_pass http://gulimall;
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}
内网穿透配置:
查看nginx异常命令
cd /mydata/nginx/logs
cat error.log|grep 'payed'
9.收单
1.订单超时,不允许支付
解决:支付时设置超时时间:应该设置订单绝对超时时间,而不是30m,按照创建订单+30m来算截止时间
time_expire
2.订单解锁完成,异步通知才到
解决:释放库存的时候,手动调用收单功能(参照官方demo的实现)
九、秒杀模块
1.后台接口
1.1.【新增】秒杀场次
CREATE TABLE `sms_seckill_session` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`name` varchar(200) DEFAULT NULL COMMENT '场次名称',
`start_time` datetime DEFAULT NULL COMMENT '每日开始时间',
`end_time` datetime DEFAULT NULL COMMENT '每日结束时间',
`status` tinyint(1) DEFAULT NULL COMMENT '启用状态',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动场次';
@RestController
@RequestMapping("coupon/seckillsession")
public class SeckillSessionController {
@Autowired
private SeckillSessionService seckillSessionService;
/**
* 保存
*/
@RequestMapping("/save")
public R save(@RequestBody SeckillSessionEntity seckillSession){
seckillSessionService.save(seckillSession);
return R.ok();
}
}
1.2.【查询】指定场次关联的商品列表
http://localhost:88/api/coupon/seckillskurelation/list?t=1641391939514&page=1&limit=10&key=&promotionSessionId=1
@Service("seckillSkuRelationService")
public class SeckillSkuRelationServiceImpl extends ServiceImpl<SeckillSkuRelationDao, SeckillSkuRelationEntity> implements SeckillSkuRelationService {
@Override
public PageUtils queryPage(Map<String, Object> params) {
QueryWrapper<SeckillSkuRelationEntity> wrapper = new QueryWrapper<>();
String promotionSessionId = (String) params.get("promotionSessionId");
if (StringUtils.isNotBlank(promotionSessionId)) {
wrapper.eq("promotion_session_id", promotionSessionId);
}
IPage<SeckillSkuRelationEntity> page = this.page(
new Query<SeckillSkuRelationEntity>().getPage(params),
wrapper
);
return new PageUtils(page);
}
}
1.3.【新增】秒杀场次关联商品
CREATE TABLE `sms_seckill_sku_relation` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'id',
`promotion_id` bigint(20) DEFAULT NULL COMMENT '活动id',
`promotion_session_id` bigint(20) DEFAULT NULL COMMENT '活动场次id',
`sku_id` bigint(20) DEFAULT NULL COMMENT '商品id',
`seckill_price` decimal(10,4) DEFAULT NULL COMMENT '秒杀价格',
`seckill_count` int(11) DEFAULT NULL COMMENT '秒杀总量',
`seckill_limit` int(11) DEFAULT NULL COMMENT '每人限购数量',
`seckill_sort` int(11) DEFAULT NULL COMMENT '排序',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀活动商品关联';
/**
* 秒杀活动商品关联
*
* @author wanzenghui
* @email lemon_wan@aliyun.com
* @date 2021-09-02 22:43:18
*/
@RestController
@RequestMapping("coupon/seckillskurelation")
public class SeckillSkuRelationController {
@Autowired
private SeckillSkuRelationService seckillSkuRelationService;
/**
* 保存
*/
@RequestMapping("/save")
public R save(@RequestBody SeckillSkuRelationEntity seckillSkuRelation){
seckillSkuRelationService.save(seckillSkuRelation);
return R.ok();
}
}
2.新增秒杀模块
3.【定时上架】秒杀场次+商品
1.提前将要秒杀的商品上架到redis中(减少db压力)
从redis获取秒杀商品
实现:
使用定时任务,扫描第二天要秒杀的商品上架到redis中
2.秒杀商品的库存也上传到redis
从redis扣除库存(信号量的方式)
/**
* 定时任务
* @Description:
* @Created: with IntelliJ IDEA.
* @author: wanzenghui
* @createTime: 2020-07-09 19:22
*/
@Slf4j
@Service
public class SeckillScheduled {
@Autowired
SeckillService seckillService;
@Autowired
RedissonClient redissonClient;
/**
* 秒杀商品定时上架,保证幂等性问题
* 每天晚上3点,上架最近三天需要秒杀的商品
* 当天00:00:00 - 23:59:59
* 明天00:00:00 - 23:59:59
* 后天00:00:00 - 23:59:59
*/
@Scheduled(cron = "*/10 * * * * ? ")
//@Scheduled(cron = "0 0 3 * * ? ")
public void uploadSeckillSkuLatest3Days() {
// 重复上架无需处理
log.info("上架秒杀的商品...");
// 分布式锁(幂等性)
RLock lock = redissonClient.getLock(SeckillConstant.UPLOAD_LOCK);
try {
lock.lock(10, TimeUnit.SECONDS);
// 上架最近三天需要秒杀的商品
seckillService.uploadSeckillSkuLatest3Days();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
@Autowired
CouponFeignService couponFeignService;
@Autowired
ProductFeignService productFeignService;
@Autowired
StringRedisTemplate redisTemplate;
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
RedissonClient redissonClient;
/**
* 上架最近三天需要秒杀的商品
*/
@Override
public void uploadSeckillSkuLatest3Days() {
// 1.查询最近三天需要参加秒杀的场次+商品
R lates3DaySession = couponFeignService.getLates3DaySession();
if (lates3DaySession.getCode() == 0) {
// 获取场次
List<SeckillSessionWithSkusTO> sessions = lates3DaySession.getData("data", new TypeReference<List<SeckillSessionWithSkusTO>>() {
});
// 2.上架场次信息
saveSessionInfos(sessions);
// 3.上架商品信息
saveSessionSkuInfo(sessions);
}
}
/**
* 上架场次
*/
private void saveSessionInfos(List<SeckillSessionWithSkusTO> sessions) {
if (!CollectionUtils.isEmpty(sessions)) {
sessions.stream().forEach(session -> {
// 1.遍历场次
long startTime = session.getStartTime().getTime();// 场次开始时间戳
long endTime = session.getEndTime().getTime();// 场次结束时间戳
String key = SeckillConstant.SESSION_CACHE_PREFIX + startTime + "_" + endTime;// 场次的key
// 2.判断场次是否已上架(幂等性)
Boolean hasKey = redisTemplate.hasKey(key);
if (!hasKey) {
// 未上架
// 3.封装场次信息
List<String> skuIds = session.getRelationSkus().stream()
.map(item -> item.getPromotionSessionId() + "_" + item.getSkuId().toString())
.collect(Collectors.toList());// skuId集合
// 4.上架
redisTemplate.opsForList().leftPushAll(key, skuIds);
}
});
}
}
/**
* 上架商品信息
*/
private void saveSessionSkuInfo(List<SeckillSessionWithSkusTO> sessions) {
if (!CollectionUtils.isEmpty(sessions)) {
// 查询所有商品信息
List<Long> skuIds = new ArrayList<>();
sessions.stream().forEach(session -> {
List<Long> ids = session.getRelationSkus().stream().map(SeckillSkuVO::getSkuId).collect(Collectors.toList());
skuIds.addAll(ids);
});
R info = productFeignService.getSkuInfos(skuIds);
if (info.getCode() == 0) {
// 将查询结果封装成Map集合
Map<Long, SkuInfoTO> skuMap = info.getData(new TypeReference<List<SkuInfoTO>>() {
}).stream().collect(Collectors.toMap(SkuInfoTO::getSkuId, val -> val));
// 绑定秒杀商品hash
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(SeckillConstant.SECKILL_CHARE_KEY);
// 1.遍历场次
sessions.stream().forEach(session -> {
// 2.遍历商品
session.getRelationSkus().stream().forEach(seckillSku -> {
// 判断商品是否已上架(幂等性)
String skuKey = seckillSku.getPromotionSessionId().toString() + "_" + seckillSku.getSkuId().toString();// 商品的key(需要添加场次ID前缀,同一款商品可能场次不同)
if (!operations.hasKey(skuKey)) {
// 未上架
// 3.封装商品信息
SeckillSkuRedisTO redisTo = new SeckillSkuRedisTO();// 存储到redis的对象
SkuInfoTO sku = skuMap.get(seckillSku.getSkuId());
BeanUtils.copyProperties(seckillSku, redisTo);// 商品秒杀信息
redisTo.setSkuInfo(sku);// 商品详细信息
redisTo.setStartTime(session.getStartTime().getTime());// 秒杀开始时间
redisTo.setEndTime(session.getEndTime().getTime());// 秒杀结束时间
// 商品随机码:用户参与秒杀时,请求需要带上随机码(防止恶意攻击)
String token = UUID.randomUUID().toString().replace("-", "");// 商品随机码(随机码只会在秒杀开始时暴露)
redisTo.setRandomCode(token);// 设置商品随机码
// 4.上架商品(序列化成json格式存入Redis中)
String jsonString = JSONObject.toJSONString(redisTo);
operations.put(skuKey, jsonString);
// 5.上架商品的分布式信号量,key:商品随机码 值:库存(限流)
RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + token);
// 信号量(扣减成功才进行后续操作,否则快速返回)
semaphore.trySetPermits(seckillSku.getSeckillCount());
}
});
});
}
}
}
}
4.【查询】当前可参与的秒杀商品列表
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
@Autowired
CouponFeignService couponFeignService;
@Autowired
ProductFeignService productFeignService;
@Autowired
StringRedisTemplate redisTemplate;
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
RedissonClient redissonClient;
/**
* 获取到当前可以参加秒杀商品的信息
*/
//@SentinelResource(value = "getCurrentSeckillSkusResource", blockHandler = "blockHandler")
@Override
public List<SeckillSkuRedisTO> getCurrentSeckillSkus() {
//try (Entry entry = SphU.entry("seckillSkus")) {
// 1.查询当前时间所属的秒杀场次
long currentTime = System.currentTimeMillis();// 当前时间
// 查询所有秒杀场次的key
Set<String> keys = redisTemplate.keys(SeckillConstant.SESSION_CACHE_PREFIX + "*");// keys seckill:sessions:*
for (String key : keys) {
//seckill:sessions:1594396764000_1594453242000
String replace = key.replace(SeckillConstant.SESSION_CACHE_PREFIX, "");// 截取时间,去掉前缀
String[] time = replace.split("_");
long startTime = Long.parseLong(time[0]);// 开始时间
long endTime = Long.parseLong(time[1]);// 截止时间
// 判断是否处于该场次
if (currentTime >= startTime && currentTime <= endTime) {
// 2.查询当前场次信息(查询结果List< sessionId_skuId > )
List<String> sessionIdSkuIds = redisTemplate.opsForList().range(key, -100, 100);// 获取list范围内100条数据
// 获取商品信息
BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);
assert sessionIdSkuIds != null;
// 根据List< sessionId_skuId >从Map中批量获取商品信息
List<String> skus = skuOps.multiGet(sessionIdSkuIds);
if (!CollectionUtils.isEmpty(skus)) {
// 将商品信息反序列成对象
List<SeckillSkuRedisTO> skuInfos = skus.stream().map(sku -> {
SeckillSkuRedisTO skuInfo = JSON.parseObject(sku.toString(), SeckillSkuRedisTO.class);
// redisTo.setRandomCode(null);当前秒杀开始需要随机码
return skuInfo;
}).collect(Collectors.toList());
return skuInfos;
}
// 3.匹配场次成功,退出循环
break;
}
}
//} catch (BlockException e) {
// log.error("资源被限流{}", e.getMessage());
//}
return null;
}
}
5.【查询】商品详情页展示秒杀信息
// product模块获取商品详情的时候,同时查询该商品的秒杀信息
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
@Autowired
CouponFeignService couponFeignService;
@Autowired
ProductFeignService productFeignService;
@Autowired
StringRedisTemplate redisTemplate;
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
RedissonClient redissonClient;
/**
* 根据skuId查询商品当前时间秒杀信息
*
* @param skuId
*/
@Override
public SeckillSkuRedisTO getSkuSeckilInfo(Long skuId) {
// 1.匹配查询当前商品的秒杀信息
BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);
// 获取所有商品的key:sessionId_
Set<String> keys = skuOps.keys();
if (!CollectionUtils.isEmpty(keys)) {
String lastIndex = "_" + skuId;
for (String key : keys) {
if (key.lastIndexOf(lastIndex) > -1) {
// 商品id匹配成功
String jsonString = skuOps.get(key);
// 进行序列化
SeckillSkuRedisTO skuInfo = JSON.parseObject(jsonString, SeckillSkuRedisTO.class);
Long currentTime = System.currentTimeMillis();
Long endTime = skuInfo.getEndTime();
if (currentTime <= endTime) {
// 当前时间小于截止时间
Long startTime = skuInfo.getStartTime();
if (currentTime >= startTime) {
// 返回当前正处于秒杀的商品信息
return skuInfo;
}
// 返回预告信息,不返回随机码
skuInfo.setRandomCode(null);// 随机码
return skuInfo;
}
}
}
}
return null;
}
}
6.秒杀抢购
6.1.高并发需关注的问题
- 1.单一职责
- 2.秒杀链接加密
- 随机码,秒杀开始才暴露
- 3.库存预热+快速扣减(redis存储库存信号量,最终正常进入购物车的流量最多是库存数)
- 按照库存信号量原子扣减
- 4.动静分离
- nginx/CDN
- 5.恶意请求拦截
- 网关层按照访问次数拦截脚本请求【异常请求】
- 6.流量错峰
- 【最重要是体现在秒杀开始的那一刻的错峰】判断登录状态、输入验证码、加入购物车、提交订单
- 7.限流&熔断&降级
- 前端限流:间隔1秒允许点击
- 后端限流:
- 限制次数:同一个用户10次放行2次
- 限制总量:秒杀服务峰值处理能力10万,网关层放行不得超过10万,超过的等待两秒放行
- 熔断:A->B->C,链路中B总是失败,则下次调用时直接返回错误不调用B
- 降级:流量太大,秒杀模块将流量引导到降级页面,服务繁忙页【正常请求】
- 8.队列削峰(杀手锏)
- 扣减库存信号量成功的秒杀信息存入队列,订单系统监听队列创建订单(按照自己的处理能力消费)
6.2.【秒杀】队列削峰
两种方案:
方案一:
加入购物车(仍然走购物车流程,但价格按照秒杀价格计算),创建订单、锁定库存
优点:只需要做好适配,无大改动
缺点:将秒杀的流量带给了其他模块
方案二:【采用方案二,队列削峰】
直接发送MQ消息,订单根据消息创建订单(不需要锁定库存,库存预热了【信号量】),订单关闭增加信号量
优点:没有将秒杀的压力分担给其他模块,只有校验合法性没有远程调用、db操作
缺点:订单等模块需要提供监听消费信息创建订单,如果订单崩了,会导致支付失败
假设一个请求50ms,一个线程1s能处理20个请求
Tomcat开启500个线程,1s能处理10000个请求
方案1:
方案2:
/**
* @Description:
* @Created: with IntelliJ IDEA.
* @author: wanzenghui
* @createTime: 2020-07-09 19:29
**/
@Slf4j
@Service
public class SeckillServiceImpl implements SeckillService {
@Autowired
StringRedisTemplate redisTemplate;
@Autowired
RabbitTemplate rabbitTemplate;
@Autowired
RedissonClient redissonClient;
/**
* 秒杀商品
* 1.校验登录状态
* 2.校验秒杀时间
* 3.校验随机码、场次、商品对应关系
* 4.校验信号量扣减,校验购物数量是否限购
* 5.校验是否重复秒杀(幂等性)【秒杀成功SETNX占位 userId_sessionId_skuId】
* 6.扣减信号量
* 7.发送消息,创建订单号和订单信息
* 8.订单模块消费消息,生成订单
* @param killId sessionId_skuid
* @param key 随机码
* @param num 商品件数
*/
@Override
public String kill(String killId, String key, Integer num) throws InterruptedException {
// TODO 1.拦截器校验登录状态
long start = System.currentTimeMillis();
// 获取当前用户信息
MemberResponseVO user = LoginUserInterceptor.loginUser.get();
// 获取当前秒杀商品的详细信息
BoundHashOperations<String, String, String> skuOps = redisTemplate.boundHashOps(SECKILL_CHARE_KEY);
String jsonString = skuOps.get(killId);// 根据sessionId_skuid获取秒杀商品信息
if (StringUtils.isEmpty(jsonString)) {
// 这一步已经默认校验了场次+商品,如果为空表示校验失败
return null;
}
// json反序列化商品信息
SeckillSkuRedisTO skuInfo = JSON.parseObject(jsonString, SeckillSkuRedisTO.class);
Long startTime = skuInfo.getStartTime();
Long endTime = skuInfo.getEndTime();
long currentTime = System.currentTimeMillis();
// TODO 2.校验秒杀时间
if (currentTime >= startTime && currentTime <= endTime) {
// TODO 3.校验随机码
String randomCode = skuInfo.getRandomCode();// 随机码
if (randomCode.equals(key)) {
// 获取每人限购数量
Integer seckillLimit = skuInfo.getSeckillLimit();
// 获取信号量
String seckillCount = redisTemplate.opsForValue().get(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);
Integer count = Integer.valueOf(seckillCount);
// TODO 4.校验信号量(库存是否充足)、校验购物数量是否限购
if (num > 0 && num <= seckillLimit && count > num) {
// TODO 5.校验是否重复秒杀(幂等性)【秒杀成功后占位,userId-sessionId-skuId】
// SETNX 原子性处理
String userKey = SeckillConstant.SECKILL_USER_PREFIX + user.getId() + "_" + killId;
// 自动过期时间(活动结束时间 - 当前时间)
Long ttl = endTime - currentTime;
Boolean isRepeat = redisTemplate.opsForValue().setIfAbsent(userKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (isRepeat) {
// 占位成功
// TODO 6.扣减信号量
RSemaphore semaphore = redissonClient.getSemaphore(SeckillConstant.SKU_STOCK_SEMAPHORE + randomCode);
boolean isAcquire = semaphore.tryAcquire(num, 100, TimeUnit.MILLISECONDS);
if (isAcquire) {
// 信号量扣减成功,秒杀成功,快速下单
// TODO 7.发送消息,创建订单号和订单信息
// 秒杀成功 快速下单 发送消息到 MQ 整个操作时间在 10ms 左右
String orderSn = IdWorker.getTimeId();// 订单号
SeckillOrderTO order = new SeckillOrderTO();// 订单
order.setOrderSn(orderSn);// 订单号
order.setMemberId(user.getId());// 用户ID
order.setNum(num);// 商品上来给你
order.setPromotionSessionId(skuInfo.getPromotionSessionId());// 场次id
order.setSkuId(skuInfo.getSkuId());// 商品id
order.setSeckillPrice(skuInfo.getSeckillPrice());// 秒杀价格
// TODO 需要保证可靠消息,发送者确认+消费者确认(本地事务的形式)
rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", order);
long end = System.currentTimeMillis();
log.info("秒杀成功,耗时..." + (end - start));
return orderSn;
}
}
}
}
}
long end = System.currentTimeMillis();
log.info("秒杀失败,耗时..." + (end - start));
return null;
}
}
6.3.TODO释放信号量
1.接收创建秒杀订单的队列也应该做成延时队列,超时未支付,消息进入死信队列释放订单
2.监听释放订单的消费者,释放订单后,发送一条释放信号量的信息到释放信号量的死信队列
3.监听释放信号量的死信队列,逻辑跟释放库存一样(释放订单产生一条释放库存的消息,延时队列产生一条释放库存的消息)
6.4.TODO释放库存
场次超时后,将信号量归还到库存
十、熔断、限流、链路追踪
1.整合步骤
1.1.定义资源
多种定义资源的方法
1.主流框架适配,例如适配feign后所有feign请求都是资源【限流、降级后,触发熔断fallback】
spring所有controller请求都是资源【限流、降级后,触发UrlBlockSentinelHandler处理】
适配gateway后,所有routes都是资源【限流、降级后,触发UrlBlockSentinelHandler处理】
2.自定义资源,使用try{}catch{}【限流、降级后,在catch中处理】
3.注解定义资源,使用@SentinelResource(blockHandler = "blockHandlerForGetUser")【流、降级后,在blockHandlerForGetUser中处理】
1.2.定义规则
1.3.检验规则是否生效
2.整合sentinel
参考 12.熔断+降级+限流+链路追踪(sentinel).md
总结
后台请求和前台请求路由
后台请求都添加了/api/member/xxx
/api/coupon/xxx
/api/renren-fast/xx
前台请求都没有/api
结论:
1.gateway拦截后台请求的时候,要将/api/member/xx 请求拦截,然后将请求替换成/member/xx
2.gateway拦截前台请求按照host拦截,不按照url拦截,
例如order.gulimall.com
member.gulimall.com
单元测试
版本2.1.8:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
@RunWith(SpringRunner.class)// 使用spring驱动
@SpringBootTest
public class GulimallSearchApplicationTests {
@Autowired
private RestHighLevelClient client;
@Test
public void contextLoads() {
}
@Test
void testEs() {
System.out.println(client);
}
}
版本2.3.2:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
@SpringBootTest
class GulimallSearchApplicationTests {
@Autowired
private RestHighLevelClient client;
@Test
void testEs() {
System.out.println(client);
}
}
组件间调用R类型问题
1.方法1:(无法实现,以为R继承自HashMap,data作为私有属性无法使用)
设计返回类型R的时候加上泛型(feign调用时,springboot底层会根据泛型封装data类型)
public class R<T> extends HashMap<String, Object> {
private T data;
public T getDate() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
2.方法2:
controller返回数据类型不使用R,直接使用List<SkuHasStockTO>
3.方法3:
public class R<T> extends HashMap<String, Object> {
public R put(String key, Object value) {
super.put(key, value);
return this;
}
/**
* 封装数据
*/
public R setData(Object data) {
return put("data", data);
}
/**
* 解析数据
* 1.@ResponseBody返回类型被封装成了Json格式
* 2.feign接收参数时也会封装成json格式,data对象也被解析成json格式的数据([集合对象]或{map对象})
* 3.将data转成json字符串格式,然后再解析成对象
*/
public <T> T getData(TypeReference<T> type) {
Object data = get("data");
String jsonString = JSONObject.toJSONStringWithDateFormat(data, DateConstant.DATE_FORMAT);
return JSONObject.parseObject(jsonString, type);
}
}
feign调用源码
/**
* 1、构造请求数据,将对象转为json
* RequestTemplate template = buildTemplateFromArgs.create(argv);
* 2、发送请求进行执行:【执行成功会解码响应数据】
* excuteAndDecode(template)
* 3、执行请求会有重试机制
* while(true){
* try{
* excuteAndDecode(template)
* }catch() {
* try{
* // 默认重试5次【具体是否重试查看重试器的实现】
* retryer.continueOrPropagate(e);
* }catch() {
* throw ex;
* }
* continue;
* }
* }
*
*
*/
查询结果使用包装类型
mapper查询返回使用Long代替long
原因:查询结果为null时,无法为long封装null值
RedirectAttributes
作用:
重定向数据域
1.attributes.addFlashAttribute("errors", errMap);// flash,session中的数据只使用一次
2.attributes.addAttribute("skuId", skuId);// 会在url后面拼接参数
添加新模块步骤
1.网关转发配置
2.spring-session依赖
spring-session配置
spring-session注解(@EnableRedisHttpSession)
3.登录拦截 LoginUserInterceptor、WebMvcConfigurer
4.域名映射
5.添加feign拦截器:GuliFeignConfig
构造请求头,避免cookie丢失登录拦截
6.添加mybatis拦截器:MybatisConfig
分页查询时封装总记录数、总页码数