【云岚到家】10.预约下单-熔断降级
1 预约下单
1.1 需求分析
完成了后台服务管理、服务上架的开发,接下来进入预约下单模块
在预约下单模块我们需要完成下单接口的设计与开发,对接支付系统完成在线支付
首先分析预约下单模块的需求
1.1.1 核心业务流程
首先明确本模块在核心业务流程的位置,下图是项目的核心业务流程:
服务上架后用户端即可在小程序搜索到区域下的服务,点击服务进行预约下单和支付

1.1.2 界面原型
在首页服务列表区域、热门服务列表区域以及全部服务界面,点击服务项名称进入服务详情页面,如下图:


点击立即预约填写预约信息:
选择上门服务地址,此地址从我的地址簿中选择

选择上门时间

填写完成点击“立即预约”

进入支付页面:

支付成功下单完成。

1.1.3 订单状态
订单数据中有一个属性关系到订单的整个流程,就是订单状态
本项目订单状态共有7种,如下图:

待支付:订单的初始状态
派单中:用户支付成功后订单的状态由待支付变为派单中
待服务:服务人员或机构抢单成功订单的状态由派单中变为待服务
服务中:服务人员开始服务,订单状态变为服务中
订单完成:服务人员完成服务订单状态变为订单完成
已取消:订单是待支付状态时用户取消订单,订单状态变为已取消
已关闭:订单已支付状态下取消订单后订单状态变为已关闭
1.2 系统设计
1.2.1 订单表设计
目标:能说出订单表的设计方法
1.订单表设计方案
在设计订单表时通常采用的结构是订单主表与订单明细表一对多关系结构,比如:在电商系统中,一个订单购买的多件不同的商品,设计订单表和订单明细表:
订单表:记录订单号、订单金额、下单人、订单状态等信息
订单明细表:记录该订单购买商品的信息,包括:商品名称、商品价格、交易价格、购买商品数量等

如果系统需求是一个订单只包括一种商品,此时无须记录订单明细,将购买商品的详细信息记录在订单表即可,设计字段包括:订单号、订单金额、下单人、订单状态、商品名称、购买商品数量等
下边根据需求设计本项目的订单表,本项目用户购买服务没有购物车,选择一个服务开始下单,所以本项目不设计订单明细表只设计订单表
2.表结构设计
下边设计本项目订单表的表结构
除了订单号、订单金额、订单状态、下单人ID等字段外,订单表还存储哪些信息?
根据需求梳理预约下单提交的数据如下:
| 属性 | 含义 |
|---|---|
| 订单所属人 | 创建订单的用户id |
| 服务项 | 用户选择服务项 |
| 城市编码 | 用户定位的城市编码 |
| 单价 | 服务的单位 |
| 购买数量 | 购买服务的数量 |
| 订单总金额 | 单价乘以购买数量 |
| 优惠金额 | 根据优惠券计算的优惠金额 |
| 实际支付金额 | 订单总金额减去优惠金额 |
| 服务详细地址 | 家政服务的具体地址 |
| 服务开始时间 | 服务预约时间 |
| 联系人手机号 | 购买家政服务联系人的手机号 |
| 联系人姓名 | 购买家政服务联系人的姓名 |
| 经度 | 家政服务具体地址的经度,来源于我的地址 |
| 纬度 | 家政服务具体地址的纬度,来源于我的地址 |
| 订单状态 | 订单状态代码,下单后状态是未支付状态 |
通过分析,订单表包括以下几部分:
订单基础信息:订单号、订单状态、排序字段、是否显示标记等
价格信息:单价、购买数量、优惠金额、订单总金额等
下单人信息:下单人ID、联系方式、位置信息(相当于收货地址)等
服务(商品)相关信息:服务类型名称、服务项名称、服务单价、价格单位、购买数量等
服务信息相当于商品,如果有订单明细表要在订单明细表中存储,本项目将服务相关信息存储在订单表
1.表结构如下
create table `jzo2o-orders`.orders
(
id bigint not null comment '订单id' constraint `PRIMARY` primary key,
user_id bigint not null comment '订单所属人',
serve_type_id bigint null comment '服务类型id',
serve_type_name varchar(50) null comment '服务类型名称',
serve_item_id bigint not null comment '服务项id',
serve_item_name varchar(50) null comment '服务项名称',
serve_item_img varchar(255) null comment '服务项图片',
unit int null comment '服务单位',
serve_id bigint not null comment '服务id',
orders_status int not null comment '订单状态,0:待支付,100:派单中,200:待服务,300:服务中,400:待评价,500:订单完成,600:已取消,700:已关闭',
pay_status int null comment '支付状态,2:待支付,4:支付成功',
refund_status int null comment '退款状态 1退款中 2退款成功 3退款失败',
price decimal(10, 2) not null comment '单价',
pur_num int default 1 not null comment '购买数量',
total_amount decimal(10, 2) not null comment '订单总金额',
real_pay_amount decimal(10, 2) not null comment '实际支付金额',
discount_amount decimal(10, 2) not null comment '优惠金额',
city_code varchar(20) not null comment '城市编码',
serve_address varchar(255) not null comment '服务详细地址',
contacts_phone varchar(20) not null comment '联系人手机号',
contacts_name varchar(255) not null comment '联系人姓名',
serve_start_time datetime not null comment '服务开始时间',
lon double(10, 5) null comment '经度',
lat double(10, 5) null comment '纬度',
pay_time datetime null comment '支付时间',
evaluation_time datetime null comment '评价时间',
trading_order_no bigint null comment '支付服务交易单号',
transaction_id varchar(50) null comment '第三方支付的交易号',
refund_no bigint null comment '支付服务退款单号',
refund_id varchar(50) null comment '第三方支付的退款单号',
trading_channel varchar(50) null comment '支付渠道',
display int default 1 null comment '用户端是否展示,1:展示,0:隐藏',
sort_by bigint null comment '排序字段,serve_start_time毫秒级时间戳+订单id后六位',
create_time datetime default CURRENT_TIMESTAMP not null,
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP
)comment '订单表' charset = utf8mb4;
2.数据来源分析
| 字段名称 | 中文含义 | 来源 |
|---|---|---|
| id | 订单id | 自动生成 19位:2位年+2位月+2位日+13位序号 |
| user_id | 订单所属人id | 从token中获取 |
| serve_id | 服务id | 前端请求 |
| serve_type_id | 服务类型id | 根据服务id远程调用运营基础服务查询 |
| serve_type_name | 服务类型名称 | 根据服务id远程调用运营基础服务查询 |
| serve_item_id | 服务项id | 根据服务id远程调用运营基础服务查询 |
| serve_item_name | 服务项名称 | 根据服务id远程调用运营基础服务查询 |
| serve_item_img | 服务项图片 | 根据服务id远程调用运营基础服务查询 |
| unit | 服务单位 | 根据服务id远程调用运营基础服务查询 |
| orders_status | 订单状态 | 设置为 未支付 |
| pay_status | 支付状态 | 设置为 未支付 |
| price | 单价 | 根据服务id远程调用运营基础服务查询 |
| pur_num | 购买数量 | 前端请求 |
| total_amount | 订单总金额 | 订单总金额 价格 * 购买数量 |
| real_pay_amount | 实际支付金额 | 实付金额 订单总金额 - 优惠金额 |
| discount_amount | 优惠金额 | 根据优惠券加订单总金额计算优惠金额,暂时为0 |
| city_code | 城市编码 | 根据服务id远程调用运营基础服务查询 |
| serve_address | 服务详细地址 | 远程调用客户中心服务查询我的地址获得 |
| contacts_phone | 联系人手机号 | 远程调用客户中心服务查询我的地址获得 |
| contacts_name | 联系人姓名 | 远程调用客户中心服务查询我的地址获得 |
| serve_start_time | 服务开始时间 | 前端请求 |
| lon | 经度 | 远程调用客户中心服务查询我的地址获得 |
| lat | 纬度 | 远程调用客户中心服务查询我的地址获得 |
| pay_time | 支付时间 | 对接支付服务获取 |
| evaluation_time | 评价时间 | 用户评价的时间,预留 |
| trading_order_no | 支付服务交易单号 | 对接支付服务,支付服务生成的交易单号,支付完成填充 |
| transaction_id | 第三方支付的交易号 | 微信支付的交易号,支付完成填充 |
| refund_no | 支付服务退款单号 | 对接支付服务,支付服务生成的退款单号,退款完成填充 |
| refund_id | 第三方支付的退款单号 | 微信支付的退款单号,退款完成填充 |
| trading_channel | 支付渠道 | 微信、支付等,支付完成填充 |
| display | 用户端是否展示,1:展示,0:隐藏 | 默认为1 |
| sort_by | 排序字段 | 根据服务开始时间转为毫秒时间戳+订单后5位 |
| create_time | 创建时间 | 数据库控制,默认当前时间 |
| update_time | 更新时间 | 数据库控制,更新时默认当前时间 |
1.2.2 搭建订单工程
目标:搭建订单工程
1.搭建订单工程
整个订单模块包括:订单管理、抢单、派单、历史订单四个小模块,对应的工程如下:
| 工程名 | 服务名 | 职责 |
|---|---|---|
| jzo2o-orders-base | 订单模块基础工程 | 提供数据模型、数据访问基础mapper,供其它工程通过maven依赖 |
| jzo2o-orders-manager | 订单管理服务 | 预约下单、服务管理、取消订单等订单管理相关的接口 |
| jzo2o-orders-seize | 抢单服务 | 为服务人员和机构抢单提供服务 |
| jzo2o-orders-dispatch | 派单服务 | 根据派单规则自动为服务人员、机构派送订单 |
| jzo2o-orders-history | 历史订单服务 | 订单冷热分离,历史订单查询接口 |
jzo2o-orders-base作为其它四个工程的公共工程,提供订单模块基础的mapper接口、数据模型等内容
当前我们实现的预约下单小模块属于订单管理模块,所以我们共创建两个工程:jzo2o-orders-base和jzo2o-orders-manager
在Git远程仓库创建jzo2o-orders仓库,并获得仓库地址,通过下边的命令提交至jzo2o-orders仓库:
git init 初始化本地git仓库
git add . 将目录中的文件加入暂存区
git commit -m '初始customer工程' 将代码提交至本地仓库
git remote add origin 远程仓库地址 指定远程仓库地址
git push -u origin "master" 将本地仓库的代码提交至远程仓库
进入项目目录,右键git,打开git界面,执行上边的命令:

git push -u origin “master”,下面提交写少了一个push

使用IDEA打开jzo2o-orders目录,工程结构如下:

创建dev_01分支,预约下单功能在dev_01分支下开发
订单数据库:jzo2o-orders,后面会进行优化分库分表

修改nacos中jzo2o-orders-manager.yaml,屏蔽下边红框中的配置信息(分库分表的配置信息)

2.搭建订单依赖的工程
在jzo2o-customer工程创建dev_02分支并切换到该分支,从课程资料中解压jzo2o-customer-02-0.zip,将代码覆盖dev_02分支中的代码
在jzo2o-foundations工程创建dev_03分支并切换到该分支,从课程资料中解压jzo2o-foundations.zip(完整代码),将代码覆盖dev_03分支中的代码
小结
整个订单工程包括哪几部分?订单工程搭建步骤:
搭建订单工程,包括jzo2o-orders-base和jzo2o-orders-manager
创建订单数据库(独立数据库)
搭建订单依赖的工程:jzo2o-customer、jzo2o-foundations
1.2.3 接口设计
目标:设计下单接
1.设计接口
根据需求分析及订单表的设计进行接口分析:
除了serve_id、pur_num、serve_start_time 由前端传入以外还需要传入以下参数:
优惠券ID:用户选择优惠券,系统根据优惠券的信息计算优惠金额,需要前端传入优惠券的Id
我的地址簿ID:用户从我的地址簿中选择地址,前端传入我的地址簿Id,系统从我的地址簿中查询服务地址及具体的经纬度坐标
接口定义如下:
接口名称:下单接口
接口功能:普通用户创建订单
接口路径:POST/orders-manager/consumer/orders/place
请求数据类型:application/json
请求参数

请求示例:
{
"serveId": 0,
"couponId": 0,
"addressBookId": 0,
"serveStartTime": "",
"purNum": 0
}
响应参数

响应示例:
{
"msg": "OK",
"code": 200,
"data": {
"id": 0
}
}
编写Controller方法:
package com.jzo2o.orders.manager.controller.consumer;
@RestController("consumerOrdersController")
@Api(tags = "用户端-订单相关接口")
@RequestMapping("/consumer/orders")
public class ConsumerOrdersController {
@Resource
private IOrdersManagerService ordersManagerService;
@PostMapping("/place")
@ApiOperation("下单接口")
public PlaceOrderResDTO place(@RequestBody PlaceOrderReqDTO placeOrderReqDTO) {
return null;
}
}
2.service实现思路
下边分析service实现思路,首先定义OrdersCreateServiceImpl 类
package com.jzo2o.orders.manager.service.impl;
/**
* 下单服务类
*/
@Slf4j
@Service
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
/**
* 下单接口
*/
@Override
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//订单转本信息,订单号,订单状态......
//下单人的信息
//服务地址信息需要远程调用jz020-customer服务食询地址薄信息
//服务信息(相当于腐品信息)
//需要远程调用jz020-foundations服务获取服务信息
//价格信息
// 封装成orders对象
// 插入数据库
return null;
}
}
1.3 系统开发
1.3.1 开发远程接口
下单接口保存的数据较多,有一些数据需要远程调用来获取:
- 根据地址簿Id远程调用客户中心,查询我的地址簿信息
- 根据服务Id远程调用运营基础服务,查询服务相关的信息
微服务之间远程调用的接口统一定义在jzo2o-api工程
1.查询地址簿远程接口
查询地址簿远程接口是根据地址簿ID查询地址簿信息,接口定义如下:
接口路径:GET/customer/inner/address-book/{id}
请求数据类型 application/x-www-form-urlencoded


在jzo2o-api工程定义接口
在jzo2o-api工程定义我的地址簿远程查询接口
这里使用@FeignClient注解实现Feign远程调用,此注解是openfeign下的注解,OpenFeign 是 Spring Cloud 对 Feign 进行了集成,并提供了对 Spring Cloud 注解的支持
package com.jzo2o.api.customer;
/**
* 内部接口 - 地址薄相关接口
*/
//contextId 指定FeignClient实例的上下文id,如果不设置默认为类名,value指定微服务的名称,path:指定接口地址
@FeignClient(contextId = "jzo2o-customer", value = "jzo2o-customer", path = "/customer/inner/address-book")
public interface AddressBookApi {
@GetMapping("/{id}")
AddressBookResDTO detail(@PathVariable("id") Long id);
}
定义完成将jzo2o-api打包发布到本地仓库

在jzo2o-customer工程定义实现类
package com.jzo2o.customer.controller.inner;
/**
* 内部接口 - 地址薄相关接口
**/
@RestController
@RequestMapping("inner/address-book")
@Api(tags = "内部接口 - 地址薄相关接口")
public class InnerAddressBookController implements AddressBookApi {
@Resource
private IAddressBookService addressBookService;
@Override
@GetMapping("/{id}")
@ApiOperation("地址薄详情")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "地址薄id", required = true, dataTypeClass = Long.class)
})
public AddressBookResDTO detail(@PathVariable("id") Long id) {
AddressBook addressBook = addressBookService.getById(id);
return BeanUtil.toBean(addressBook, AddressBookResDTO.class);
}
}
2.查询服务信息远程接口
jzo2o.api
package com.jzo2o.api.foundations;
@FeignClient(contextId = "jzo2o-foundations", value = "jzo2o-foundations", path = "/foundations/inner/serve")
public interface ServeApi {
@GetMapping("/{id}")
ServeAggregationResDTO findById(@PathVariable("id") Long id);
}
3.小结
如何编写一个项目内部调用的远程接口?
1.项目内部的远程接口统一放在jzo2o-api工程
2.首先进入jzo2o-api编写接口,注意使用@FeignClient注解
3.进入服务提供者微服务,编写接口实现类
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 了
1.3.2 熔断降级
1.什么是熔断降级(客户端)
在微服务架构一定要去预防微服务雪崩问题,微服务雪崩问题是指在微服务架构中,当一个服务出现故障时,由于服务之间的依赖关系,故障可能会传播到其他服务,导致大规模的服务失败,系统无法正常运行。这种情况就像雪崩一样,最初一个小问题最终引发了整个系统的崩溃
简单理解微服务雪崩就是微服务之间相互调用,因为调用链中的一个服务故障,引起整个链路都无法访问的情况

常用的预防微服务雪崩的的方法:
超时处理:设定超时时间,请求超过一定时间没有响应就返回错误信息,不会无休止等待
熔断降级:当服务的异常数或异常比例超过了预设的阈值时,熔断器会进入开启状态,暂时中断对该服务的请求,此时走降级方法,能够快速响应,确保系统的基本功能能够继续运行
限流:限制对服务的请求速率,避免短时间内大量的请求导致系统崩溃
线程池隔离(异步请求):给要请求的资源分配一个线程池,线程池去控制请求数量

信号量隔离:使用计数器模式,记录请求资源的并发线程数量,达到信号量上限时,禁止新的请求
信号量隔离适合同步请求,控制并发数,比如:对文件的下载并发数进行控制
大多数场景都适合使用线程池隔离,对于需要同步操作控制并发数的场景可以使用信号量隔离
2.使用sentinel实现熔断降级
本项目使用Sentinel(神腾了)实现限流、熔断等机制预防微服务雪崩
熔断降级是微服务保护的一种方法,当使用Feign进行远程调用,在客户端通过熔断降级措施进行微服务保护
如下图:
orders-manager订单服务请求customer查询地址簿,在进行feign远程调用过程出现异常将走降级方法,当异常比例或异常数达到一定的阈值将触发熔断,熔断期间将直接走降级逻辑快速响应
当customer服务恢复后,熔断时间结束此时会再次尝试请求customer,如果成功请求将关闭熔断,恢复原来的链路

下边使用sentinel实现熔断、降级
3.安装sentinel
安装:获取sentinel.zip,并上传至/data/soft目录下
解压sentinel.zip:
unzip sentinel.zip
注意:如果unzip命令不可用则需要安装unzip:
sudo yum install unzip
进入/data/soft/sentinel目录,如下所示:

注意修改docker-compose.yml中的nacos的地址及nacos的账号和密码:
version: "3.8"
services:
sentinel:
container_name: sentinel-dashboard
image: sentinel-dashboard:latest
ports:
- "8080:8080"
environment:
- "TZ=Asia/Shanghai"
# nacos访问地址+端口号
- SENTINEL_NACOS_SERVER_ADDR=192.168.101.68:8848
# nacos访问账号
- SENTINEL_NACOS_USERNAME=nacos
# nacos访问密码
- SENTINEL_NACOS_PASSWORD=nacos
# nacos访问命名空间
- SENTINEL_NACOS_NAMESPACE=75a593f5-33e6-4c65-b2a0-18c403d20f63
# sentinel dashboard平台登录账号
- SENTINEL_USERNAME=sentinel
# sentinel dashboard平台登录密码
- SENTINEL_PASSWORD=sentinel
# sentinel dashboard 访问端口号
- SENTINEL_PORT=8080
执行下边的命令创建镜像,如下:
docker build -t sentinel-dashboard .
下边启动容器:
docker-compose up -d
启动容器成功,通过docker ps命令查看容器:

接下来访问sentinel ,地址:http://192.168.101.68:8080/#/login,需要输入账号和密码,默认都是:sentinel

项目集成sentinel
熔断降级是发生在客户端的,所以应该是客户端的微服务集成sentinel
添加nacos配置文件shared-sentinel.yaml,如下:
spring:
cloud:
sentinel:
transport:
# 供sentinel dashboard平台访问端口
port: 8719
# sentinel控制台
dashboard: 192.168.101.68:8080
#服务启动直接建立心跳连接
eager: true
注意:
1.spring.cloud.sentinel.transport.port端口可以无需修改,如果多个微服务运行在同一个物理机时,同时都要占用8719端口,记得修改端口号,保证sentinel.transport.port端口不冲突,如果使用docker部署一定要将该端口开放
2.sentinel-dashboard注册中心和配置中心一定要和微服务保持一致,不然读不到熔断配置
3.spring.cloud.sentinel.transport.dashboard需要配置成自己sentinel-dashboard的访问地址
在jzo2o-orders-01-0项目中引入shared-sentinel.yaml配置文件

只需要在远程调用的调用方(客户端)配置sentinel即可,订单管理服务调用 客户管理服务,只需要在订单管理服务配置sentienl
jzo2o-orders-01-0项目中添加依赖
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-sentinel</artifactId>
</dependency>
4.使用sentinel实现熔断降级

@EnableFeignClients是一个类级别的注解,主要用于启动Feign客户端扫描。当添加这个注解到你的Spring Boot应用主类或配置类上时,Spring Boot将会自动扫描指定包路径下的所有@FeignClient注解的接口,并为每个接口创建一个实现类,这个实现类实际上就是一个HTTP客户端,能够以声明式的方式调用远程服务
降级逻辑
我们以前使用feign进行熔断降级时,对 @FeignClient

我们平时只需要添加对应的fallback(),然后写同名的降级函数即可,例如:
以前谁用@FeignClient就加在头上,调用fallback、fallbackFactory方法,这样所有的对象调用这个api时都使用同样的方法逻辑

但是仔细思考,我们的熔断降级发生在客户端,不同客户端调用服务端有着不同的降级逻辑,如果我们在服务端这里写死fallback,那么任何的客户端调用都对应相同的fallback逻辑,这明显时不对的
根据上图可知,熔断、降级发生在客户端,下边在订单管理服务(调用customer的客户端)定义CustomerClient类用于请求customer服务
所以新建com.jzo2o.orders.manager.service.impl.client.CustomerClient,代表在orders服务中调用customer的服务逻辑及熔断降级逻辑
package com.jzo2o.orders.manager.service.client;
/**
* 调用customer的客户端类
*/
@Component
@Slf4j
public class CustomerClient {
@Resource
private AddressBookApi addressBookApi;
@SentinelResource(value = "getAddressBookDetail", fallback = "detailFallback", blockHandler = "detailBlockHandler")
public AddressBookResDTO getDetail(Long id) {
log.error("根据id查询地址簿,id:{}", id);
// 调用其他微服务方法
AddressBookResDTO detail = addressBookApi.detail(id);
return detail;
}
//执行异常走
public AddressBookResDTO detailFallback(Long id, Throwable throwable) {
log.error("非限流、熔断等导致的异常执行的降级方法,id:{},throwable:", id, throwable);
return null;
}
//熔断后的降级逻辑
public AddressBookResDTO detailBlockHandler(Long id, BlockException blockException) {
log.error("触发限流、熔断时执行的降级方法,id:{},blockException:", id, blockException);
return null;
}
}
@SentinelResource注解的属性说明:
value: 用于定义资源的名称,即 Sentinel 会对该资源进行流量控制和熔断降级
fallback:非限流、熔断等导致的异常执行的降级方法,当getDetail方法执行异常时调用此方法
blockHandler:触发限流、熔断时执行的降级方法,发送熔断时走这个方法
那么我们的创建订单服务的类就可以注入我们刚刚的CustomerClient来进行访问

下边在下单方法中通过CustomerClient 调用customer:
@Service
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
//客户端注入
@Resource
private CustomerClient customerClient;
@Override
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//地址簿id
Long addressBookId = placeOrderReqDTO.getAddressBookId();
//下单人信息,获取地址簿,调用jzo2o-customer服务获取
//(使用客户端调用)
AddressBookResDTO detail = customerClient.getDetail(addressBookId);
//服务相关信息,调用jzo2o-foundations获取
//生成订单号
//计算价格
//组装订单信息,插入数据库订单表
return null;
}
}
然后controller调用service,serivce再调用OrdersCreateServiceImpl实现类来完成feign远程调用
@RestController("consumerOrdersController")
@Api(tags = "用户端-订单相关接口")
@RequestMapping("/consumer/orders")
public class ConsumerOrdersController {
@Resource
private IOrdersManagerService ordersManagerService;
@Resource
private IOrdersCreateService ordersCreateService;
@ApiOperation("下单接口")
@PostMapping("/place")
public PlaceOrderResDTO place(@RequestBody PlaceOrderReqDTO placeOrderReqDTO) {
PlaceOrderResDTO orderResDTO = ordersCreateService.placeOrder(placeOrderReqDTO);
return null;
}
}
5.测试
测试流程:
1.通过接口文档测试下单接口,触发customerClient.getDetail(addressBookId);
2.在sentinel中配置熔断规则,getAddressBookDetail:客户端定义的资源名称

为了方便测试熔断效果配置异常数规则,如下:

5秒以内最少请求5次,有2次异常则进行熔断。熔断时长为30秒
3.停止customer服务,进行测试
由于customer服务停止导致远程调用异常,调用fallback 方法:
具体流程是:先调用getDetail(Long id) 方法,发生异常调用detailFallback(Long id, Throwable throwable) 方法
当达到熔断阈值触发熔断,将直接走blockHandler 不再走getDetail(Long id) 方法
通过观察控制台日志判断是否达到测试预期结果
7.查询服务信息接口熔断降级(实战)
参考“查询地址簿接口” 的远程调用代码,对查询服务信息接口的远程调用添加熔断、降级的处理方法
前面是针对下单人信息-远程调用jzo2o-customer服务,实战则是针对订单服务信息-远程调用jzo2o-foundations服务
因为熔断降级是在客户端的,所以调用jzo2o-foundations服务获取针对订单服务信息的熔断降级也是写在order端
创建com.jzo2o.orders.manager.service.impl.client.FoundationsClient
@Component
@Slf4j
public class FoundationsClient {
@Resource
private ServeApi serveApi;
@SentinelResource(value = "getServeDetail", fallback = "detailFallback", blockHandler = "detailBlockHandler")
public ServeAggregationResDTO getDetail(Long id) {
log.error("根据id查询服务详情,id:{}", id);
// 调用其他微服务方法
ServeAggregationResDTO serveDetail = serveApi.findById(id);
return serveDetail;
}
//执行异常走
public AddressBookResDTO detailFallback(Long id, Throwable throwable) {
log.error("非限流、熔断等导致的异常执行的降级方法,id:{},throwable:", id, throwable);
return null;
}
//熔断后的降级逻辑
public AddressBookResDTO detailBlockHandler(Long id, BlockException blockException) {
log.error("触发限流、熔断时执行的降级方法,id:{},blockException:", id, blockException);
return null;
}
}
完成,比较简单就不测试了
1.3.3 订单号生成规则
先解决订单号

1.常见的订单号生成规则
自增数字序列
使用数据库的自增主键或者其他递增的数字序列(比如redis的INCR命令)作为订单号的一部分。例如,订单号可以是"202310280001",其中"20231028"表示日期,"0001"是自增的订单序号
时间戳+随机数
将年月日时分秒和一定范围内的随机数组合起来。例如,订单号可以是"20181028124523" + “1234”,其中"20181028124523"表示日期和时间,"1234"是随机生成的数字
使用时间戳+随机数作为主键有重复的风险
简单系统就可以用,并发量不多的就适合
订单类型+日期+序号
将订单类型(例如"01"表示普通订单,“02"表示VIP订单等)、日期和序号组合起来。例如,订单号可以是"0101028100001”,其中"01"表示订单类型,"20181028"表示日期,"00001"是序号。
加上订单类型的好处是方便客户服务,根据订单号就可以知道订单的类
分布式唯一ID生成器
使用分布式唯一ID生成器(例如Snowflake雪花算法)生成全局唯一的ID作为订单号。这种方法保证了在分布式系统中生成的订单号的唯一性和有序性。
Snowflake 算法根据机器ID、时间戳、序号等因素生成,保证全局唯一性,它的优势在于生成的 ID 具有趋势递增、唯一性、高效性等特点
Snowflake 算法对系统时钟的依赖性较强,如果系统时钟发生回拨,可能会导致 ID 生成出现问题。因此,在使用 Snowflake 算法时,需要定时进行时钟同步,确保系统时钟的稳定性
2.本项目订单号生成规则
19位:2位年+2位月+2位日+13位序号
例如:2311011000000000001
实现方案:
1.前6位通过当前时间获取,即 DateUtils.getFormatDate(LocalDateTime.now(), “yyMMdd”)
2.后13位通过Redis的INCR命令实现,即 redisTemplate.opsForValue().increment
3.代码实现
定义订单管理的接口
package com.jzo2o.orders.manager.service.impl;
/**
* 下单服务类
*/
@Slf4j
@Service
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
@Resource
private RedisTemplate<String, Long> redisTemplate;
/**
* 生成订单id 格式:{yyMMdd}{13位id}
*/
private Long generateOrderId() {
//通过Redis自增序列得到序号
Long id = redisTemplate.opsForValue().increment(ORDERS_SHARD_KEY_ID_GENERATOR, 1);
//生成订单号 2位年+2位月+2位日+13位序号
long orderId = DateUtils.getFormatDate(LocalDateTime.now(), "yyMMdd") * 10000000000000L + id;
return orderId;
}
/**
* 下单接口
*/
@Override
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//订单转本信息,订单号,订单状态......
Long orderId = generateOrderId();
//下单人的信息
//服务地址信息需要远程调用jz020-customer服务食询地址薄信息
//服务信息(相当于腐品信息)
//需要远程调用jz020-foundations服务获取服务信息
//价格信息
// 封装成orders对象
// 插入数据库
return null;
}
}
RedisConstants中的常量
package com.jzo2o.orders.base.constants;
public class RedisConstants {
public static final class RedisFormatter {
/**
* 订单分片id获取锁
*/
public static final String ORDERS_SHARD_KEY_ID_LOCK = "ORDERS:SHARD_KEY:LOCK";
public static final String ORDERS_SHARD_KEY_ID_GENERATOR = "ORDERS:SHARD_KEY:GENERATOR";
}
}
1.3.3 下单接口实现
1.Mapper
下单接口向orders表插入一条记录,使用Mybatis-Plus生成的Service类或Mapper接口即可实现,不用单独定义Mapper接口
2.Service
@Slf4j
@Service
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
@Resource
private CustomerClient customerClient;
@Resource
private FoundationsClient foundationsClient;
@Resource
private RedisTemplate<String, Long> redisTemplate;
/**
* 生成订单id 格式:{yyMMdd}{13位id}
*
* @return
*/
private Long generateOrderId() {
//通过Redis自增序列得到序号
Long id = redisTemplate.opsForValue().increment(RedisConstants.Lock.ORDERS_SHARD_KEY_ID_GENERATOR, 1);
//生成订单号 2位年+2位月+2位日+13位序号
long orderId = DateUtils.getFormatDate(LocalDateTime.now(), "yyMMdd") * 10000000000000L + id;
return orderId;
}
@Override
@Transactional(rollbackFor = Exception.class)//回滚的异常类型
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//1.生成订单
//1.1 订单基本信息
//1.2 下单人信息-远程调用jzo2o-customer服务
AddressBookResDTO customerDetail = customerClient.getDetail(placeOrderReqDTO.getAddressBookId());
//1.3 订单服务信息-远程调用jzo2o-foundations服务
ServeAggregationResDTO foundationsDetail = foundationsClient.getDetail(placeOrderReqDTO.getServeId());
//1.4 生成订单id
Long orderId = generateOrderId();
//1.5 订单基础信息
Orders orders = new Orders();
//1.5.0 下单人id
orders.setUserId(UserContext.currentUserId());
//1.5.1 设置订单id
orders.setId(orderId);
//1.5.2 设置服务类型id
orders.setServeTypeId(foundationsDetail.getServeTypeId());
//1.5.3 设置服务类型名称
orders.setServeTypeName(foundationsDetail.getServeTypeName());
//1.5.4 设置服务项id
orders.setServeItemId(foundationsDetail.getServeItemId());
//1.5.5 设置服务项名称
orders.setServeItemName(foundationsDetail.getServeItemName());
//1.5.6 设置服务项图片
orders.setServeItemImg(foundationsDetail.getServeItemImg());
//1.5.7 设置服务单位
orders.setUnit(foundationsDetail.getUnit());
//1.5.8 设置服务id
orders.setServeId(placeOrderReqDTO.getServeId());
//1.5.9 设置订单状态
orders.setOrdersStatus(OrderStatusEnum.NO_PAY.getStatus());
//1.5.10 设置支付状态
orders.setPayStatus(OrderPayStatusEnum.NO_PAY.getStatus());
//1.6 订单价格信息
//1.6.1 单价
orders.setPrice(foundationsDetail.getPrice());
//1.6.1 购买数量
orders.setPurNum(placeOrderReqDTO.getPurNum());
//1.6.2 总价
BigDecimal totalAmount = foundationsDetail.getPrice().multiply(new BigDecimal(placeOrderReqDTO.getPurNum()));
orders.setTotalAmount(totalAmount);
//1.6.3 优惠金额
orders.setDiscountAmount(BigDecimal.ZERO);
//1.6.4 实付金额
orders.setRealPayAmount(NumberUtils.sub(orders.getTotalAmount(), orders.getDiscountAmount()));
//1.7 服务地址信息
//1.7.1 设置服务cityCode
orders.setCityCode(foundationsDetail.getCityCode());
//1.7.2 设置服务地址
String serveAddr = new StringBuffer()
.append(customerDetail.getProvince())
.append(customerDetail.getCity())
.append(customerDetail.getCounty())
.append(customerDetail.getAddress())
.toString();
orders.setServeAddress(serveAddr);
//1.7.3 设置服务人电话
orders.setContactsPhone(customerDetail.getPhone());
//1.7.4 设置服务人姓名
orders.setContactsName(customerDetail.getName());
//1.8 服务其他信息
//1.8.1 设置服务开始时间
orders.setServeStartTime(placeOrderReqDTO.getServeStartTime());
//1.8.2 经纬度
orders.setLon(customerDetail.getLon());
orders.setLat(customerDetail.getLat());
//1.8.3 排序字段
long sortBy = DateUtils.toEpochMilli(orders.getServeStartTime()) + orders.getId() % 100000;
orders.setSortBy(sortBy);
//2 插入数据库
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
return new PlaceOrderResDTO(orders.getId());
}
}
3.Controller
package com.jzo2o.orders.manager.controller.consumer;
@RestController("consumerOrdersController")
@Api(tags = "用户端-订单相关接口")
@RequestMapping("/consumer/orders")
public class ConsumerOrdersController {
@Resource
private IOrdersCreateService ordersCreateService;
@ApiOperation("下单接口")
@PostMapping("/place")
public PlaceOrderResDTO place(@RequestBody PlaceOrderReqDTO placeOrderReqDTO) {
PlaceOrderResDTO placeOrderResDTO = ordersCreateService.placeOrder(placeOrderReqDTO);
return placeOrderResDTO;
}
}
4.测试
测试流程:
启动jzo2o-foundations、jzo2o-customer、jzo2o-publics、jzo2o-gateway、jzo2o-orders-manager服务
打开小程序进行下单,进入首页,点击一个服务进行预约下单
预期结果:下单成功向orders表写入一条记录,注意观察数据的正确性

点击我们的码农洗车,进入详情页

点击立即预约后填写上门时间

查看数据库

1.3.4 下单代码优化
1.事务方法存在远程调用问题
在事务方法@Transactional中存在远程调用是否有问题?
下单方法中远程调用查询地址簿信息和服务信息,远程调用涉及网络传输,如果网络传输时间过长会增加数据库事务的时长,如果并发高会把数据库的链接消耗殆尽,导致系统不能正常工作
将保存订单的代码移动add方法中,add方法只保存订单,去掉placeOrder方法上的@Transactional注解,在add方法上添加@Transactional注解,优化如下:
在com.jzo2o.orders.manager.service.IOrdersCreateService中添加add方法用于保存订单:
public interface IOrdersCreateService extends IService<Orders> {
/**
* 生成订单
*/
void add(Orders orders);
}
实现
@Transactional(rollbackFor = Exception.class)
public void add(Orders orders) {
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
}
修改service的代码,即com.jzo2o.orders.manager.service.impl.OrdersCreateServiceImpl#placeOrder,修改事务的控制粒度
@Override
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
//1.远程调用获取订单相关信息
//1.1 下单人信息-远程调用jzo2o-customer服务
AddressBookResDTO customerDetail = customerClient.getDetail(placeOrderReqDTO.getAddressBookId());
if (customerDetail == null) {
throw new BadRequestException("预约地址异常,无法下单");
}
//1.2 订单服务信息-远程调用jzo2o-foundations服务
ServeAggregationResDTO foundationsDetail = foundationsClient.getDetail(placeOrderReqDTO.getServeId());
if (foundationsDetail == null || foundationsDetail.getSaleStatus() != 2) {
throw new BadRequestException("服务不可用");
}
//2 下单前数据准备
Orders orders = new Orders();
//2.1 生成订单id
Long orderId = generateOrderId();
//2.2 下单人id
orders.setUserId(UserContext.currentUserId());
//2.3 设置订单id
orders.setId(orderId);
//2.4 设置服务类型id
orders.setServeTypeId(foundationsDetail.getServeTypeId());
//2.5 设置服务类型名称
orders.setServeTypeName(foundationsDetail.getServeTypeName());
//2.6 设置服务项id
orders.setServeItemId(foundationsDetail.getServeItemId());
//2.7 设置服务项名称
orders.setServeItemName(foundationsDetail.getServeItemName());
//2.8 设置服务项图片
orders.setServeItemImg(foundationsDetail.getServeItemImg());
//2.9 设置服务单位
orders.setUnit(foundationsDetail.getUnit());
//2.10 设置服务id
orders.setServeId(placeOrderReqDTO.getServeId());
//2.11 设置订单状态
orders.setOrdersStatus(OrderStatusEnum.NO_PAY.getStatus());
//2.12 设置支付状态
orders.setPayStatus(OrderPayStatusEnum.NO_PAY.getStatus());
//3 订单价格信息
//3.1 单价
orders.setPrice(foundationsDetail.getPrice());
//3.2 购买数量
orders.setPurNum(placeOrderReqDTO.getPurNum());
//3.3 总价
BigDecimal totalAmount = foundationsDetail.getPrice().multiply(new BigDecimal(placeOrderReqDTO.getPurNum()));
orders.setTotalAmount(totalAmount);
//3.4 优惠金额
orders.setDiscountAmount(BigDecimal.ZERO);
//3.5 实付金额
orders.setRealPayAmount(NumberUtils.sub(orders.getTotalAmount(), orders.getDiscountAmount()));
//4 服务地址信息
//4.1 设置服务cityCode
orders.setCityCode(foundationsDetail.getCityCode());
//4.2 设置服务地址
String serveAddr = new StringBuffer()
.append(customerDetail.getProvince())
.append(customerDetail.getCity())
.append(customerDetail.getCounty())
.append(customerDetail.getAddress())
.toString();
orders.setServeAddress(serveAddr);
//4.3 设置服务人电话
orders.setContactsPhone(customerDetail.getPhone());
//4.4 设置服务人姓名
orders.setContactsName(customerDetail.getName());
//5 服务其他信息
//5.1 设置服务开始时间
orders.setServeStartTime(placeOrderReqDTO.getServeStartTime());
//5.2 经纬度
orders.setLon(customerDetail.getLon());
orders.setLat(customerDetail.getLat());
//5.3 排序字段
long sortBy = DateUtils.toEpochMilli(orders.getServeStartTime()) + orders.getId() % 100000;
orders.setSortBy(sortBy);
//6 插入数据库
this.add(orders);
//7 返回结果
return new PlaceOrderResDTO(orders.getId());
}
2.事务失效问题
测试事务失效问题
现在对优化后的代码进行测试,测试add方法是否可以进行事务控制,在add方法中添加异常代码:
@Transactional(rollbackFor = Exception.class)
public void add(Orders orders) {
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
//测试事务失效
int i=1/0;
}
如果事务可以被控制,当抛出异常数据库事务进行回滚,最终保存订单失败
经过测试发现事务控制失败,当add方法抛出异常数据库事务并没有回滚,订单信息保存成功
解决事务失效问题
这里为什么会事务失效呢?
首先要明白Spring进行事务控制是通过代理对象进行的,在调用add方法之前开启事务,方法执行结束提交事务

跟踪add方法调用代码,如下图:
并不是通过代理对象执行的add方法

如果是在placeOrder方法上加@Transactional就可以进行事务控制,暂时先在placeOrder方法上添加@Transactional注解,我们跟踪placeOrder方法调用处的代码,在controller方法中调用的placeOrder方法,打断点,如下图:
的确是通过CGLIB代理对象调用的placeOrder方法

执行完成后控制台抛出了异常,事务被控制,订单数据没有保存成功
为什么调用add方法处不是通过代理对象调用呢?
下图展示了代理对象与原始对象之间的关系图:

代理对象调用原始对象的placeOrder方法,在placeOrder方法中通过this.add()调用add方法,this就是原始对象本身并不是代理对象
如何解决呢?
在OrdersCreateServiceImpl注入OrdersCreateServiceImpl的代理对象,通过代理对象去调用add方法
public class OrdersCreateServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersCreateService {
//将自己的代理对象注入
@Resource
private IOrdersCreateService owner;
...
@Override
public PlaceOrderResDTO placeOrder(PlaceOrderReqDTO placeOrderReqDTO) {
...
//保存订单
owner.add(orders);
return new PlaceOrderResDTO(orders.getId());
}
重启订单管理服务继续进行测试,在owner.add(orders);打断点:

从上图可以看出这次是通过代理对象调用的add方法
方法执行完成事务控制成功,add方法抛出异常,事务回滚,订单保存失败,符合预期结果
3.循环依赖问题
通过将自己注入自己,使用代理对象调用add方法解决了事务失效问题,但是有些同学有疑问,这样不会产生循环依赖吗?
什么是循环依赖
在 Spring 中,如果一个 bean 尝试将自身引用注入到自身中,通常会引发循环依赖
首先搞清楚什么是循环依赖:
两个Bean,A依赖B,B依赖A就构成了循环依赖,如下图:

Spring 如何解决循环依赖
以上图为例说明Spring是如何处理循环依赖问题的?
Spring会延迟初始化,B需要注入A,此时Spring会先实例化A,把一个半成品A注入给B,延迟A的初始化(三级缓存)
小结
下单代码怎么优化的?
在数据库事务方法中不要加入网络请求的代码,这样会延长事务时间,占用数据库资源
将数据库事务方法单独提到一个独立的方法中,这里存在非事务方法调用事务方法,要通过代理对象去调用事务方法,才能保证事务控制有效
service方法事务失效的原因是什么?
Spring 如何解决循环依赖?
更多推荐



所有评论(0)