【云岚到家】-实战问题(上)
- 基础架构
- 项目涉及那些角色
- 云岚的业务流程?
- 云岚家政包括那些模块
- 项目采用什么架构
- 如何开发一个接口?
- RESTful风格的去定义一个接口
- 如何开发一个接口的service方法
- 接口的异常处理怎么实现的?
- Spring如何解决循环依赖?
- Feign和OpenFeign的区别?
- 微服务之间远程调用怎么实现的
- 微服务保护怎么做?
- 项目怎么实现微服务保护的?怎么实现熔断降级的?
- 项目中用到设计模式了吗,具体说说
- 怎么部署项目?
- 基础运营管理
- Mybatis-Plus有几种主键生成策略
- @Resource 和 @Autowired有什么区别?
- 项目的分页查询是怎么实现的?
- pagehelper分页组件的原理?
- 为什么使用 ThreadLocal?
- 服务管理/搜索模块
- 模块介绍
- 服务管理/搜索模块都有那些表
- SpringCache怎么用
- SpringCache原理
- 服务管理/搜索模块的Redis缓存方案如何
- 如何保证缓存一致性?
- xxl-job的工作原理
- xxl-job的路由策略及阻塞策略
- 缓存穿透,缓存击穿,缓存雪崩
- 什么是缓存穿透?如何解决缓存穿透?
- 什么是布隆过滤器?如何使用布隆过滤器?
- 什么是缓存击穿?如何解决缓存击穿?
- 什么是缓存雪崩?如何解决缓存雪崩?
- 项目为什么要用Elasticsearch?数据很多吗?
- 服务中ES搜索是怎么实现的
- Canal+MQ执行机制是什么
- Canal怎么伪装成从库?
- Canal数据不同步怎么处理?
- 在项目服务管理模块中MQ是怎么用的?
- MQ消息可靠性怎么保证
- MQ怎么避免重复消费?
- 如何保证Canal+MQ同步消息的顺序性
- 客户管理
- 本项目的认证方式有哪些?
- 小程序认证流程是什么
- 当前认证通过的用户信息保存到哪里了?
- 手机验证码认证流程是什么
- 手机验证码服务的实现方案?
- 如何开发小程序定位功能
- 门户
- 实现一个门户用到的技术方案有哪些?
- 预约下单
- 订单的状态有哪些?
- 订单表是怎么设计的?
- 常见的订单号生成规则有哪些?
- 分库分表方案
- 为什么要使用状态机
- 订单的状态机怎么实现的
- 为什么要使用快照
- 订单快照怎么实现的
- 使用redis对订单查询怎么优化的?
- 怎么防止订单重复提交
- 取消订单怎么实现的
- 取消订单的策略模式是什么
- 支付类策略模式
- 取消订单类策略模式
- @Transactional@GlobalTransactional的区别
- Service方法事务失效的原因是什么
基础架构
项目涉及那些角色
- 家政需求方:通过用户端小程序完成在线预约下单、支付、评价、投诉、退款等操作。
- 家政服务人员:通过服务端APP完成在线接单、结算等操作。
- 家政服务公司:通过机构端完成在线接单、派单、投诉处理、结算等操作。
- 平台方:通过管理端完成服务人员管理、机构管理、订单管理、财务管理等操作,一笔完成的订单,结算时按照分成比例平台进行抽成。
云岚的业务流程?
平台包括四个端:用户端(小程序)、服务端(app)、机构端(PC)、运营管理端(PC)。
- 用户通过平台在线下单、支付
- 家政服务人员和家政公司通过平台抢单
- 抢单成功服务人员去现场服务,平台跟进整个服务过程(服务前、服务中、服务后)。
- 服务完成后用户进行评价以及售后、退款等。
- 运营人员通过管理端完成服务人员管理、机构管理、订单管理等业务。
云岚家政包括那些模块
-
大综管理模块
- 企业管理:对机构的信息、认证进行管理。
- 客户管理:对c端用户的信息、用户的状态等信息进行管理。
- 服务人员管理:对服务人员的信息、认证等进行管理。
- 服务管理:对家政服务项目进行管理,最后在指定区域上架服务后用户可在当前区域购买。
-
业务相关模块
- 下单支付:用户通过小程序完成下单支付,进入小程序首页查询服务,用户选择服务,下单并支付。
- 抢单:服务人员和机构进行抢单。首先服务人员和机构设置接单范围、服务技能、开启抢单开关,然后进入抢单界面进行抢单。
- 派单调度:平台根据撮合匹配算法通过任务调度将订单和服务人员进行撮合匹配,促进成交。
- 订单管理:对订单的生命周期进行管理,包括创建订单、取消订单、删除订单、历史订单等。
- 营销管理:对优惠券活动进行管理。
项目采用什么架构
项目是基于Spring Cloud Alibaba框架构建的微服务项目,采用前后端分离模式进行开发。
平台共包括四个端:运营端(PC)、服务端(APP)、机构端(PC)、用户端(小程序)。
网关我们用的SpringCloudGateWay网关,最前边是Nginx进行负载均衡。
服务层我们划分了运营基础服务foundations、客户管理服务customer、公共服务public、订单管理服务orders-manager、抢单服务orders-seize、派单服务order-dispatch、支付服务trade等微服务。
服务层用到了Nacos、XXL-JOB、RabbitMQ、Elasticsearch、Canal、Sentinel等中间件。
数据层用的是MySQL数据库,使用ShardingShphere进行分库分表,使用TiDB分布式数据库存储历史订单数据,还用到了消息队列RabbitMQ、Redis缓存及Elasticsearch等中间件。
如何开发一个接口?
-
首先设计接口,再根据接口设计去定义接口并生成接口文档,前后端依据接口文档进行开发。
-
具体编码的过程通常先编写mapper接口,再实现service方法,最后编写controller方法。
-
每层的编码都会进行单元测试。
-
和前端约定后待双方都完成本模块的编码后进行前后端联调。
RESTful风格的去定义一个接口
我们使用的是SpringBoot定义Controller方法,接口设计包括7个方面,定义controller方法根据接口设计的内容进行定义:
-
根据HTTP请求方法使用不同的注解
HTTP方法与注解对应如下:
- @GetMapping注解表示GET方法。
- @PutMapping:put方法
- @DeleteMapping:delete方法
- @PostMapping:post方法
- @RequestMapping 可用于任何方法
-
在类及方法上设置接口路径
在类上使用**
@RequestMapping
**(根路径)指定根路径。在方法上指定具体的接口路径。 -
根据请求参数类型决定使用什么注解
-
当请求参数格式为
Content-Type: application/json
需要在方法参数前加**@RequestBody
**注解 -
当请求参数类型为from表单格式,如
Content-Type: application/x-www-form-urlencoded 或 multipart/form-data
不用添加此注解。通过表单提交数据时,表单的字段会以 URL 编码的形式包含在请求的正文中。
-
-
定义接口请求参数内容的DTO类型
- 定义与请求参数相同属性的DTO类型作为参数类型。
-
统一响应结果类型为json
- 使用**
RestController
**注解,它集成了ResponseBody
注解,ResponseBody注解的作用将响应结果转为json格式。 @RestController
是 Spring 4 引入的一个组合注解,它实际上是@Controller
和@ResponseBody
的组合。也就是说,@RestController
会自动为所有的方法添加@ResponseBody
,因此方法的返回值会被直接写入 HTTP 响应体中,而不需要额外的视图解析器。
- 使用**
-
响应结果状态码
没有特殊要求无需专门指定。
-
定义响应结果的DTO类型
将响应结果对象转成Json输出。
如何开发一个接口的service方法
service方法负责业务处理,是一个接口的核心,开发一个service方法需要注意以下几点:
-
接口职责要单一,一个接口不能过多的承担责任。
-
注意事务控制,对于更改数据库的操作要加@Transactional注解。
-
接口参数定义要简单明了,能用简单类型不要使用自定义类型。
-
对于增、删、改接口一定要做入参校验,其它接口根据情况而定。
-
接口的参数与返回值避免与其它框架耦合
- 比如:分页查询我们使用的是com.github.pagehelper分页插件,使用插件查询得到Page对象,Page类型是插件提供,避免service方法返回Page类型而是用我们自定义的类型PageResult对Page对象的数据进行封装返回,这样就避免Controller与pagehelper分页插件的耦合。
接口的异常处理怎么实现的?
mapper异常及service异常都抛给controller,controller方法的异常由异常处理器统一对异常进行处理。
-
我们抛出的异常是自定义异常类型,自定义的异常类型都继承了CommonException类型,在异常处理器中对此类型的异常进行处理。
-
通过**
@RestControllerAdvice
注解加@ExceptionHandler
注解实现,具体的原理是当controller
抛出异常由DispatcherServlet
统一拦截处理,再根据异常类型找到@ExceptionHandler
**标识方法去执行该方法进行异常处理。@RestControllerAdvice @Slf4j public class CommonExceptionAdvice {/*** 捕获feign异常* @param e* @return*/@ExceptionHandler({FeignException.class})public Result feignException(FeignException e) {ResponseUtils.setResponseHeader(BODY_PROCESSED, "1");Object headerValue = e.responseHeaders().get(HeaderConstants.INNER_ERROR);if(RequestUtils.getRequest().getRequestURL().toString().contains("/inner/")) {// 内部接口调用内部接口,异常抛出if(ObjectUtils.isNull(headerValue)) {throw new CommonException(ErrorInfo.Msg.REQUEST_FAILD);}else {String encodeMsg = JsonUtils.parseArray(headerValue).getStr(0);String[] msgs = Base64Utils.decodeStr(encodeMsg).split("\\|");throw new CommonException(NumberUtils.parseInt(msgs[0]), msgs[1]);}}else {// 外部接口调用内部接口异常捕获if(ObjectUtils.isNull(headerValue)) {return Result.error(ErrorInfo.Msg.REQUEST_FAILD);}else {String encodeMsg = JsonUtils.parseArray(headerValue).getStr(0);String[] msgs = Base64Utils.decodeStr(encodeMsg).split("\\|");return Result.error(NumberUtils.parseInt(msgs[0]), msgs[1]);}}}
Spring如何解决循环依赖?
Spring通过三级缓存对Bean延迟初始化解决循环依赖。
具体如下:
- singletonObjects缓存(一级缓存):这是 Spring 容器用来缓存完全初始化好的单例 bean 实例的缓存。
- earlySingletonObjects缓存(二级缓存):这个缓存是用来保存被实例化但还未完全初始化的 bean (半成品)的引用。
- singletonFactories缓存(三级缓存):这个缓存保存的是用于创建 bean 实例的 ObjectFactory,存储的是 用于创建 Bean 实例的
ObjectFactory
,用于支持循环依赖的延迟初始化。注册BeanDefinition
到BeanFactory
等待后续实例化。
-
Spring 通过这三级缓存的组合,来确保在循环依赖情况下,能够正常初始化 bean。当一个 bean 在初始化过程中需要依赖另一个还未初始化的 bean 时,Spring 会调用相应的 对象工厂来获取对应的 bean 半成品实例,这样就实现了循环依赖的延迟初始化。一旦 bean 初始化完成,它就会被移动到正式的单例缓存中。
-
对于通过构造方法注入导致循环依赖的在其中一个类的构造方法中使用**
@Lazy
**注解注入一个代理对象即可解决。
Feign和OpenFeign的区别?
-
Feign 是 Netflix 公司开发的一个独立的项目,在使用 Spring Cloud 时,需要单独引入 Feign 的依赖。
-
OpenFeign 是 Spring Cloud 对 Feign 进行了集成,并提供了对 Spring Cloud 注解的支持。
Feign 使用了一套自己的注解,例如 @FeignClient 用于声明一个 Feign 客户端,@RequestMapping 用于声明请求的映射等。
OpenFeign 则直接使用了 Spring MVC 注解,例如 @GetMapping、@PostMapping 等,这使得 OpenFeign 更加和 Spring 生态集成。
从spring boot 2.0之后基本上都是使用OpenFeign 了。
微服务之间远程调用怎么实现的
使用的Spring Cloud Alibaba框架,微服务之间远程调用使用OpenFeign,远程调用接口都集合在jzo2o-api的模块下,具体实现步骤如下:
-
引入依赖
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId> </dependency>
-
创建 Feign 客户端接口:定义一个接口,并使用
@FeignClient
注解来声明它是一个 Feign 客户端。@FeignClient(contextId = "jzo2o-foundations", value = "jzo2o-foundations", path = "/foundations/inner/serve") public interface ServeApi {@GetMapping("/{id}")ServeAggregationResDTO findById(@PathVariable("id") Long id);}
contextId
: 用于指定该 Feign 客户端的上下文标识。这个属性可以用来区分多个 Feign 客户端,尤其当你在一个应用中定义了多个 Feign 客户端时。value
: 这是 Feign 客户端的服务名称path
: 配置了该 Feign 客户端请求的基础路径
-
启用 Feign 客户端:服务调用方(客户端)依赖api工程,使用**
@EnableFeignClients
**注解扫描Feign接口,生成代理对象并放在Spring容器中。 -
在服务中调用 Feign 客户端:注入ServeApi即可,服务调用方(客户端)定义专门远程调用的客户端类,在客户端类中实现远程调用、熔断、降级逻辑,用sentinel再包装一层,做一下熔断处理和削峰。
微服务保护怎么做?
微服务之间相互调用,因为调用链中的一个服务故障,引起整个链路都无法访问会导致微服务雪崩。
常用的预防微服务雪崩的的方法:
- 超时处理:设定超时时间,请求超过一定时间没有响应就返回错误信息,不会无休止等待。
- 熔断降级:当服务的异常数或异常比例超过了预设的阈值时,熔断器会进入开启状态,暂时中断对该服务的请求,此时走降级方法,能够快速响应,确保系统的基本功能能够继续运行。
- 限流:限制对服务的请求速率,避免短时间内大量的请求导致系统崩溃。
- 线程池隔离:给要请求的资源分配一个线程池,通过线程池去控制请求数量
- 优点:主动超时、异步调用
- 缺点:线程切换开销大
- 信号量隔离:使用计数器模式,记录请求资源的并发线程数量,达到信号量上限时,禁止新的请求。信号量隔离适合同步请求,控制并发数,比如:对文件的下载并发数进行控制。
- 优点:轻量级、无额外开销
- 缺点:无主动超时、无异步调用
大多数场景都适合使用线程池隔离,对于需要同步操作控制并发数的场景可以使用信号量隔离。
项目怎么实现微服务保护的?怎么实现熔断降级的?
项目使用sentinel实现微服务保护,我们在Feign远程调用时进行熔断降级控制。
当远程调用发生异常首先走降级方法,当异常比较或异常数达到阈值将触发熔断,在熔断时间内不再走原来的方法而是走降级方法,可以快速进行响应。
当服务恢复后,熔断时间结束此时会再次尝试请求服务,如果成功请求将关闭熔断,恢复原来的链路。
具体方法:
- 在客户端使用使用**
@FeignClient
**注解定义远程调用接口 - 定义专门远程调用的客户端类实现远程调用、熔断、降级逻辑
- 使用**
@SentinelResource
**注解定义sentinel监控的资源,@SentinelResource
注解的属性具体包括。value
: 用于定义资源的名称,即 Sentinel 会对该资源进行流量控制和熔断降级。fallback
:非限流、熔断等导致的异常执行的降级方法blockHandler
:触发限流、熔断时执行的降级方法
@Component
@Slf4j
public class NativePayClient {@Resourceprivate NativePayApi nativePayApi;@SentinelResource(value = "createHealthDownLineTrading", fallback = "createHealthDownLineTradingFallback", blockHandler = "createHealthDownLineTradingBlockHandler")public NativePayResDTO createHealthDownLineTrading(NativePayReqDTO nativePayDTO) {log.error("扫码支付,收银员通过收银台或商户后台调用此接口,生成二维码后,展示给用户,由用户扫描二维码完成订单支付。");// 调用其他微服务方法NativePayResDTO nativePayResDTO = nativePayApi.createDownLineTrading(nativePayDTO);return nativePayResDTO;}//执行异常走public NativePayResDTO createHealthDownLineTradingFallback(Long id, Throwable throwable) {log.error("非限流、熔断等导致的异常执行的降级方法,id:{},throwable:", id, throwable);return null;}//熔断后的降级逻辑public NativePayResDTO createHealthDownLineTradingBlockHandler(Long id, BlockException blockException) {log.error("触发限流、熔断时执行的降级方法,id:{},blockException:", id, blockException);return null;}
}
项目中用到设计模式了吗,具体说说
-
单例模式:controller、service、mapper三层的bean都是单例。
-
工厂模式:spring容器就是一个工厂,在Spring中,BeanFactory 接口就是一个典型的工厂方法模式的例子。
-
策略模式:我们在开发取消订单业务时使用了策略模式,因为不同的场景执行取消订单的逻辑不同,我们使用使用策略模式实现提高了系统的扩展性。
- 首先定义策略接口
- 再定义不同的策略类即不同场景下取消订单的逻辑,每个策略类定义的bean名称为“用户类型:订单状态”。
- 在取消订单时通过拿到用户的类型及当前订单的状态,根据用户类型和订单状态得到具体的策略对象,执行该策略对象的取消订单方法。
怎么部署项目?
- 开发环境有一套持续集成的环境 ,使用的是k8s,开发人员提交代码由开发经理合并到测试分支通过持续集成的环境自动完成部署。
- 生产环境由运维人员负责部署。
- 有手动将微服务部署到Centos上的经历,先将项目打包,在centos上创建docker镜像,启动容器。
手动部署:
-
首先将项目打包
- 打开需要打包的项目,例如jzo2o-customer打包,双击install,编译完成后在Project窗口下就能看到jar包了。
-
编写Dockerfile文件
-
找到上传jar的路径,执行命令
vi Dockerfile
-
Dockerfile文件如下:**注意:**jar包一定要和Dockerfile文件放在同一个目录下
# 指定基础镜像 FROM openjdk:11-jdk # 指定容器时区 RUN echo "Asia/Shanghai" > /etc/timezone # 指定jar包路径 ARG PACKAGE_PATH=./jzo2o-customer.jar # 复制jar包到镜像中,并将名字改为 app.jar ADD ${PACKAGE_PATH:-./} app.jar # 启动时运行的命令 ENTRYPOINT ["sh","-c","java -jar $JAVA_OPTS app.jar"]
-
-
创建镜像,执行命令,如下:
docker build -t jzo2o-customer
-
创建容器,执行如下
docker run -d --restart always --net=host --name jzo2o-customer jzo2o-customer:latest
基础运营管理
Mybatis-Plus有几种主键生成策略
-
AUTO:基于数据库的自增主键
-
NONE: 不设置id生成策略
-
INPUT:用户手工输入id
-
ASSIGN_ID:雪花算法生成id(可兼容数值型与字符串型):1+41+10+12
-
ASSIGN_UUID:以UUID生成算法作为id生成策略
@Resource 和 @Autowired有什么区别?
-
来源不同
- @Resource是 Java EE 的规范化定义注解,不仅可以用于 Spring 环境,还可以用于其他 Java EE 容器。
- @Autowired 是 Spring 框架定义的注解,主要用于 Spring 环境中。
-
注入方式不同
-
@Resource默认按名称注入,如果在spring容器找不到对应名称的 Bean,则按照 byType(类型注入)进行注入。
-
@Autowired默认按照 byType 的方式进行注入。如果有多个类型相同的 Bean,可以结合 @Qualifier 使用指定具体的 Bean 名称。
// 使用 @Resource @Resource(name = "myBean") private MyBean myBean; // 使用 @Autowired @Autowired @Qualifier("myBean") private MyBean myBean;
-
项目的分页查询是怎么实现的?
项目共用了两个分页实现方法:
- mybatis-plus自带的分页方法,通过调用selectPage方法实现分页,适用于通过QueryWrapper拼装SQL。
- pagehelper分页组件适用于自定义sql的分页查询。
pagehelper分页组件的原理?
- 调用
PageHelper.startPage
方法设置分页参数,通过一层一层进入源码,最终将分页参数设置到ThreadLocal<Page> LOCAL_PAGE =newThreadLocal();
中。 - 通过
PageInterceptor
拦截器拦截 MyBatis的Executor的query() 方法得到原始的sql语句,首先得到count总数,然后从ThreadLocal中取出分页参数,在原始sql语句中添加分页参数查询分页数据。- 执行
SELECT COUNT(*) FROM users WHERE ...
查询,获取符合条件的记录总数。 - 修改原始 SQL 语句,加入
LIMIT 10 OFFSET 0
(因为是第一页,每页 10 条)。 - 执行修改后的 SQL 查询,返回前 10 条数据。
- 执行
- 每次分页查询之后分页组件清空了ThreadLocal中的分页参数,以免影响后边的SQL语句的执行。
为什么使用 ThreadLocal?
ThreadLocal
是一种线程本地存储机制,它允许每个线程存储独立的数据。
在 PageHelper
中,ThreadLocal
用于存储每个线程的分页信息,确保同一时间内多个线程执行分页查询时不会相互干扰。
这样,无论是多线程环境,还是同一个线程执行多个分页查询,分页参数都能被正确地管理。
服务管理/搜索模块
模块介绍
-
负责服务管理模块的设计和开发:包括服务分类、服务项、运营区域、服务上架、下架、热门服务配置等内容。服务上架后将服务信息缓存到Redis提高查询性能,使用Spring Cache操作缓存,对于热门服务使用xxl-job实现定时任务将数据库的信息同步到Redis避免缓存击穿。
-
服务搜索模块:使用Elasticsearch实现服务搜索,支持关键字搜索、分类搜索、关键字自动补全、自定义词库等功能。提供统一的搜索接口供前端调用,使用Canal+MQ将MySQL中的服务信息实时同步到Elasticsearch。
服务管理/搜索模块都有那些表
服务分类表:存储服务分类信息
服务项表:存储服务项信息
服务表:存储服务运营的相关信息,包括运营的地区、价格等运营相关的信息。
区域表:存储运营区域的信息。
完整区域表:来源于高德地图,因为我们的定位接口对接的高德地图,区域表的数据是从高德地图下载到。
SpringCache怎么用
使用Spring Cache的方法很简单,首先引入Spring Cache的依赖,根据缓存策略配置缓存管理器(可以配置缓存过期时间),在业务方法上添加Spring Cache的注解,指定缓存key和缓存管理器即可。Spring Cache 默认并不会自动过期缓存。你可以通过配置或自定义的缓存策略来设置缓存的过期时间,或者使用其他缓存框架(如 Redis)来实现自动失效。
常用的注解有:
- @EnableCaching:开启缓存注解功能
- @Cacheable:查询数据时缓存,将方法的返回值进行缓存
- @CacheEvict:用于删除缓存,将一条或多条数据从缓存中删除
- @CachePut:用于更新缓存,将方法的返回值放到缓存中
- @Caching:组合多个缓存注解,能够灵活地设置不同的缓存策略。它支持多个
@Cacheable
注解。
注解的详细属性:
-
value
:指定缓存的名称,通常是一个字符串或者数组,决定了缓存存储的位置。多个缓存可以使用不同的缓存名称。 -
key
:指定缓存项的键,通常是基于方法参数的表达式。如果不指定,Spring 会使用所有方法参数的哈希值生成一个默认的键。 -
condition
:指定一个条件,只有满足条件时才会进行缓存。例如,condition = "#userId > 10"
表示只有userId > 10
时才会缓存数据。 -
unless
:指定缓存的条件,只有不满足unless
的条件时才会缓存结果。例如,unless = "#result == null"
表示当方法返回值为null
时,不缓存该结果。
SpringCache原理
基于AOP原理,对添加注解**@Cacheable
**的类生成代理对象,在方法执行前查看是否有缓存对应的数据,如果有直接返回数据,如果没有调用源方法获取数据返回,并缓存起来。
@Cacheable(value = "users", key = "#userId")
public User getUserById(Long userId) {return userRepository.findById(userId);
}
-
当 Spring 启动时,如果在某个方法上加上了
@Cacheable
注解,Spring 会使用 AOP 动态代理的方式生成一个代理对象。 -
这个代理对象会拦截方法的调用,并加入缓存逻辑。Spring 会生成一个代理类,它会在调用方法之前、之后执行额外的缓存操作。
-
当调用
getUserById(1L)
时,Spring 会先通过 AOP 代理拦截这个调用,并根据value
(缓存名称)和key
(缓存的键,userId
)去查找是否存在缓存 -
代理对象首先查看缓存中是否存在以
userId
为键的数据,如果存在,直接返回缓存中的User
对象。 -
如果缓存中没有该数据,它会执行原始方法(即调用
userRepository.findById(userId)
)并返回结果。
服务管理/搜索模块的Redis缓存方案如何
-
服务信息缓存方案:
- 缓存结构:String
- 缓存时间:一天
- 缓存同步机制:上架服务添加缓存,下架服务删除缓存,修改服务修改缓存。
-
对热门服务进行缓存:
- 缓存结构:String
- 缓存时间:永不过期
- 缓存同步机制:定时任务将数据库中热门服务的信息同步Redis。
如何保证缓存一致性?
保证缓存一致性需要根据具体的需求来定:
-
对数据实时性有一定要求
-
对数据实时性有一定要求即数据库数据更新需要近实时查询到最新的数据,针对这种情况可采用延迟双删、Canal+MQ异步同步的方式。
-
延迟双删:在数据库更新前后,分别删除缓存,并在一定时间后再次删除缓存,防止并发问题。
// 更新数据库 updateDatabase(); // 删除缓存 redisTemplate.delete(cacheKey); // 延迟再删除 Thread.sleep(500); // 适当延迟,保证高并发情况下数据库已经更新 redisTemplate.delete(cacheKey);
-
Canal+MQ:利用 MySQL 的 binlog 监听数据变更,推送到 MQ(如 Kafka/RabbitMQ),由消费者更新缓存。
-
-
-
对数据实时性要求不高
- 使用定时任务的方式定时更新缓存。
- 定时任务:XXL-Job,定期同步数据库与缓存,减少缓存过期带来的问题。
- 适用场景:排行榜、统计数据、热点商品等无需强一致性的场景。
- 使用定时任务的方式定时更新缓存。
-
对数据实时性要求非常高
- 此类场景不适合用缓存,直接使用数据库即可。
- 优化方式:使用**读写分离、数据库分片、查询缓存(如 MySQL Query Cache)、分布式缓存(如 TiDB、CockroachDB)**等方案提升性能。
- 读写分离是数据库架构中的一种优化策略,将写操作(INSERT、UPDATE、DELETE)交给主库(Master),将读操作(SELECT)交给从库(Slave)。通过复制(如 MySQL 的主从复制)保证主从数据一致,提高系统的并发能力和读性能。
- 此类场景不适合用缓存,直接使用数据库即可。
注意:在使用缓存时不论采用哪种方式如果没有特殊要求一定要对key加过期时间,即使一段时间缓存不一致当缓存过期后最终数据是一致的。
xxl-job的工作原理
我们的是分布式系统,每个微服务部署多个实例,为了保证任务只在一个实例执行,使用了xxl-job由它统一调度。
-
安装 XXL-JOB 调度中心并在微服务中添加 XXL-JOB 依赖
-
新增配置类设置执行器,每个微服务实例需要配置一个执行器,执行器负责执行任务。
@Configuration public class XxlJobConfig {private Logger logger = LoggerFactory.getLogger(XxlJobConfig.class);@Value("${xxl.job.admin.addresses}")private String adminAddresses;@Value("${xxl.job.executor.appname}")private String appname;@Value("${xxl.job.executor.port}")private int port;@Beanpublic XxlJobSpringExecutor xxlJobExecutor() {logger.info(">>>>>>>>>>> xxl-job config init.");XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();xxlJobSpringExecutor.setAdminAddresses(adminAddresses);xxlJobSpringExecutor.setAppname(appname);xxlJobSpringExecutor.setPort(port);return xxlJobSpringExecutor;} }
-
定义任务并实现
@XxlJob
注解或在调度中心配置任务(重点配置任务的执行策略)@XxlJob("demoJobHandler")
: 这个注解标记的方法会被 XXL-JOB 执行。在调度中心中配置任务时,任务的执行方法就是所填写的方法名demoJobHandler
就是我们处理器的唯一表示符- 在这个方法中编写业务的实际逻辑。
@Component public class HelloJob {@XxlJob("demoJobHandler")public void helloJob(){System.out.println("简单任务执行了。。。。");} }
运行模式Bean,JobHandler就是我们这里的demoJobHandler
xxl-job的路由策略及阻塞策略
-
路由策略
- FIRST(第一个):固定选择第一个机器;
- LAST(最后一个):固定选择最后一个机器;
- ROUND(轮询)
- RANDOM(随机):随机选择在线的机器;
- CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
- LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;
- LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举;
- FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;
- BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;
- SHARDING_BROADCAST(分片广播):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;
阻塞处理策略
-
阻塞处理策略:调度过于密集执行器来不及处理时的处理策略;
- 单机串行(默认):调度请求进入单机执行器后,调度请求进入FIFO(First Input First Output)队列并以串行方式运行;
- 丢弃后续调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,本次请求将会被丢弃并标记为失败;
- 覆盖之前调度:调度请求进入单机执行器后,发现执行器存在运行的调度任务,将会终止运行中的调度任务并清空队列,然后运行本地调度任务;
缓存穿透,缓存击穿,缓存雪崩
什么是缓存穿透?如何解决缓存穿透?
缓存穿透是指请求一个不存在的数据,缓存层和数据库层都没有这个数据,这种请求会穿透缓存直接到数据库进行查询。它通常发生在一些恶意用户可能故意发起不存在的请求,试图让系统陷入这种情况,以耗尽数据库连接资源或者造成性能问题。
解决缓存穿透的方法有很多,常用的有:
- 对请求增加校验机制
- 缓存空值或特殊值
- 使用布隆过滤器
项目中对没有查询到数据的请求我们缓存了空值避免缓存穿透,具体实现是使用的Spring Cache。
使用Cacheable注解标记在service方法,并通过unless指定条件,根据返回值去控制缓存过期时间。
- 正常的数据我们设置了永不过期,我们会有一个定时任务去更新这部分的缓存。
- 对空列表数据为防止缓存穿透我们缓存了空值,缓存过期时间是30分钟。
@Override
@Caching(cacheable = {//result为null时,属于缓存穿透情况,缓存时间30分钟@Cacheable(value = RedisConstants.CacheName.SERVE_TYPE, key = "#regionId", unless = "#result.size() != 0", cacheManager = RedisConstants.CacheManager.THIRTY_MINUTES),//result不为null时,永久缓存@Cacheable(value = RedisConstants.CacheName.SERVE_TYPE, key = "#regionId", unless = "#result.size() == 0", cacheManager = RedisConstants.CacheManager.FOREVER)}
)
public List<ServeAggregationTypeSimpleResDTO> queryServeTypeListByRegionIdCache(Long regionId) {//1.校验当前城市是否为启用状态Region region = regionService.getById(regionId);if (ObjectUtil.equal(FoundationStatusEnum.DISABLE.getStatus(), region.getActiveStatus())) {return Collections.emptyList();}//2.根据城市编码查询服务对应的服务分类List<ServeAggregationTypeSimpleResDTO> list = serveService.findServeTypeListByRegionId(regionId);if (ObjectUtil.isEmpty(list)) {return Collections.emptyList();}return list;
}
CacheManager定义如下:
@Configuration
public class SpringCacheConfig {/*** 缓存时间30分钟** @param connectionFactory redis连接工厂* @return redis缓存管理器*/@Beanpublic RedisCacheManager cacheManager30Minutes(RedisConnectionFactory connectionFactory) {int randomNum = new Random().nextInt(100);RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(30 * 60L + randomNum)).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(JACKSON_SERIALIZER));return RedisCacheManager.builder(connectionFactory).cacheDefaults(config).transactionAware().build();}/*** 缓存时间1天** @param connectionFactory redis连接工厂* @return redis缓存管理器*/@Beanpublic RedisCacheManager cacheManagerOneDay(RedisConnectionFactory connectionFactory) {//生成随机数int randomNum = new Random().nextInt(6000);RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()//过期时间为基础时间加随机数.entryTtl(Duration.ofSeconds(24 * 60 * 60L + randomNum)).serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(JACKSON_SERIALIZER));return RedisCacheManager.builder(connectionFactory).cacheDefaults(config).transactionAware().build();}/*** 永久缓存** @param connectionFactory redis连接工厂* @return redis缓存管理器*/@Bean@Primarypublic RedisCacheManager cacheManagerForever(RedisConnectionFactory connectionFactory) {RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig().serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(JACKSON_SERIALIZER));return RedisCacheManager.builder(connectionFactory).cacheDefaults(config).transactionAware().build();}private static final Jackson2JsonRedisSerializer<Object> JACKSON_SERIALIZER;static {//定义Jackson类型序列化对象JACKSON_SERIALIZER = new Jackson2JsonRedisSerializer<>(Object.class);//解决查询缓存转换异常的问题ObjectMapper om = new ObjectMapper();// SimpleModule对象,添加各种序列化器和反序列化器。解决LocalDateTime、Long序列化异常SimpleModule simpleModule = new SimpleModule()// 添加反序列化器.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern(DateUtils.DEFAULT_DATE_TIME_FORMAT))).addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern(DateUtils.DEFAULT_DATE_FORMAT)))// 添加序列化器.addSerializer(BigInteger.class, ToStringSerializer.instance).addSerializer(Long.class, ToStringSerializer.instance) // 实现 Long --> String 的序列化器.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern(DateUtils.DEFAULT_DATE_TIME_FORMAT))).addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern(DateUtils.DEFAULT_DATE_FORMAT)));om.registerModule(simpleModule);om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);om.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.WRAPPER_ARRAY);JACKSON_SERIALIZER.setObjectMapper(om);}
}
什么是布隆过滤器?如何使用布隆过滤器?
布隆过滤器(Bloom Filter)是一种数据结构,用于快速判断一个元素是否属于一个集合中。它基于哈希函数实现,可以高效地判断一个元素是否在集合中,但不能精确地确定一个元素在集合中的位置。
布隆过滤器是适合处理大规模数据集,比如:海量数据去重、垃圾邮件过滤、避免缓存穿透等。
使用布隆过滤器需要提前将数据通过多个hash函数映射布隆过滤器中,从布隆过滤器查询的方法也是通过多hash函数进行映射找到具体的位置,如果找到一个位置的值为0则说明数据一定不存在,如果找到位置的值都是1则说明可能存在。
我知道的在redit中提供bitmap位图结构可以实现布隆过滤器,使用redisson也可以实现,使用google的Guava库可以实现。
Redisson中只需要初始化布隆过滤器后设定预期插入的元素数量和误判率就可以完成布隆过滤器的使用。
RedissonClient redisson = Redisson.create();
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("myBloomFilter");
bloomFilter.tryInit(1000000, 0.03); // 初始化布隆过滤器,设定预期插入的元素数量和误判率
bloomFilter.add("someData");// 添加元素
boolean exists = bloomFilter.contains("someData"); // 查询元素,返回是否可能存在
什么是缓存击穿?如何解决缓存击穿?
缓存击穿发生在访问热点数据,大量请求访问同一个热点数据,当热点数据失效后同时去请求数据库,瞬间耗尽数据库资源,导致数据库无法使用。
解决方案:
- 使用同步锁或分布式锁控制。
- 热点数据永不过期。
- 缓存预热,分为提前预热、定时预热
- 降级处理
什么是缓存雪崩?如何解决缓存雪崩?
缓存雪崩是缓存中大量key失效后当高并发到来时导致大量请求到数据库,瞬间耗尽数据库资源,导致数据库无法使用。
解决方案:
- 使用锁进行控制
- 对同一类型信息的key设置不同的过期时间
- 缓存预热
项目为什么要用Elasticsearch?数据很多吗?
项目使用Elasticsearch是实现了门户上对服务的搜索。
平台上的服务数据是并不是很多,全国所有区域下的服务信息加一起几千条,之所以使用Elasticsearch是因为:
- 考虑几年后的数据及对全文检索使用的需求使用了Elasticsearch。
- **ES倒排索引的结构对全文检索的性能非常优秀。**对服务信息进行搜索使用的是全文检索方式,虽然MySQL也支持全文检索但是我们这个接口是面向C端用户且对接口性能有要求,所以使用了ES。
- 虽然现在数据量不大考虑几年后的数据量增长问题,我们使用了Elasticsearch。
- 在项目中除了通过关键字搜索服务信息,还有根据地理坐标进行搜索,使用Elasticsearch也考虑了这一点。
服务中ES搜索是怎么实现的
- 建立服务搜索的索引结构。使用IK分词器对中文进行分词。
- 使用Canal+MQ将数据库中的服务信息同步到Elasticsearch中。
- 开发了一个搜索接口,实现了关键字搜索、服务类型搜索、关键字自动补全、高亮、分页等功能。
- 关键字搜索:
match
查询会分析输入的查询词并进行分词,然后去寻找最相关的文档,基于 Elasticsearch 的倒排索引机制来完成的。 - 类型搜索:可以使用
bool
查询与filter
子查询。 - 自动补全:
- Elasticsearch 提供了
completion
类型字段,这种字段可以用于实现高效的前缀匹配和自动补全功能,需要在索引时为字段指定completion
类型。 - 用户输入查询时,可以使用
suggest
查询来实现自动补全。
- Elasticsearch 提供了
- 高亮查询:在返回的结果中,
highlight
部分会包含匹配的文本,并用<em>
标签等标记突出显示。 - 分页查询:Elasticsearch 支持
from
和size
参数来控制分页,类似于数据库的LIMIT
和OFFSET
。你可以指定返回的起始位置和每页返回的文档数量。
- 关键字搜索:
Canal+MQ执行机制是什么
- Canal伪装成MySQL从库解析binlog从而得到修改的数据,将修改的数据同步MQ.
- 在数据库创建一张用于同步数据的表。
- 当进行服务信息修改、添加、删除时候向同步表中写数据,记录binlog。
- Canal从主库中得到binlog日志,解析出修改的数据,发给MQ。
- 同步程序监听 MQ,获取数据库的变更数据,并根据数据变更类型对 Elasticsearch 进行相应的文档操作(新增、修改、删除)。
Canal怎么伪装成从库?
- Canal模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump协议,告诉主库自己所在的偏移量。
- MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal )。一旦连接建立成功,Canal会一直等待并监听来自MySQL主服务器的binlog事件流,当有新的数据库变更发生时MySQL master主服务器发送binlog事件流给Canal。
- Canal会及时接收并解析这些变更事件并解析 binary log
Canal数据不同步怎么处理?
-
检查MySQL主从状态 :检查MySQL主从服务器的状态,确保主从服务器之间的连接正常,MySQL主库的binlog开启,Canal连接是否正常等。如果有网络故障、MySQL主从服务器之间的通信问题,或者Canal连接问题,都可能导致数据同步异常。
-
查看Canal日志: Canal会生成日志文件,其中包含了关于数据同步的详细信息。
- 通过日志可以了解出现同步是成功的还是出了错误,如果同步错误在日志会显示读取哪个binlog文件出现了错误,然后通过show binary logs查询是否存在该 binlog日志。
- 如果由于binlog日志被删除导致canal同步失败,可以将canal中的meta.dat清理,并且将master复位,重启canal。
- 此时为了防止数据不同步需要对表中的数据全部update,触发canal读取update产生的binlog,最终保证当前数据是全部同步的。
-
确认Canal配置: 检查Canal的配置文件,确保配置正确。特别是Canal的过滤规则等是否正确,即canal.instance.filter.regex的配置。
在项目服务管理模块中MQ是怎么用的?
在数据同步中使用到了MQ。
- 首先有一个用于数据同步的交换机(topic)
- 交换机绑定多个队列,每个队列对应一个数据同步的队列
- 当服务信息修改后,Canal将修改的数据发送到MQ,指定用于数据同步队列的Routing key。
- 同步程序监听MQ,得到修改的数据库,请求Elasticsearch进行相应的文档操作(新增、修改、删除)。
MQ消息可靠性怎么保证
保证MQ消息的可靠性分两个方面:保证生产消息的可靠性、保证消费消息的可靠性。
-
保证生产消息的可靠性:RabbitMQ提供生产者重试机制和确认机制保证生产消息的可靠性。
- 重试机制:首先发送消息的方法如果执行失败会进行重试,这里我们在发送消息的工具类中使用spring提供的
@Retryable
注解,实现发送失败重试机制,通过注解的backoff
属性指定重试等待策略,通过Recover
注解指定失败回调方法,失败重试后仍然失败的会走失败回调方法,在回调方法中将失败消息写入一个失效消息表由定时任务进行补偿(重新发送),如果系统无法补偿成功则由人工进行处理,单独开发人工处理失败消息的功能模块。 - 确认机制:另外MQ提供生产者确认机制,我们在发送消息时给每个消息指定一个唯一ID,设置回调方法,如果发送成功MQ返回ack,如果失败会返回nack,我们在回调方法中解析是ack还是nack,如果发送失败可以记录到失败表由定时任务去异步重新发送。
- 还有一种情况是如果消息发送到MQ的Broker成功了但是并没有到达队列,此时会调用ReturnCallback回调方法,在回调方法中我们可以收到失败的消息进行补偿处理。
- 重试机制:首先发送消息的方法如果执行失败会进行重试,这里我们在发送消息的工具类中使用spring提供的
-
保证消费消息的可靠性:RabbitMQ是通过消费者回执来确认消费者是否成功处理消息的。
- 消费者获取消息后,应该向RabbitMQ发送ACK回执,表明自己已经处理完成消息,RabbitMQ收到ACK后删除消息。
- RabbitMQ提供三个确认模式:手动ack,自动ack、关闭ack
- 本项目使用自动ack模式,当消费消息失败会重试,重试3次如果还失败会将消息投递到失败消息队列,由定时任务程序定时读取队列的消息。
MQ怎么避免重复消费?
需要保证MQ消费消息的幂等性。
- 使用数据库的唯一约束去控制。比如:添加主键索引或者唯一索引保证添加数据的幂等性
- 使用token机制,发送消息时给消息指定一个唯一的ID,发送消息时将消息ID写入Redis,消费时根据消息ID查询Redis判断是否已经消费,如果已经消费则不再消费。
如何保证Canal+MQ同步消息的顺序性
Canal解析binlog日志信息按顺序发到MQ的队列中,现在是要保证消费端如何按顺序消费队列中的消息。
解决方法:
- 多个jvm进程监听同一个队列保证只要一个消费者接收消息。
- 队列需要增加x-single-active-consumer参数,值为true,表示否启用单一活动消费者模式,消费队列中的数据使用单线程。
- 在监听队列的java代码中参数**
concurrency
指定消费线程为1,@RabbitListener(queues="canal-mq-foundations",concurrency="1")
**
客户管理
本项目的认证方式有哪些?
-
小程序认证
-
手机验证码认证
-
账号名密码方式认证
小程序认证流程是什么
-
前端请求微信提供的 API(
wx.login()
)获取登录凭证code。code
是微信认证的关键,它是一个短期有效的凭证,用于获取openid
和session_key
。
-
前端携带登录凭证code请求微服务认证接口,后端通过该
code
向微信的 API 请求openid
和session_key
,从而确认用户的身份。// 前端向微服务发送请求,传递 code axios.post('/auth/login', { code: res.code });
-
微服务请求微信获取openid,初次认证将openid记录在数据库common_user表中。
openid
是用户在微信平台上的唯一标识,可以用来标识该用户。session_key
用于会话加密,但在微信小程序中,通常不需要永久存储,而是用于前端加密、解密。- 微服务接收到
openid
后,通常会在数据库中查找是否已有该用户的记录。如果是初次认证,就将openid
以及相关信息保存到common_user
表中。 - 保存用户信息,并为后续操作(如查询、更新手机号)提供唯一标识。
如果还想拿到手机号等信息,也可以继续调用:
-
前端请求微信提供的 API(
wx.getPhoneNumber()
)获取手机号拿到token令牌。 -
前端携带手机号token令牌请求微服务查询用户手机号。
-
微服务请求微信查询用户手机号并更新到数据库中。
当前认证通过的用户信息保存到哪里了?
用户信息保存在两个地方:
-
前端的storage中(通常是
localStorage
或sessionStorage
):// 示例:存储 token 到 localStorage localStorage.setItem('token', response.data.token);
-
后端的ThreadLocal中:
用户认证通过后端生成token返回给前端由前端保存,前端每次请求微服务会携带token访问。
网关收到前端的请求进行token校验,token不合法直接返回失败信息,token合法解析出用户信息放在http的head中继续请求微服务,在微服务中解析http头信息中的用户信息,写入ThreadLocal方便应用程序使用。
手机验证码认证流程是什么
-
输入手机号。
-
点击发送验证码。系统会生成一个验证码并通过短信发送到用户的手机号,同时将验证码存储在服务端(通常是 Redis)中,并设置一个过期时间。
-
输入验证码,点击登录。用户在收到验证码后,在登录页面输入收到的验证码,并点击登录按钮。
-
认证过程
- 系统首先会从 Redis 中获取存储的验证码并与用户输入的验证码进行比对。
- 如果验证码正确且未过期,系统会继续进行后续步骤。如果验证码不匹配或者已过期,则认证失败,返回错误提示。
-
再根据手机号查询serve_provider表是否存在相应记录且用户未被冻结,全部成功则认证通过。
手机验证码服务的实现方案?
由于项目中使用手机验证码的业务非常多所以抽取一个公共的服务里边包括了验证码功能。
-
提供发送验证码接口
-
传入业务类型及手机号,向手机号发送验证码并将验证码存储在redis。
-
业务类型:将使用验证码的业务场景定义为业务类型TYPE,如:1:机构注册,2:机构忘记密码,3:服务人员登录。
-
存储到redis使用string类型。
- 验证码key:PHONE:CODE:VERIFY_CODE_1xxxxxxxxxxxx_TYPE
- 验证码value:6位随机数
- 验证码有过期时间:默认300秒
-
-
提供校验验证码接口
- 传入业务类型、手机号及用户输入的验证码。
- 根据key从redis取出正确的验证码和用户输入的进行对比,一致说明验证码输入正确,否则输入错误,校验通过删除验证码。
-
对发送验证码接口进行限流,避免频繁发送验证码。
- 首先在前端操作频率控制,60秒内只允许发一次。
- 在后端通过对发送验证码接口进行限流。
如何开发小程序定位功能
-
小程序通过微信提供的
wx.getLocation()
的API方法获取手机当前位置(经纬度)。 -
小程序请求后端获取经纬度对应的城市等详细位置信息,后端调用地图服务(高德地图)根据经纬度查询地理编码信息。
-
后端根据地理编码中的city_code对应到平台的区域。
-
定位成功在小程序首页显示定位的城市。
门户
实现一个门户用到的技术方案有哪些?
对于web门户主要是使用cms系统对网页进行静态化:
-
使用内容发布系统将门户生成静态网页发布到CDN服务器。
- 纯静态网页通过Nginx加载要比Jsp等动态脚本通过Tomcat加载强很多。
- 将网页加入CDN服务提高网络访问效率。
-
html文件上的静态资源比如:图片、视频、CSS、Js等也全部放到CDN服务。
-
html上的动态数据通过异步请求后端缓存服务器加载,不要直接查询数据库。
-
使用负载均衡加分布式架构,通过部署多个Nginx服务器共同提供服务。
-
在前端也做一部分缓存。
对于app类的门户:
- 静态资源要走CDN服务器,对所有请求进行负载均衡、缓存门户上显示的动态数据、可在前端缓存或在服务端进行缓存。
预约下单
订单的状态有哪些?
-
待支付:订单的初始状态。
-
派单中:用户支付成功后订单的状态由待支付变为派单中。
-
待服务:服务人员或机构抢单成功订单的状态由派单中变为待服务。
-
服务中:服务人员开始服务,订单状态变为服务中。
-
待评价:服务人员完成服务,订单状态变为待评价。
-
订单完成:用户完成评价,订单状态变为订单完成。
-
已取消:订单是待支付状态时用户取消订单,订单状态变为已取消。
-
已关闭:订单已支付状态下取消订单后订单状态变为已关闭。
订单表是怎么设计的?
订单表通常采用的结构是订单主表与订单明细表一对多关系结构,比如:在电商系统中,一个订单购买的多件不同的商品,设计订单表和订单明细表:
-
订单表:记录订单号、订单金额、下单人信息、订单状态等信息。
-
订单明细表:记录该订单购买商品的信息,包括:商品名称、商品价格、交易价格、购买商品数量等。
如果系统需求是一个订单只包括一种商品,此时无须记录订单明细,将购买商品的详细信息记录在订单表即可,设计字段包括:订单号、订单金额、下单人、订单状态、商品名称、购买商品数量等。
本项目订单表包括以下内容:
订单基础信息:订单号、订单状态、排序字段、是否显示标记等。
价格信息:单价、购买数量、优惠金额、订单总金额等。
下单人信息:下单人ID、联系方式、位置信息(相当于收货地址)等。
服务信息(如果有订单明细表要放在订单明细表):服务类型名称、服务项名称、服务单价、价格单位、购买数量等。
常见的订单号生成规则有哪些?
-
自增数字序列
使用数据库的自增主键、redis的INCR 命令生成序列化。
-
时间戳+随机数
将年月日时分秒和一定范围内的随机数组合起来。有重复的风险。
-
订单类型+日期+序号
将订单类型(例如"01"表示普通订单,"02"表示VIP订单等)、日期和序号组合起来。加上订单类型的好处是方便客户服务,根据订单号就可以知道订单的类型。我们采用19位:2位年+2位月+2位日+13位序号,这也是可以优化的点!
-
分布式唯一ID生成器
使用分布式唯一ID生成器(例如Snowflake算法)生成全局唯一的ID作为订单号。Snowflake 算法根据机器ID、时间戳、序号等因素生成,保证全局唯一性,它的优势在于生成的 ID 具有趋势递增、唯一性、高效性等特点。1+41+10+12
分库分表方案
为了解决日益增长的订单数据对系统造成的瓶颈,在系统架构时设计分库分表的技术方案,使用ShardingSphere框架实现。
Apache ShardingSphere 是一个开源的分布式数据库中间件,可以将任意数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密等能力对原有数据库进行增强。
-
引入依赖并配置shardingsphere-jdbc-dev.yml
<dependency><groupId>com.jzo2o</groupId><artifactId>jzo2o-shardingsphere-jdbc</artifactId> </dependency>
-
配置数据源:(哪张表)
orders
、orders_serve
、biz_snapshot
等表都进行了 分库分表 的配置。
-
分库分表规则:(哪个字段)
- 分库策略:基于不同的字段进行分库,
orders
表根据user_id
进行分库,orders_serve
表根据serve_provider_id
进行分库,biz_snapshot
表根据db_shard_id
进行分库。 - 分表策略:对于每个表,使用了
id
或其他字段进行分表。每个表的actualDataNodes
字段定义了实际的数据节点,${0..2}
表示分为 3 个数据节点。
- 分库策略:基于不同的字段进行分库,
-
分库分表算法:(咋分)
- 这里定义了 分库 和 分表 的具体算法,使用了
INLINE
类型的算法,具体根据字段的值(如user_id
、serve_provider_id
、biz_id
)来计算目标库和表。 - 例如,
orders_database_inline
的algorithm-expression: jzo2o-orders-${user_id % 3}
表示user_id
按照取余 3 来决定分配到哪一个库。
- 这里定义了 分库 和 分表 的具体算法,使用了
-
设置广播表:
BROADCAST
表示 广播表,这些表的数据会在所有数据库中同步。广播表适用于数据量小、查询频繁的表,不进行分库分表,所有数据库节点都存有该表的副本。
-
ID生成器:
- 配置了 Snowflake 算法用于生成分布式唯一 ID。
对订单表、服务单表进行分库分表,这里只展示订单表分库分表,服务单表一样的配置。
- 分库方案:
- 设计三个数据库,根据用户id哈希,分库表达式为:db_用户id % 3。
- 参考历史经验,前期设计三个数据库,每个数据库使用主从结构部署,可以支撑项目3年左右的运行,虽然哈希存在数据迁移问题,在很长一段时间也不用考虑这个问题。
- 分表方案:
- 根据订单范围分表,0—1500万落到table_0,1500万—3000万落到table_1,依次类推。根据范围分表不存在数据库迁移问题,方便系统扩容。
- 分表表达式:orders_${(int)Math.floor(id % 10000000000 / 15000000)}
为什么要使用状态机
清晰管理状态流:它帮助清楚地定义一个对象如何从一个状态转到另一个状态,避免代码中混乱的条件判断。
易于扩展:当需要增加新的状态或转换时,状态机可以方便地扩展,而不需要大改现有代码。
减少错误:状态机明确哪些状态可以转换,避免了不合法的状态变化。
集中管理:所有状态和转换逻辑都集中管理,更容易理解和维护。
提高可测试性:每个状态转换都可以独立测试,确保业务流程正确。
订单的状态机怎么实现的
当订单的状态非常多且变化关系复杂时就非常有必要使用状态机来管理订单状态,这样可以避免代码中对状态变更的硬编码,提高系统的可扩展可维护性。
本项目基于状态机设计模式开发了状态机组件,状态机设计模式描述了一个对象在内部状态发生变化时如何改变其行为,将状态之间的变更定义为事件,将事件暴露出去,通过执行状态变更事件去更改状态,这是状态机设计模式的核心内容。
我们封装了一个状态机组件,包括:状态机抽象类、状态接口、事件接口、动作接口、快照基础类。
我们在order-base下定义这些
-
设计状态机表
create table `jzo2o-orders`.state_persister (id bigint auto_increment comment '主键'constraint `PRIMARY`primary key,state_machine_name varchar(255) null comment '状态机名称',biz_id varchar(255) null comment '业务id',state varchar(255) null comment '状态',create_time datetime default CURRENT_TIMESTAMP null comment '创建时间',update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',constraint 唯一索引unique (state_machine_name, biz_id) )comment '状态机持久化表' charset = utf8mb4;
-
定义订单状态枚举类
@Getter @AllArgsConstructor public enum OrderStatusEnum implements StatusDefine {NO_PAY(0, "待支付", "NO_PAY"),DISPATCHING(100, "派单中", "DISPATCHING"),NO_SERVE(200, "待服务", "NO_SERVE"),SERVING(300, "服务中", "SERVING"),FINISHED(500, "已完成", "FINISHED"),CANCELED(600, "已取消", "CANCELED"),CLOSED(700, "已关闭", "CLOSED");private final Integer status;private final String desc;private final String code;/*** 根据状态值获得对应枚举** @param status 状态* @return 状态对应枚举*/public static OrderStatusEnum codeOf(Integer status) {for (OrderStatusEnum orderStatusEnum : values()) {if (orderStatusEnum.status.equals(status)) {return orderStatusEnum;}}return null;} }
-
定义状态变更事件枚举类
@Getter @AllArgsConstructor public enum OrderStatusChangeEventEnum implements StatusChangeEvent {PAYED(OrderStatusEnum.NO_PAY, OrderStatusEnum.DISPATCHING, "支付成功", "payed"),DISPATCH(OrderStatusEnum.DISPATCHING, OrderStatusEnum.NO_SERVE, "接单/抢单成功", "dispatch"),START_SERVE(OrderStatusEnum.NO_SERVE, OrderStatusEnum.SERVING, "开始服务", "start_serve"),COMPLETE_SERVE(OrderStatusEnum.SERVING, OrderStatusEnum.NO_EVALUATION, "完成服务", "complete_serve"),CANCEL(OrderStatusEnum.NO_PAY, OrderStatusEnum.CANCELED, "取消订单", "cancel"),SERVE_PROVIDER_CANCEL(OrderStatusEnum.NO_SERVE, OrderStatusEnum.DISPATCHING, "服务人员/机构取消订单", "serve_provider_cancel"),CLOSE_DISPATCHING_ORDER(OrderStatusEnum.DISPATCHING, OrderStatusEnum.CLOSED, "派单中订单关闭", "close_dispatching_order"),CLOSE_NO_SERVE_ORDER(OrderStatusEnum.NO_SERVE, OrderStatusEnum.CLOSED, "待服务订单关闭", "close_no_serve_order"),CLOSE_SERVING_ORDER(OrderStatusEnum.SERVING, OrderStatusEnum.CLOSED, "服务中订单关闭", "close_serving_order"),CLOSE_FINISHED_ORDER(OrderStatusEnum.FINISHED, OrderStatusEnum.CLOSED, "已完成订单关闭", "close_finished_order");/*** 源状态*/private final OrderStatusEnum sourceStatus;/*** 目标状态*/private final OrderStatusEnum targetStatus;/*** 描述*/private final String desc;/*** 代码*/private final String code; }
-
定义事件变更动作类
-
拿PAYED事件对应的动作类说明,如下代码:
-
动作类的Bean名称为"状态机名称_事件名称",例如下边的动作类Bean的名称为order_payed,表示order状态机的payed事件。
-
根据事件名称找到事件的定义,如下:
PAYED(OrderStatusEnum.NO_PAY, OrderStatusEnum.DISPATCHING, “支付成功”, “payed”)
通过事件的定义可知原始状态为
OrderStatusEnum.NO_PAY(未支付)
,目标状态为OrderStatusEnum.DISPATCHING(派单中)
,支付成功事件执行后将从原始状态改为目标状态。@Slf4j @Component("order_payed") public class OrderPayedHandler implements StatusChangeHandler<OrderSnapshotDTO> {@Resourceprivate IOrdersCommonService ordersService;/*** 订单支付处理逻辑** @param bizId 业务id* @param statusChangeEventEnum 状态变更事件* @param bizSnapshot 快照*/@Overridepublic void handler(String bizId, StatusChangeEvent statusChangeEventEnum, OrderSnapshotDTO bizSnapshot) {log.info("支付事件处理逻辑开始,订单号:{}", bizId);// 修改订单状态和支付状态OrderUpdateStatusDTO orderUpdateStatusDTO = OrderUpdateStatusDTO.builder().id(Long.valueOf(bizId)).originStatus(OrderStatusEnum.NO_PAY.getStatus()).targetStatus(OrderStatusEnum.DISPATCHING.getStatus()).payStatus(OrderPayStatusEnum.PAY_SUCCESS.getStatus()).payTime(LocalDateTime.now()).tradingOrderNo(bizSnapshot.getTradingOrderNo()).transactionId(bizSnapshot.getThirdOrderId()).tradingChannel(bizSnapshot.getTradingChannel()).build();int result = ordersService.updateStatus(orderUpdateStatusDTO);if (result <= 0) {throw new DbRuntimeException("支付事件处理失败");}} }
-
-
使用订单状态机
首先定义订单状态机类,AbstractStateMachine状态机抽象类是状态机的核心类,是具体的状态机要继承的抽象类,比如我们实现订单状态机就需要继承AbstractStateMachine抽象类。
@Component public class OrderStateMachine extends AbstractStateMachine<OrderSnapshotDTO> {public OrderStateMachine(StateMachinePersister stateMachinePersister, BizSnapshotService bizSnapshotService, RedisTemplate redisTemplate) {super(stateMachinePersister, bizSnapshotService, redisTemplate);}/*** 设置状态机名称** @return 状态机名称*/@Overrideprotected String getName() {return "order";}@Overrideprotected void postProcessor(OrderSnapshotDTO orderSnapshotDTO) {}/*** 设置状态机初始状态** @return 状态机初始状态*/@Overrideprotected OrderStatusEnum getInitState() {return OrderStatusEnum.NO_PAY;} }
下边通过OrderStateMachine 去使用状态机:
启动状态机:调用OrderStateMachine的start()方法启动一个订单的状态机,表示此订单的状态交由状态机管理并且设置初始状态
//启动状态机,指定订单id String start = orderStateMachine.start("101xxxxxxxxxxxxxxxx"); log.info("返回初始状态:{}", start);
变更状态:
调用OrderStateMachine的changeStatus()方法通过状态变更事件去变更状态,自动从Bean中
SpringUtil.getBean
获取对应时间的动作类,执行状态变更事件的动作方法,最终更新订单的状态。//状态变更,指定状态变更事件 orderStateMachine.changeStatus("101xxxxxxxxxxxxxxxx",OrderStatusChangeEventEnum.PAYED);
为什么要使用快照
查看历史状态:可以回溯到某个时间点,查看当时的数据。
提高性能:缓存快照,减少频繁查询数据库,提高查询速度。
避免重复计算:记录状态变化,避免每次都重新计算。
保证一致性:确保在多次状态变化中数据不丢失或出错。
方便排查问题:发生问题时,可以通过快照回溯定位原因。
支持回滚:系统出错时,可以恢复到某个稳定状态。
订单快照怎么实现的
-
定义快照类
订单快照是订单变化瞬间的状态及相关信息。通过订单快照可以查询订单当前状态下的信息,以及历史某个状态下的订单信息。
快照基础类型是
StateMachineSnapshot
,如果我们要实现订单快照则需要定义一个订单快照类OrderSnapshotDTO 去继承StateMachineSnapshot类型,代码如下:@Data @Builder @NoArgsConstructor @AllArgsConstructor public class OrderSnapshotDTO extends StateMachineSnapshot { ....
-
保存订单快照
在启动状态机时指定快照:
/*** 状态机初始化,并保存业务快照,快照分库分表** @param dbShardId 分库键* @param bizId 业务id* @param bizSnapshot 业务快照* @return 初始化状态代码*/ public String start(Long dbShardId, String bizId, T bizSnapshot) {return start(dbShardId, bizId, initState, bizSnapshot); }
在变更状态时指定新的快照:
/*** 变更状态并保存快照,快照分库分表** @param dbShardId 分库键* @param bizId 业务id* @param statusChangeEventEnum 状态变换事件* @param bizSnapshot 业务数据快照(json格式)*/ public void changeStatus(Long dbShardId, String bizId, StatusChangeEvent statusChangeEventEnum, T bizSnapshot) { ... }
-
使用订单快照
在查询订单详情时通过查询订单快照,并且对快照信息进行缓存提高查询性能。
下边的代码是状态机提供的查询快照的方法:
/*** 获取当前状态的快照缓存** @param bizId 业务id* @return 快照信息*/ public String getCurrentSnapshotCache(String bizId) {//先查询缓存,如果缓存没有就查询数据库然后存缓存String key = "JZ_STATE_MACHINE:" + name + ":" + bizId;Object object = redisTemplate.opsForValue().get(key);if (ObjectUtil.isNotEmpty(object)) {return object.toString();}String bizSnapshot = getCurrentSnapshot(bizId);redisTemplate.opsForValue().set(key, bizSnapshot, 30, TimeUnit.MINUTES);return bizSnapshot; }
在查询订单详情方法(部分代码)中调用状态机的查询快照方法:
/*** 根据订单id查询** @param id 订单id* @return 订单详情*/ @Override public OrderResDTO getDetail(Long id) {//从快照中查询订单数据String jsonResult = orderStateMachine.getCurrentSnapshotCache(String.valueOf(id));OrderSnapshotDTO orderSnapshotDTO = JSONUtil.toBean(jsonResult, OrderSnapshotDTO.class);... }
总结:
-
在订单状态变更时会记录订单的快照信息,在订单状态机的方法中实现了保存订单快照,订单快照保存到数据库的订单的快照。
-
提供快照查询接口,根据订单id查询订单详情是查询订单快照的信息。
-
快照查询接口实现Redis缓存,根据订单Id查询缓存信息,先从缓存查询如果缓存没有则查询快照表的数据然后保存到缓存中。缓存设置了过期时间是30分钟。
-
当订单状态变更,此时订单最新状态的快照有变更,会删除快照缓存,当再次查询快照时从数据库查询最新的快照信息进行缓存。
public String findLastSnapshotByBizIdAndState(String stateMachineName, String bizId, String state) {LambdaQueryWrapper<BizSnapshot> queryWrapper = Wrappers.lambdaQuery().eq(ObjectUtil.isNotEmpty(stateMachineName), BizSnapshot::getStateMachineName, stateMachineName).eq(ObjectUtil.isNotEmpty(bizId), BizSnapshot::getBizId, bizId).eq(ObjectUtil.isNotEmpty(state), BizSnapshot::getState, state).gt(BizSnapshot::getDbShardId, 0) // 确保 dbShardId 大于 0.orderByDesc(BizSnapshot::getCreateTime) // 按照创建时间降序排列.last("limit 1"); // 只取最新的一条记录// 执行查询BizSnapshot bizSnapshot = this.bizSnapshotMapper.selectOne(queryWrapper);// 如果找到快照,返回它的业务数据;否则返回 nullreturn ObjectUtil.isNotNull(bizSnapshot) ? bizSnapshot.getBizData() : null; }
使用redis对订单查询怎么优化的?
-
对于根据订单id查询订单详情优化:
查询数据库改为查询订单快照缓存,状态机组件提供快照缓存的查询方法,将快照信息缓存到 redis提供查询效率。
-
对于C端订单列表的查询优化:
- 分页查询改为滚动查询,避免count查询。每次查询后,都会得到新的
last_order_id
,相当于从这个id的游标开始查询。- 数据量非常大时(如几百万甚至更多条记录),普通的分页查询会变得非常缓慢。
- 不需要计算总页数或总记录数,只关心从某个点开始的一批数据。
- 查询的数据是增量变化的,例如用户或订单数据,滚动查询能够高效地处理增量数据的获取。
- 首先使用覆盖索引查出符合条件的订单ID(主键),再根据订单ID查询订单信息。
- 将订单信息缓存到Redis的Hash结构中。
- 缓存同步:当订单状态变更则删除Hash结构中的对应的订单信息。
- 分页查询改为滚动查询,避免count查询。每次查询后,都会得到新的
-
对于运营端订单列表的查询优化:
- 使用覆盖索引查出符合条件的订单ID(主键),再根据订单ID查询订单信息。
怎么防止订单重复提交
重复提交通常是因为网络不稳定,用户提交后没有响应用户重复点提交按钮导致,通过前端和后端共同完成。
-
前端:
在用户点击提交按钮后,立即将按钮禁用,防止用户多次点击。
-
后端:
使用分布式锁,以用户id+服务id作为分布式锁,锁定下单接口10秒,10秒内只会提交一次。
取消订单怎么实现的
分为两种情况:
-
对未支付订单系统通过定时任务和懒加载方式自动取消订单。
- 使用xxl-job定时(10分钟)扫描未支付订单达到支付超时(未支付15分钟后)自动取消订单。
- 懒加载方式是当用户去查看订单详情时程序实时判断是否支付超时,如果支付超时则自动取消订单。
-
对已支付订单进行取消操作除了更改订单状态以外还需要调用退款接口进行退款。
- 如果订单已支付还没有发货此时用户可自动取消,其它情况需要用户联系客服取消订单。
- 取消订单的情况比较多,需要根据操作用户的类型(c端用户、运营人员)和订单的当前状态来确定取消订单的策略,这里使用策略模式实现取消订单。
取消订单的策略模式是什么
策略模式作为一种软件设计模式,指对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。
策略模式由以下部分组成:
-
抽象策略角色: 策略类,通常由一个接口或者抽象类实现。
-
具体策略角色:包装了相关的算法和行为。
-
环境角色:持有一个策略类的引用,最终给客户端调用。
支付类策略模式
具体实现:
-
策略接口:支付方式接口(银行卡支付或者微信支付)
// 定义策略接口 public interface PaymentStrategy {void pay(BigDecimal amount); }
-
具体策略类:银行卡支付或者微信支付逻辑
public class CreditCardPayment implements PaymentStrategy {private String cardNumber;public CreditCardPayment(String cardNumber) {this.cardNumber = cardNumber;}@Overridepublic void pay(BigDecimal amount) {System.out.println("信用卡:"+cardNumber+"支付金额:"+amount);} }public class WeixinPayment implements PaymentStrategy {private String account;public WeixinPayment(String account) {this.account = account;}@Overridepublic void pay(BigDecimal amount) {System.out.println("微信:"+account+"支付金额:"+amount);} }
-
环境类:购物车
class ShoppingCart {private PaymentStrategy paymentStrategy;public void setPaymentStrategy(PaymentStrategy paymentStrategy) {this.paymentStrategy = paymentStrategy;}public void checkout(BigDecimal amount) {paymentStrategy.pay(amount);} }
-
客户端调用
public class StrategyPatternExample {public static void main(String[] args) {// 创建环境类ShoppingCart shoppingCart = new ShoppingCart();// 选择支付策略--信用卡支付PaymentStrategy creditCardPayment = new CreditCardPayment("1234-5678-9876-5432");shoppingCart.setPaymentStrategy(creditCardPayment);// 进行支付shoppingCart.checkout(new BigDecimal(100));// 切换支付策略,使用微信支付PaymentStrategy weixinPayment = new WeixinPayment("example@example.com");shoppingCart.setPaymentStrategy(weixinPayment);// 进行支付shoppingCart.checkout(new BigDecimal(50));} }
取消订单类策略模式
学习了策略模式我们可以将取消订单定义为策略接口,针对不同场景下取消订单的逻辑定义为一个一个的策略类,如果哪个场景下的策略有变化只需要修改该策略类即可,如果增加场景也只需要增加策略类。
-
取消订单策略接口
取消策略接口需要指定用户类型、订单状态及取消信息,根据用户类型和订单状态决定取消订单的逻辑。
public interface OrderCancelStrategy {/*** 订单取消** @param orderCancelDTO 订单取消模型*/void cancel(OrderCancelDTO orderCancelDTO); }
OrderCancelDTO 类:
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class OrderCancelDTO {/*** 订单id*/private Long id;/*** 用户id*/private Long userId;/*** 当前用户id*/private Long currentUserId;/*** 当前用户名称*/private String currentUserName;/*** 当前用户类型*/private Integer currentUserType;/*** 取消原因*/private String cancelReason;/*** 预约服务开始时间*/private LocalDateTime serveStartTime;/*** 实际支付金额*/private BigDecimal realPayAmount;/*** 城市编码*/private String cityCode;/*** 支付服务交易单号*/private Long tradingOrderNo; }
-
策略类:每个策略类定义的bean名称为“用户类型:订单状态”。
@Component("4:DISPATCHING") public class OperationDispatchingOrderCancelStrategy implements OrderCancelStrategy {@Resourceprivate OrderStateMachine orderStateMachine;@Resourceprivate IOrdersCanceledService ordersCanceledService;@Resourceprivate ISeizeDispatchService seizeDispatchService;@Resourceprivate IOrdersRefundService ordersRefundService;/*** 订单取消** @param orderCancelDTO 订单取消模型*/@Overridepublic void cancel(OrderCancelDTO orderCancelDTO) {//1.构建订单快照OrderSnapshotDTO orderSnapshotDTO = OrderSnapshotDTO.builder().refundStatus(RefundStatusEnum.SENDING.getCode()).cancellerId(orderCancelDTO.getCurrentUserId()).cancelerName(orderCancelDTO.getCurrentUserName()).cancellerType(orderCancelDTO.getCurrentUserType()).cancelReason(orderCancelDTO.getCancelReason()).cancelTime(LocalDateTime.now()).build();//2.保存订单取消记录OrdersCanceled ordersCanceled = BeanUtil.toBean(orderSnapshotDTO, OrdersCanceled.class);ordersCanceled.setId(orderCancelDTO.getId());ordersCanceledService.save(ordersCanceled);//3.订单状态变更orderStateMachine.changeStatus(orderCancelDTO.getUserId(), orderCancelDTO.getId().toString(), OrderStatusChangeEventEnum.CLOSE_DISPATCHING_ORDER, orderSnapshotDTO);//5.取消抢派单seizeDispatchService.clearSeizeDispatchPool(orderCancelDTO.getCityCode(), orderCancelDTO.getId());//6.存入退款表,定时任务扫描进行退款OrdersRefund ordersRefund = new OrdersRefund();ordersRefund.setId(orderCancelDTO.getId());ordersRefund.setTradingOrderNo(orderCancelDTO.getTradingOrderNo());ordersRefund.setRealPayAmount(orderCancelDTO.getRealPayAmount());ordersRefundService.save(ordersRefund);} }
其它策略类也使用此方法定义bean的名称。
下边方法是该策略类针对策略接口的具体实现方法:
取消待支付的订单执行的动作有:更改订单的状态为已取消,添加取消订单记录。
@Override public void cancel(OrderCancelDTO orderCancelDTO) {//1.校验是否为本人操作if (ObjectUtil.notEqual(orderCancelDTO.getUserId(), orderCancelDTO.getCurrentUserId())) {throw new ForbiddenOperationException("非本人操作");}//2.构建订单快照更新模型OrderSnapshotDTO orderSnapshotDTO = OrderSnapshotDTO.builder().cancellerId(orderCancelDTO.getCurrentUserId()).cancelerName(orderCancelDTO.getCurrentUserName()).cancellerType(orderCancelDTO.getCurrentUserType()).cancelReason(orderCancelDTO.getCancelReason()).cancelTime(LocalDateTime.now()).build();//3.保存订单取消记录OrdersCanceled ordersCanceled = BeanUtil.toBean(orderSnapshotDTO, OrdersCanceled.class);ordersCanceled.setId(orderCancelDTO.getId());ordersCanceledService.save(ordersCanceled);//4.订单状态变更orderStateMachine.changeStatus(orderCancelDTO.getUserId(), orderCancelDTO.getId().toString(), OrderStatusChangeEventEnum.CANCEL, orderSnapshotDTO); }
-
策略的环境类
@Slf4j @Component public class OrderCancelStrategyManager {@Resourceprivate IOrdersManagerService ordersManagerService;//key格式:userType+":"+orderStatusEnum,例:1:NO_PAYprivate final Map<String, OrderCancelStrategy> strategyMap = new HashMap<>();@PostConstructpublic void init() {Map<String, OrderCancelStrategy> strategies = SpringUtil.getBeansOfType(OrderCancelStrategy.class);strategyMap.putAll(strategies);log.debug("订单取消策略类初始化到map完成!");}/*** 获取策略实现** @param userType 用户类型* @param orderStatus 订单状态* @return 策略实现类*/public OrderCancelStrategy getStrategy(Integer userType, Integer orderStatus) {String key = userType + ":" + OrderStatusEnum.codeOf(orderStatus).toString();return strategyMap.get(key);}/*** 订单取消** @param orderCancelDTO 订单取消模型*/public void cancel(OrderCancelDTO orderCancelDTO) {Orders orders = ordersManagerService.queryById(orderCancelDTO.getId());OrderCancelStrategy strategy = getStrategy(orderCancelDTO.getCurrentUserType(), orders.getOrdersStatus());if (ObjectUtil.isEmpty(strategy)) {throw new ForbiddenOperationException("不被许可的操作");}orderCancelDTO.setUserId(orders.getUserId());orderCancelDTO.setServeStartTime(orders.getServeStartTime());orderCancelDTO.setCityCode(orders.getCityCode());orderCancelDTO.setRealPayAmount(orders.getRealPayAmount());orderCancelDTO.setTradingOrderNo(orders.getTradingOrderNo());strategy.cancel(orderCancelDTO);}}
@PostConstruct
是java自带的注解,此注解标记的方法用于在对象创建后依赖注入完成后执行一些初始化操作。和
@PostConstruct
对应的还有一个@PreDestroy
,是在bean销毁时调用,通常需要做一些释放资源的操作。在
init()
方法中取出所有的策略接口实现对象放入strategyMap
中,key为:userType+“:”+orderStatusEnum,即bean的名称,value为对象本身。cancel(OrderCancelDTO orderCancelDTO) 取消订单方法:
1、根据用户类型和订单状态取出策略类的对象
2、执行策略对象的cancel(OrderCancelDTO orderCancelDTO) 方法即执行取消订单操作。
-
客户端调用
修改取消订单的service方法:
由于取消优惠券核销需要使用分布式事务控制,分布式事务控制影响性能,这里将本地事务提交与分布式事务提交分开编写代码。
@Slf4j @Service public class OrdersManagerServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersManagerService { ... /*** 取消订单** @param orderCancelDTO 取消订单模型*/ @Override public void cancel(OrderCancelDTO orderCancelDTO) {// 1.有优惠金额的回滚优惠券(当前优惠金额均来自优惠券)Orders orders = getById(orderCancelDTO.getId());if (ObjectUtils.isNull(orders.getDiscountAmount()) || orders.getDiscountAmount().compareTo(BigDecimal.ZERO) > 0) {CouponUseBackReqDTO couponUseBackReqDTO = new CouponUseBackReqDTO();couponUseBackReqDTO.setOrdersId(orderCancelDTO.getId());couponUseBackReqDTO.setUserId(orders.getUserId());//需要取消优惠券核销要使用分布式事务控制owner.cancelWithCoupon(orderCancelDTO, couponUseBackReqDTO);} else {//使用本地事务控制即可owner.cancelWithoutCoupon(orderCancelDTO);}}@GlobalTransactional public void cancelWithCoupon(OrderCancelDTO orderCancelDTO, CouponUseBackReqDTO couponUseBackReqDTO) {couponApi.useBack(couponUseBackReqDTO);orderCancelStrategyManager.cancel(orderCancelDTO); }@Transactional public void cancelWithoutCoupon(OrderCancelDTO orderCancelDTO) {orderCancelStrategyManager.cancel(orderCancelDTO); } ...
@Transactional@GlobalTransactional的区别
特性 | @Transactional | @GlobalTransactional |
---|---|---|
事务范围 | 局部事务(单个服务或数据库) | 全局事务(跨多个服务或数据库) |
支持的场景 | 单体应用,单个数据库的数据操作 | 分布式系统,跨服务和跨数据库的事务管理 |
事务管理方式 | 由 Spring 管理 | 由 Seata 框架管理 |
使用的框架 | Spring | Seata |
典型应用 | 单个数据库事务 | 多个微服务之间的分布式事务 |
传播行为 | 支持多种传播行为(如 REQUIRED, REQUIRES_NEW 等) | 支持分布式事务的多阶段提交 |
回滚规则 | 默认回滚运行时异常,支持自定义 | 支持全局事务的回滚,保证一致性 |
Service方法事务失效的原因是什么
- 捕获异常但没抛出:在方法中捕获了异常没有抛出去,没有把异常抛给代理对象,代理对象捕捉不到异常没有进行事务回滚
- 非事务方法调用事务方法:非事务方法内部调用事务方法,不是通过代理对象去调用
- 事务方法不是public:Spring只能拦截@Transactional标记的public方法来管理事务,private或protected方法会失效。
- 异常类型不匹配:抛出的异常与rollbackFor指定的异常不匹配,默认rollbackFor指定的异常为RuntimeException。默认情况下,事务只对
RuntimeException
回滚,如果抛出的异常不符合配置的回滚条件,事务也不会回滚。 - 数据库表不支持事务:数据库表不支持事务,比如MySQL的MyISAM
- 事务传播行为不当:Spring的传播行为导致事务失效,比如:
PROPAGATION_NEVER
、PROPAGATION_NOT_SUPPORTED
,最好使用**PROPAGATION_REQUIRED
**PROPAGATION_NOT_SUPPORTED
:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。PROPAGATION_NEVER
:以非事务方式执行,如果当前存在事务,则抛出异常。