云岚到家后几章,到11章
学习目标
-
能够说出家政项目订单表的设计方案
-
能说出家政项目订单的状态有哪些
-
能够说出常用的订单号生成方案
-
能够完成家政项目下单接口开发与调试
-
能够进行下单方法的优化
-
能说出Service方法事务失效的原因
-
能说出Spring 如何解决循环依赖
-
能够说出防止订单重复提交的方案
-
能够说出优惠券核销设计方案
-
能够开发优惠券核销功能
-
能够说出取消超时未支付订单的设计方案
代码环境
jzo2o-foundations:dev_03
jzo2o-customer:dev_02
jzo2o-market:dev_02
jzo2o-orders:dev_01
1 需求调研
1)订单管理管什么?
AI:订单模块管什么?
订单模块通常是电子商务系统或企业资源规划(ERP)系统中的一个重要组成部分。它负责处理与客户订单相关的所有活动,包括但不限于以下几个方面:
订单创建与接收:当客户下单时,订单模块会记录下所有的订单信息,包括产品或服务的种类、数量、价格、客户的联系信息等。
订单确认与通知:确认订单细节是否正确,并且向客户发送订单确认信息。
库存管理:检查库存水平以确保可以满足订单需求。如果库存不足,则需要启动补货流程或者调整订单状态。
订单履行:协调仓库拣货、包装以及发货等物流环节,确保产品能够准时准确地送达客户手中。
支付处理:与支付网关集成来处理客户的付款,并确认款项已经到账。
订单跟踪:提供订单状态查询功能,允许客户追踪他们订单的最新进展。
退换货管理:处理客户的退货请求,并按照公司的政策进行退款或更换商品。
客户服务支持:提供客户服务支持,解决在订单过程中遇到的问题。
数据分析与报告:收集订单数据用于分析销售趋势、优化库存管理和提高客户满意度等目的。
通过这些功能,订单模块帮助商家更好地管理他们的订单流程,提升运营效率和服务质量。
订单模块的功能列表:
-
创建订单:选择商品和数量,提交订单。
-
订单确认与通知:审核订单的完整性,确认订单并生成订单号,下单成功通知客户,订单状态变更通知客户
-
订单查询:个人查询自己创建的订单,店铺查询本店铺的订单,运营端查询平台的所有订单
-
库存管理:实时检查库存,确保有足够库存来满足订单需求,下单扣减库存,扣减库存失败则取消订单
-
订单履行:创建拣货清单和出库单,对接物流系统
-
支付:订单付款,对接支付系统。
-
退换货管理:处理客户的退换货申请,更新订单状态,处理退款,取消订单。
-
数据分析与报告:订单数据分析、报表
-
客服系统:提供订单相关的客户服务支持,记录客户服务请求及解决方案。
在以上内容中有一些属于订单管理本身的业务,有一些属于外部分系统与订单管理交互的业务,我们的重点放在订单管理本身的业务,如下图:
暂时无法在飞书文档外展示此内容
2)创建订单需求分析
AI: 电商项目中创建订单需要考虑哪些因素
以下是创建电商订单时需要考虑的主要因素:
客户信息:
客户的基本信息,包括姓名、联系方式、收货地址等。
账号信息,如用户名、密码等,用于登录和验证身份。
商品信息:
商品的详细描述,包括名称、规格、图片、属性等。
商品的数量、单价、总价等信息。
促销与优惠:
是否使用了优惠券、积分、折扣码等。
计算优惠后的最终价格。
支付方式:
选择支付方式,如信用卡、借记卡、第三方支付(支付宝、微信支付等)、货到付款等。
确认支付安全,如采用SSL加密技术保护支付信息。
库存校验:
在提交订单前检查商品是否有足够的库存。
如库存不足,需要提示用户或调整订单。
物流与配送:
选择配送方式,如快递、平邮、自提等。
计算运费,并告知预计的送达时间。
订单状态跟踪:
提供订单状态的实时跟踪功能,让用户随时了解订单进度。
订单状态包括待支付、已支付、已发货、已签收等。
发票信息:
用户是否需要发票,发票抬头、税号等信息。
电子发票或纸质发票的选择。
退换货政策:
说明退换货的条件和流程。
用户可以在一定时间内申请退换货。
系统集成与数据同步:
1. 订单信息需要与仓库管理系统(WMS)、物流信息系统(TMS)等进行同步。 2. 数据的一致性和完整性,避免信息孤岛。
- 用户体验:
1. 订单创建流程要简洁明了,避免过多步骤造成用户流失。 2. 用户界面友好,易于理解和操作。
- 安全性与隐私保护:
1. 确保用户个人信息的安全,遵守GDPR等隐私保护法规。 2. 使用加密技术保护用户的支付信息和个人资料。
- 多渠道支持:
1. 支持PC端、移动端等多种设备上的订单创建。 2. 确保跨平台体验一致。创建电商订单时,上述因素都需要综合考虑,以确保订单的顺利生成和执行。同时,也要注意持续优化订单流程,提升用户体验,促进转化率。
根据调研内容在开发创建订单功能时需要实现以下需求:
-
获取创建订单的客户信息,姓名、联系方式、收货地址等
-
获取下单商品的信息,名称、规格、图片、属性、价格
-
获取下单选择的优惠券及优惠金额
-
价格计算,计算订单金额(应付金额,实付金额,优惠金额)
-
校验库存
-
选择支付方式
-
选择物流方式
-
填写发票信息
-
确定订单状态,初始状态?
-
生成订单号
3)小结
能够说出订单模块的功能列表?
下单接口需要考虑哪些因素?
2 家政项目服务预约
1.1 需求分析
完成了后台服务管理、服务上架的开发,接下来进入预约下单模块,用户通过小程序进行服务预约。
在预约下单模块我们需要完成下单接口的设计与开发。
首先分析预约下单模块的需求。
1)核心业务流程
首先明确本模块在核心业务流程的位置,下图是项目的核心业务流程:
服务上架后用户端即可在小程序搜索到区域下的服务,点击服务进行预约下单和支付。
暂时无法在飞书文档外展示此内容
2) 界面原型
在首页服务列表区域、热门服务列表区域以及全部服务界面,点击服务项名称进入服务详情页面,如下图:
填写预约信息
点击立即预约填写预约信息:
选择上门服务地址,此地址从我的地址簿中选择。
选择上门时间
填写完成点击“立即预约”
预约信息包括如下内容:
服务项
预约人信息(预约人、电话等)
服务地址
服务时间
结算页面
进入支付页面(结算页面):
结算页面主要是计算订单价格,显示支付信息等内容。
结算页面显示如下信息:
订单总价
实付金额
支付方式
下单成功
支付成功下单完成。
3)订单状态
订单数据中有一个属性关系到订单的整个流程,就是订单状态。
本项目订单状态共有7种,如下图:
暂时无法在飞书文档外展示此内容
待支付:订单的初始状态。
派单中:用户支付成功后订单的状态由待支付变为派单中。
待服务:服务人员或机构抢单成功订单的状态由派单中变为待服务。
服务中:服务人员开始服务,订单状态变为服务中。
订单完成:服务人员完成服务订单状态变为订单完成。
已取消:订单是待支付状态时用户取消订单,订单状态变为已取消。
已关闭:订单已支付状态下取消订单后订单状态变为已关闭。
4)小结
下单业务流程是什么?
订单的状态有哪些?
1.2 系统设计
1.2.1 订单表设计
目标:能说出订单表的设计方法
1)订单表设计方案
在设计订单表时通常采用的结构是订单主表与订单明细表一对多关系结构,比如:在电商系统中,一个订单购买的多件不同的商品,设计订单表和订单明细表:
订单表:记录订单号、订单金额、下单人、订单状态等信息。
订单明细表:记录该订单购买商品的信息,包括:商品名称、商品价格、交易价格、购买商品数量等。
如下图:
暂时无法在飞书文档外展示此内容
如果系统需求是一个订单只包括一种商品,此时无须记录订单明细,将购买商品的详细信息记录在订单表即可,设计字段包括:订单号、订单金额、下单人、订单状态、商品名称、购买商品数量等。
下边根据需求设计本项目的订单表,本项目用户购买服务没有购物车,选择一个服务开始下单,所以本项目不设计订单明细表只设计订单表。
2)表结构设计
下边设计本项目订单表的表结构。
除了订单号、订单金额、订单状态、下单人ID等字段外,订单表还存储哪些信息?
根据需求梳理预约下单提交的数据如下:
| 属性 | 含义 |
| 订单所属人 | 创建订单的用户id |
| 服务项 | 用户选择服务项 |
| 城市编码 | 用户定位的城市编码 |
| 单价 | 服务的单位 |
| 购买数量 | 购买服务的数量 |
| 订单总金额 | 单价乘以购买数量 |
| 优惠金额 | 根据优惠券计算的优惠金额 |
| 实际支付金额 | 订单总金额减去优惠金额 |
| 服务详细地址 | 家政服务的具体地址 |
| 服务开始时间 | 服务预约时间 |
| 联系人手机号 | 购买家政服务联系人的手机号 |
| 联系人姓名 | 购买家政服务联系人的姓名 |
| 经度 | 家政服务具体地址的经度,来源于我的地址 |
| 纬度 | 家政服务具体地址的纬度,来源于我的地址 |
| 订单状态 | 订单状态代码,下单后状态是未支付状态 |
通过分析,订单表包括以下几部分:
订单基础信息:订单号、订单状态、排序字段、是否显示标记等。
价格信息:单价、购买数量、优惠金额、订单总金额等。
下单人信息:下单人ID、联系方式、位置信息(相当于收货地址)等。
服务(商品)相关信息:服务类型名称、服务项名称、服务单价、价格单位、购买数量等。
服务信息相当于商品,如果有订单明细表要在订单明细表中存储,本项目将服务相关信息存储在订单表。
表结构如下
暂时无法在飞书文档外展示此内容
数据来源分析
| 字段名称 | 中文含义 | 来源 |
| 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 | 更新时间 | 数据库控制,更新时默认当前时间 |
3)小结
订单表是怎么设计的?
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接口、数据模型等内容。
课程提供的仓库地址:https://gitee.com/jzo2o-v2/jzo2o-orders.git
先fork再从自己仓库拉取代码,jzo2o-orders工程结构如下:
切换到dev_01分支,预约下单功能在dev_01分支下开发。
- 创建订单数据库:jzo2o-orders
从课程资料下SQL脚本目录获取jzo2o-orders-alone.sql,导入数据,如下:
修改nacos中jzo2o-orders-manager.yaml:
屏蔽下边红框中的配置信息。
修改nacos中jzo2o-orders-seize.yaml:
修改nacos中jzo2o-orders-dispatch.yaml:
2) 切换分支
基于jzo2o-customer工程的dev_01分支创建dev_02分支,然后使用下发的jzo2o-customer_dev02.zip覆盖dev_02下的代码。
创建数据库jzo2o-customer-all作为jzo2o-customer工程dev_02使用的数据库。
使用下发的jzo2o-customer-all.sql脚本导入jzo2o-customer-all数据库。
在jzo2o-foundations工程创建dev_03分支,然后使用jzo2o-foundations_dev03.zip覆盖dev_03下的代码。
3) 小结
整个订单工程包括哪几部分?
1.2.3 接口设计
目标:设计下单接口。
1)设计接口
根据需求分析及订单表的设计进行接口分析:
除了serve_id、pur_num、serve_start_time 由前端传入以外还需要传入以下参数:
优惠券ID:用户选择优惠券,系统根据优惠券的信息计算优惠金额,需要前端传入优惠券的Id。
我的地址簿ID:用户从我的地址簿中选择地址,前端传入我的地址簿Id,系统从我的地址簿中查询服务地址及具体的经纬度坐标。
接口定义如下:
接口名称:下单接口
接口功能:普通用户创建订单
接口路径:POST/orders-manager/consumer/orders/place
请求数据类型 application/json
请求参数
请求示例:
暂时无法在飞书文档外展示此内容
响应参数
响应示例:
暂时无法在飞书文档外展示此内容
编写Controller方法:
暂时无法在飞书文档外展示此内容
2) service实现思路
下边分析service实现思路,首先在IOrdersCreateService中定义下单方法
暂时无法在飞书文档外展示此内容
在实现类实现方法:
暂时无法在飞书文档外展示此内容
3)小结
下单接口的执行流程?
1.2.4 订单号生成规则
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位通过当前时间获取。
2、后13位通过Redis的INCR 命令实现。
暂时无法在飞书文档外展示此内容
3)代码实现
定义订单管理的接口
暂时无法在飞书文档外展示此内容
4) 小结
常见的订单号生成规则有哪些?
1.3 系统开发
1.3.1 下单接口实现
1) Mapper实现
下单接口向orders表插入一条记录,使用Mybatis-Plus生成的Service类或Mapper接口即可实现,不用单独定义Mapper接口。
2)熟悉远程接口
查询地址簿远程接口
微服务之间远程调用的接口统一定义在jzo2o-api工程。
地址簿远程接口是根据地址簿ID查询地址簿信息,接口定义如下:
接口路径:GET/customer/inner/address-book/{id}
请求数据类型 application/x-www-form-urlencoded
Feign接口如下:
暂时无法在飞书文档外展示此内容
查询服务信息远程接口
查询服务信息的Feign接口如下:
暂时无法在飞书文档外展示此内容
2)Service实现
暂时无法在飞书文档外展示此内容
3) Controller实现
暂时无法在飞书文档外展示此内容
4) 测试
测试流程:
启动jzo2o-foundations服务
启动jzo2o-customer服务。
启动jzo2o-publics服务。
启动jzo2o-gateway服务。
启动jzo2o-orders-manager服务。
打开小程序进行下单。
打开小程序,进入首页,点击一个服务进行预约下单。
预期结果:下单成功向orders表写入一条记录,注意观察数据的正确性。
1.3.3 下单代码优化
1) 事务方法优化
在事务方法中存在远程调用是否有问题?
下单方法中远程调用查询地址簿信息和服务信息,远程调用涉及网络传输,如果网络传输时间过长会增加数据库事务的时长,如果并发高会把数据库的链接消耗殆尽,导致系统不能正常工作。
将保存订单的代码移动add方法中,add方法只保存订单,去掉placeOrder方法上的@Transactional注解,在add方法上添加@Transactional注解,优化如下:
首先增加add方法用于保存订单:
暂时无法在飞书文档外展示此内容
编写方法实现:
暂时无法在飞书文档外展示此内容
2)事务失效问题
- 测试事务失效问题
现在对优化后的代码进行测试,测试add方法是否可以进行事务控制,在add方法中添加异常代码:
暂时无法在飞书文档外展示此内容
如果事务可以被控制,当抛出异常数据库事务进行回滚,最终保存订单失败。
经过测试发现事务控制失败,当add方法抛出异常数据库事务并没有回滚,订单信息保存成功。
- 解决事务失效问题
这里为什么会事务失效呢?
首先要明白Spring进行事务控制是通过代理对象进行的,在调用add方法之前开启事务,方法执行结束提交事务。
跟踪add方法调用代码,如下图:
并不是通过代理对象执行的add方法。
如果是在placeOrder方法上加@Transactional就可以进行事务控制,暂时先在placeOrder方法上添加@Transactional注解,我们跟踪placeOrder方法调用处的代码,在controller方法中调用的placeOrder方法,打断点,如下图:
的确是通过CGLIB代理对象调用的placeOrder方法。
执行完成后控制台抛出了异常,事务被控制,订单数据没有保存成功。
执行原理如下图:
暂时无法在飞书文档外展示此内容
为什么placeOrder方法上没有加事务注解,add方法上添加事务注解不能控制事务呢?
首先placeOrder方法上没有加事务注解是不会在执行此方法之前开启事务的。
其次add方法上虽然上了事务注解,但是代理对象调用原始对象的placeOrder方法,在placeOrder方法中通过this.add()调用add方法,this就是原始对象本身并不是代理对象,所以并不是通过代理对象调用的add(),执行前并没有开启事务。
执行原理如下图:
暂时无法在飞书文档外展示此内容
如何解决呢?
在OrdersCreateServiceImpl注入OrdersCreateServiceImpl的代理对象,通过代理对象去调用add方法.
首先在IOrdersCreateService 接口中增加add方法,如下:
暂时无法在飞书文档外展示此内容
然后修改下单方法:
通过owner去调用add方法。
暂时无法在飞书文档外展示此内容
重启订单管理服务继续进行测试,在owner.add(orders);打断点:
从上图可以看出这次是通过代理对象调用的add方法,并且add方法上添加事务注解,所以在执行add方法之前开启事务,执行add方法后提交事务。
方法执行完成事务控制成功,add方法抛出异常,事务回滚,订单保存失败,符合预期结果。
3) 循环依赖问题
通过将自己注入自己,使用代理对象调用add方法解决了事务失效问题,但是有些同学有疑问,这样不会产生循环依赖吗?
- 什么是循环依赖
在 Spring 中,如果一个 bean 尝试将自身引用注入到自身中,通常会引发循环依赖。
首先搞清楚什么是循环依赖:
两个Bean,A依赖B,B依赖A就构成了循环依赖,如下图:
同样的道理,如果在A中注入A表示A依赖A,也就构成了循环依赖。
- Spring 如何解决循环依赖
以上图为例说明Spring是如何处理循环依赖问题的?
首先按照常规的流程是:
创建A实例--》初始化A--》注入B--》创建B实例--》初始化B--》注入A
在初始化A时需要注入B,要注入B就需要创建B实例再初始化B,而在初始B时需要注入A,此时A还没有创建完成就陷入死循环。
针对循环依赖的问题Spring会上边的过程调整为下边的流程:
创建A实例--》创建B实例--》在B中注入A--》B初始化---》在A中注入B--》A初始化。
Spring是如何做到呢?
Spring会延迟初始化,B需要注入A,此时Spring会先实例化A,把一个半成品A注入给B,延迟A的初始化。
循环依赖的底层原理是Spring通过三级缓存实现:
1)singletonObjects缓存:这是 Spring 容器用来缓存完全初始化好的单例 bean 实例的缓存。当一个 bean 初始化完成后,它会被放入singletonObjects缓存中。这个缓存是单例 bean 的最终缓存,也是 BeanFactory 中保存 bean 的主要缓存。
2)earlySingletonObjects缓存:这个缓存是用来保存被实例化但还未完全初始化的 bean 的引用。当一个 bean 已经被实例化(但还未初始化)时,它会被放入earlySingletonObjects缓存中。
3)singletonFactories缓存:这个缓存保存的是用于创建 bean 实例的 ObjectFactory,用于支持循环依赖的延迟初始化。当一个 bean 被实例化,但尚未完全初始化时,Spring 会在singletonFactories缓存中查找该 bean 的ObjectFactory。这个ObjectFactory会在需要时被调用来完成 bean 的初始化。
Spring 通过这三级缓存的组合,来确保在循环依赖情况下,能够正常初始化 bean。当两个或多个 bean 之间存在循环依赖时,Spring 使用 singletonFactories 缓存来存储 bean 的对应工厂(ObjectFactory)。当一个 bean 在初始化过程中需要依赖另一个还未初始化的 bean 时,Spring 会调用相应的 ObjectFactory 来获取对应的 bean 实例,这样就实现了循环依赖的延迟初始化。一旦 bean 初始化完成,它就会被移动到singletonObjects缓存中。
举例:
创建A实例--》创建B实例--》在B中注入A--》B初始化---》在A中注入B--》A初始化。
创建A实例(半成品),在earlySingletonObjects放入A半成品。
创建B实例(半成品),在earlySingletonObjects放入B半成品。
在B中注入A,通过singletonFactories拿到A的对象工厂,通过对象工厂拿到A的半成品注入到B中。
B初始化完成,将B从earlySingletonObjects移动到singletonObjects。
A初始化完成,将A从earlySingletonObjects移动到singletonObjects。
- 构造参数注入解决循环依赖问题
虽然Spring可以解决上边通过成员变量注入引发的循环依赖问题,但是通过构造参数注入引发的循环依赖问题是会报错。
如下图:
为什么上图中的循环依赖会报错呢?
因为创建C需要调用构造方法,而构造方法需要依赖D,此时C是无法实例化的,上边分析Spring解决循环依赖是通过延迟初始化,当出现循环依赖问题可以注入一个半成品,而这里连半成品都无法创建成功。
如何解决这种通过构造参数注入导致的循环依赖问题呢?
可以在C或D的任意一方注入另一方的代理对象而不是注入原始对象,如下:
假设在C的构造方法中注入D的代理对象可以写为:
在构造参数前加@Lazy注解,表示注入D的代理对象。
暂时无法在飞书文档外展示此内容
- 分析本项目自已注入自己没有循环依赖的原因
我们在OrdersCreateServiceImpl 中注入的是OrdersCreateServiceImpl 的代理对象,并不是OrdersCreateServiceImpl 本身实例,构不成循环依赖。
即使向OrdersCreateServiceImpl 注入的是本身实例也不会报错,Spring通过三级缓存解决循环依赖,会先向成员变量注入一个半成品实例,而后再完成初始化,过程如下:
创建A实例-->向A注入自己-->完成A初始化
4) 小结
下单代码怎么优化的?
在数据库事务方法中不要加入网络请求的代码,这样会延长事务时间,占用数据库资源。
将数据库事务方法单独提到一个独立的方法中,这里存在非事务方法调用事务方法,要通过代理对象去调用事务方法,才能保证事务控制有效。
service方法事务失效的原因是什么?
Spring 如何解决循环依赖?
1.3.4 防止重复提交订单
1)什么情况下会重复提交订单?
以下情况会导致重复提交相同的订单:
网络延迟或不稳定: 当用户点击提交按钮后,由于网络延迟或不稳定,前端可能在等待服务端响应时,用户误以为操作未成功,多次点击提交按钮。
重复点击按钮: 用户可能会因为页面加载缓慢或其他原因感到不耐烦,多次点击提交按钮,导致重复提交订单。
页面刷新后的再次提交: 用户在订单提交后,如果刷新页面,浏览器可能会重新发送上一次的表单提交请求,导致订单的重复提交。
2)如何防止重复提交相同的订单?
防止重复提交相同的订单就是保证创建订单方法的幂等性。
通常需要在前端和后端共同去完成:
前端防重复提交
禁用提交按钮: 在用户点击提交按钮后,立即将按钮禁用,防止用户多次点击。
显示加载中状态: 提交按钮点击后,显示加载中状态,防止用户再次点击。
- 后端防重复提交
利用Token机制
-
生成Token:用户每次进入下单页面,前端会向服务器请求一个唯一的Token。
-
验证Token:用户提交订单时,需要携带这个Token一起发送。服务器接收到订单请求后,验证Token的有效性和唯一性,一旦Token被使用过就失效。
-
存储Token:可以将Token存放在Redis等缓存数据库中,并设置一定的过期时间。
暂时无法在飞书文档外展示此内容
使用分布式锁
以用户id+服务id作为分布式锁锁定下单接口10秒,10秒内该用户不能再次下同一个服务的订单。
暂时无法在飞书文档外展示此内容
3)使用分布锁实现
下边用分布式锁实现,修改下单方法,执行下单方法前获取分布式锁,锁的过期时间设置为30秒,30秒内同一个用户不允许重复购买同一个服务。
我们使用项目封装的Lock注解实现。
暂时无法在飞书文档外展示此内容
基于此注解编写AOP切面类LockAspect,通过代理对象实现加锁与释放锁功能。
LockAspect核心 代码如下:
暂时无法在飞书文档外展示此内容
锁名称:ORDERS:CREATE:LOCK:用户id:服务id
代码如下:
暂时无法在飞书文档外展示此内容
4) 测试
使用小程序针对同一个服务提交两次订单,两次提交的时间间隔小于30秒。
第一次提交成功
在30秒内针对同一个服务再次下单提示“请勿重复下单”
效果如下:
5)小结
如何防止重复订单提交?
AOP在项目中有用吗?怎么用的?
1.3.5 优惠券核销
1.3.5.1 查询可用优惠券接口
1) 交互流程
用户在下单界面可以选择可用的优惠券,订单管理服务调用优惠券服务查询可用的优惠券列表。
选择优惠券,提交订单,进行优惠券核销。
首先要实现订单管理服务远程调用优惠券服务查询可用优惠券。
暂时无法在飞书文档外展示此内容
2)接口定义
订单管理需要调用优惠券服务,传入订单金额。
用户还没有下单,订单金额需要根据服务ID和数量获取。
如下图,购买1台空调维护的订单金额是17元。
前端需要将服务ID和数量传给订单管理服务,计算出订单金额,再调用优惠券服务获取可用优惠券列表。
接口定义如下:
接口名称:获取可用优惠券
接口功能:用户下单,小程序请求订单管理服务接口查询可用的优惠券
接口路径:GET /orders-manager/consumer/orders/getAvailableCoupons
请求数据类型 application/x-www-form-urlencoded
在订单管理服务编写controller定义可用优惠券查询接口:
暂时无法在飞书文档外展示此内容
重启订单管理服务查看接口文档是否正确。
3)定义service接口
暂时无法在飞书文档外展示此内容
定义service接口实现
暂时无法在飞书文档外展示此内容
4) 定义controller方法
暂时无法在飞书文档外展示此内容
5)接口测试
测试流程:
启动优惠券服务
启动客户管理服务
启动订单服务
启动网关
启动运营管理前端
创建优惠券活动,设置门槛比较低或者创建折扣类的优惠券。
通过立即发券接口向用户发放优惠券
打开小程序进行下单,在选择优惠券界面跟踪“查询可用优惠券”接口。
预期:
查到可用的优惠券。
示例:
创建两个优惠券活动:全场八折、全场七折,注意活动开始时间设置和当前时间很近。
使用立即发放优惠券接口向指定用户发放优惠券。
在下单界面查看可用的优惠券
优惠金额最大的排在最上边:
1.3.5.2 优惠券核销
1)交互流程
下单时核销优惠券,创建订单和核销优惠券需要保证事务一致性,要么两者都成功,要么两者都失败。
这里存在分布式事务,如下图:
所以首先我们要实现分布式事务控制,这里使用Seata控制分布式事务。
2)启动seata 的事务协调器
启动seata容器(TC): docker start seata-server
测试,登录地址http://192.168.101.68:7091,账号和密码均为seata/seata,首次登录可能会慢
3) 配置seata环境
修改订单管理服务的bootstrap.yml,添加seata配置文件
在订单基础工程jzo2o-orders-base和优惠券工程jzo2o-market中添加seata依赖
暂时无法在飞书文档外展示此内容
在订单的数据库和优惠券数据库中创建undo_log表,此表记录每个分支事务的undo_log信息。
暂时无法在飞书文档外展示此内容
2) 修改下单方法
根据需求,下边在下单接口中调用优惠券核销接口,在核销优惠券方法中开启全局事务。
定义使用优惠券下单的接口
暂时无法在飞书文档外展示此内容
service实现方法:
暂时无法在飞书文档外展示此内容
修改原有下单方法:
暂时无法在飞书文档外展示此内容
4) 测试
首先保证用户当前有未使用的优惠券。
通过立即发券接口向用户发放优惠券
测试流程:
启动jzo2o-foundations服务
启动jzo2o-customer服务。
启动jzo2o-publics服务。
启动jzo2o-gateway服务。
启动jzo2o-orders-manager服务。
启动jzo2o-market服务。
打开小程序进行下单。
打开小程序,进入首页,点击一个服务进行预约下单。
用户在下单界面选择一个优惠券
提交订单
启动:
选择一张优惠券:
提交订单:
提交成功查看优惠券信息:
status:状态改为已使用
orders_id:使用优惠券的订单id
use_time:使用优惠券时间
查看优惠券核销表:
5) 分布式事务控制测试
修改下单方法,在核销优惠券方法中模拟异常的代码:
暂时无法在飞书文档外展示此内容
重启订单管理服务和优惠券服务
重新下单,选择一个优惠券提交订单
预期结果:
下单异常,优惠券核销回滚
进入优惠券控制台查看关于seata事务回滚的日志:
暂时无法在飞书文档外展示此内容
通过日志Branch Rollbacked result: PhaseTwo_Rollbacked可以看出分布式事务回滚完成。
3 取消未支付订单
3.1 技术方案
通常未支付订单在一定时间内如果不支付将会自动取消,这做的原因是为了释放资源,比如:库存资源。
1)MQ延迟消息
创建订单并发送延迟消息,收到延迟时间判断订单是否支付,如果未支付则取消订单。
暂时无法在飞书文档外展示此内容
2)定时任务加懒加载方式
定时任务方式:
创建订单成功在订单表记录支付超时时间,如果超时时间为15分钟则超时时间为下单时间加15分钟。
在数据库对支付超时时间字段和订单状态字段加索引。
定时任务每分钟扫描订单表中未支付状态且到达超时时间的订单,执行取消操作。
懒加载方式:
定时任务方式不能准时做到超时取消订单,这里可以通过懒加载方式实现,当用户查看订单详情时判断如果订单未支付且支付超时,此时触发订单取消操作。
3)用户支付成功订单却自动取消怎么办?
举例说明:
用户在下单后14分钟59秒去支付订单,在用户打开支付窗口去支付时订单达到超时时间,此时自动取消订单,用户支付成功。
怎么解决此问题?
给用户留足够的时间进行支付即可,一般支付二维码失效时间在5分钟内失效,当用户开始支付时以支付时间为起点加上5分钟作为支付结束时间,如果支付结束时间超过了订单超时时间则更新订单的超时时间为此时间,否则不更新。
订单到达超时时间将不能进行支付。
如果万一有漏网之鱼,订单取消后过一段时间订单服务才收到用户支付成功的消息,此时系统要记录该支付记录并给用户退款。
4)取消订单退回优惠券
订单取消后需要调用优惠券退回接口退回优惠券,这里需要进行分布式事务控制。
5)本项目方案
前边我们使用了延迟消息取消订单的方案,本项目使用定时任务加懒加载的方式自动取消支付超时的订单。
3.2 定义取消订单方法
1)需求分析
我们先实现手动取消订单,再由定时任务调用取消订单的方法。
用户在订单列表点击订单信息进入订单详情页面,点击“取消订单”
进入取消订单界面:
选择取消原因,点击“提交”。
在订单的不同状态下去取消订单其执行的逻辑是不同的,如下图:
暂时无法在飞书文档外展示此内容
本章先处理待支付状态取消订单,待支付状态取消订单后将订单状态改为已取消,并且在订单取消记录表保存取消订单的记录信息。
订单取消记录表结构如下:
暂时无法在飞书文档外展示此内容
2)service方法
代码如下:
暂时无法在飞书文档外展示此内容
方法实现:
暂时无法在飞书文档外展示此内容
3)controller方法
暂时无法在飞书文档外展示此内容
4)测试
测试流程:
新创建一个订单,不进行支付。
进入订单列表,点击订单信息进入订单详情页面,点击“取消订单”
预期结果:
查看订单表中订单状态改为600(已取消)
订单取消记录表orders_canceled是否成功插入一条记录。
3.3 实现定时任务取消订单
1)查询超时订单
- 定义service查询支付超时订单
暂时无法在飞书文档外展示此内容
实现方法:
暂时无法在飞书文档外展示此内容
2)定义定时任务
暂时无法在飞书文档外展示此内容
3)测试
定时测试流程:
找一个超过30分钟未支付的订单,如果没有符合条件的订单可以修改订单表的over_time时间为当前时间之前。
在xxl-job中创建定时任务(每分钟执行一次),并启动任务。
在定时任务方法中打断点进行调试。
预期结果:
订单到达未支付超时时间由定时任务进行取消。
4) 小结
自动取消支付超时订单怎么实现的?
-
定时任务扫描支付超时的订单进行取消。
-
懒加载方式取消订单
通过以上两种方式结合实现自动取消未支付订单。
3.4 懒加载方式取消订单
1)定义懒加载方法
在订单详情service中添加
暂时无法在飞书文档外展示此内容
2)测试
懒加载方式取消订单测试流程:
首先关闭支付超时取消订单的定时任务,以免影响被动取消功能。
找一个超过30分钟未支付的订单,如果没有符合条件的订单可以修改订单表的超时时间在当前时间之前。
在代码canalIfPayOvertime方法中打断点。
打开小程序,进入该订单的详情页面,跟踪代码执行过程。
预期结果:
超时订单打开变为已取消。
示例:
在未什么中找到该订单,点击订单名称打开订单详情界面
订单自动取消后进入订单详情页面显示“订单已取消”
4 删除订单(自学)
1)需求分析
删除订单和取消订单不同,取消订单的目的是终止订单,删除订单是不希望订单信息出现订单列表中。
此需求通常是针对普通用户提供。
用户删除订单后订单信息将不在订单列表显示。
也有平台提供订单回收站查询功能,即查询已经删除的订单。
2)技术方案分析
根据需求,我们在订单表添加逻辑删除标记,删除订单相当于隐藏订单。
在订单表添加字段:display (1:展示,0:隐藏)
删除订单将此字段设置为0。
在订单列表中只查询display 为1的订单信息。
3)阅读代码
- 接口定义
暂时无法在飞书文档外展示此内容
- service方法实现
暂时无法在飞书文档外展示此内容
学习目标
-
能够说出对接小程序支付接口的流程
-
能够测试通过支付服务的支付和退款接口
-
能够说出支付服务核心表设计
-
能够说出如何防止重复支付
-
能够说出对接支付服务接口有哪些及流程
-
能够对接支付服务的支付接口
-
能够对接支付服务查询支付结果接口
-
能够开发支付结果通知功能
-
能够说出取消订单模块的设计方案
-
能够实现取消订单自动退款功能
-
能够说出策略模式在取消订单模块的应用方案
1 支付服务
1.1 支付接口调研
通常项目的支付业务会开发独立的支付服务,由支付服务对接第三方支付平台,如:微信、支付宝、聚合支付平台等。
对于小程序支付需要对接微信的小程序支付接口,对于APP支付和PC网站支付可以对接微信、支付宝或第三方聚合支付平台。
目标:根据需求调研支付接口,确定本项目需要对接哪些接口。
1)界面原型
用户下单成功提示用户支付,用户支付成功即下单成功,下单成功后家政服务人员才可以进行抢单,如下图:
用户使用的是微信小程序,所以支付过程是使用微信支付,点击确认支付即弹出微信支付界面,输入支付密码确认支付,支付成功后显示下单成功。
2) 小程序支付调研
参考“微信支付调研 v1.0”文档。
通过调研小程序支付,我们系统需要与小程序支付接口对接有:
下单接口:家政系统后端调用 微信支付的下单接口。
支付通知:微信支付调用家政系统后端接口。
商户订单号查询:家政系统后端调用 微信支付的商户订单号查询接口。
这里有一个问题不方便广大学生学习:
就是在小程序调起支付这里,微信会校验小程序的APPID与微信支付商户的ID是否绑定,微信支付商户的ID怎么获取呢?是需要注册商户上传企业资料及法人资料,微信审核通过后方可 注册成功,所以注册成为一个普通商户对大家有限制。
针对这些限制我们尝试调研其它的接口。
3) 扫码支付调研
下边调研扫码支付支付,此接口不存在小程序端调起支付的限制,要注意:开发小程序一定要对接微信小程序的支付接口,在小程序上使用 的扫码支付是不符合小程序支付的用户需求的,这里仅是用于学习。
参考“微信支付调研 v1.0”文档。
4)如何保证接口安全性?
第三方支付平台除了使用https协议进行加密传输以外,还通过接口签名以及敏感参数加密的方式保证接口的安全性。
拿 微信支付接口为例,请求任何微信支付接口都需要对请求参数进行签名,微信会进行验签,如果通过才会进行业务处理。
详细参见:https://pay.weixin.qq.com/docs/merchant/development/interface-rules/signature-generation.html
什么是签名和验签
签名是对原始数据通过签名算法生成的一段数据(签名串),用于证明数据的真实性和完整性。签名通常使用密钥进行生成,这个密钥可以是对称密钥或非对称密钥中的私钥。
验签是对签名串进行验证的过程,用于确认数据的真实性和完整性。验签的过程通常使用与签名过程中相对应的公钥进行解密。
签名和验签是为了防止内容被篡改。
参考微信支付的文档,签名方式如下:
第一步生成签名串:
签名串一共有五行,每一行为一个参数。结尾以\n(换行符,ASCII编码值为0x0A)结束,包括最后一行。如果参数本身以\n结束,也需要附加一个\n。
HTTP请求方法\n
URL\n
请求时间戳\n
请求随机串\n
请求报文主体\n
示例:
GET\n
2/v3/certificates\n
31554208460\n
4593BEC0C930BF1AFEB40B4A08C8FB242\n
5\n
第二步计算签名值
使用商户私钥对待签名串进行SHA256 with RSA签名,并对签名结果进行Base64编码得到签名值。
第三步请求设置http头
通过HTTP Authorization头来传递签名信息,如下:
Authorization: 认证类型 签名信息
示例:
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="1900009191",nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242",signature="uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==",timestamp="1554208460",serial_no="1DDE55AD98ED71D6EDD4A4A16996DE7B47773A8C"
WECHATPAY2-SHA256-RSA2048:这是签名算法的标识,表明使用的是基于SHA-256和RSA-2048的签名方法。
mchid="1900009191":商户ID(Merchant ID),这是微信支付分配给商户的唯一标识。
nonce_str="593BEC0C930BF1AFEB40B4A08C8FB242":随机字符串(Nonce String),用于确保HTTP请求的不可重复性(non-replayability)。每次请求都应该不同。
signature="... ":签名(Signature),这是经过签名算法计算后得到的一串字符,用于验证请求的完整性和真实性。这里省略了一部分,因为签名太长了。签名是通过将其他参数与商户的私钥一起进行加密运算得出的。
timestamp="1554208460":时间戳(Timestamp),表示请求的时间点,通常为Unix时间戳格式(从1970年1月1日00:00:00开始所经过的时间秒数)。这里的值对应于2019年4月2日 04:14:20 UTC。
serial_no="1DDE55AD98ED71D6EDD4A4A16996DE7B47773A8C":证书序列号(Serial Number),用于标识微信支付颁发给商户的数字证书。
微信支付收到请求根据请求头Authorization的内容进行验签,验证信息是否被篡改。
同样,如果微信支付通知支付结果给业务系统,业务系统也需要使用SHA256 with RSA进行验签。
参考:https://pay.weixin.qq.com/docs/merchant/development/interface-rules/signature-verification.html
获取证书
检查平台证书序列号
检查时间戳是否已过期
获取应答时间戳、应答随机串、应答报文主体,组成验签串,行尾以
\n结束,举例如下:1554209980\n c5ac7061fccab6bf3e254dcf98995b8c\n {"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"4de73afd28b6","associated_data":"certificate","ciphertext":"..."}}]}\n
- 获取应答签名,
Wechatpay-Signature: uOVRnA4qG/MNnYzdQxJanN+zU+lTgIcnU9BxGw5dKjK+VdEUz2FeIoC+D5sB/LN+nGzX3hfZg6r5wT1pl2ZobmIc6p0ldN7J6yDgUzbX8Uk3sD4a4eZVPTBvqNDoUqcYMlZ9uuDdCvNv4TM3c1WzsXUrExwVkI1XO5jCNbgDJ25nkT/c1gIFvqoogl7MdSFGc4W4xZsqCItnqbypR3RuGIlR9h9vlRsy7zJR9PBI83X8alLDIfR1ukt1P7tMnmogZ0cuDY8cZsd8ZlCgLadmvej58SLsIkVxFJ8XyUgx9FmutKSYTmYtWBZ0+tNvfGmbXU7cob8H/4nLBiCwIUFluw==使用 base64 解码
Wechatpay-Signature字段值,将保存为文件signature.txt。$openssl base64 -d -A <<< \ 'CtcbzwtQjN8rnOXItEBJ5aQFSnIXESeV28Pr2YEmf9wsDQ8Nx25ytW6FXBCAFdrr0mgqngX3AD9gNzjnNHzSGTPBSsaEkIfhPF4b8YRRTpny88tNLyprXA0GU5ID3DkZHpjFkX1hAp/D0fva2GKjGRLtvYbtUk/OLYqFuzbjt3yOBzJSKQqJsvbXILffgAmX4pKql+Ln+6UPvSCeKwznvtPaEx+9nMBmKu7Wpbqm/+2ksc0XwjD+xlvlECkCxfD/OJ4gN3IurE0fpjxIkvHDiinQmk51BI7zQD8k1znU7r/spPqB+vZjc5ep6DC5wZUpFu5vJ8MoNKjCu8wnzyCFdA==' > signature.txt
- 最后,获取证书中的公钥,验证签名,得到验签结果。
$ openssl dgst -sha256 -verify 1900009191_wxp_pub.pem -signature signature.txt << EOF 1554209980 c5ac7061fccab6bf3e254dcf98995b8c {"data":[{"serial_no":"5157F09EFDC096DE15EBE81A47057A7232F1B8E1","effective_time":"2018-03-26T11:39:50+08:00","expire_time":"2023-03-25T11:39:50+08:00","encrypt_certificate":{"algorithm":"AEAD_AES_256_GCM","nonce":"d215b0511e9c","associated_data":"certificate","ciphertext":"..."}}]} EOF Verified OK
加密与解密
加密是将原始的、可读的数据(称为明文)通过某种算法和密钥转换成不可读的数据(称为密文)。加密的目的是防止未经授权的访问者能够理解或识别被加密的信息。加密算法通常基于密钥,有对称加密和非对称加密两种主要类型。
解密是加密的逆过程,即将密文还原为明文。只有持有正确密钥的人或系统能够进行解密操作。解密的目的是还原加密前的原始信息,使其能够被理解和使用。
-
对称加密:使用相同的密钥进行加密和解密。这种方法的优点是速度快,适合大量数据的加密处理。常见的对称加密算法包括AES(高级加密标准)、DES(数据加密标准)、3DES(三重数据加密算法)等。
-
非对称加密:使用一对密钥,即公钥和私钥。公钥可以公开分发,而私钥则必须保密。公钥用于加密消息,私钥用于解密消息;或者反过来,在数字签名的情况下,私钥用于创建签名,而公钥用于验证签名。常见的非对称加密算法包括RSA(Rivest-Shamir-Adleman)、ECC(椭圆曲线密码术)等。
需要注意的是,在实际应用中,非对称加密主要用于密钥交换或数字签名,因为它的计算量大,速度相对较慢。对于大量的数据加密,通常会结合使用对称加密和非对称加密技术,即先用对称加密算法加密数据,再用非对称加密算法来保护对称密钥的安全传输。这种组合使用的方式称为混合加密系统。
加密与解密是为了防止内容被泄露,保证内容的机密性。
为了保证安全性,微信支付在回调通知和平台证书下载接口中,对关键信息进行了AES-256-GCM加密,商户收到报文后,要解密出明文,APIv3密钥是解密时使用的对称密钥。参考:https://pay.weixin.qq.com/docs/merchant/development/interface-rules/certificate-callback-decryption.html
非对称加密算法用于加解密上为什么采用公钥加密,岂不是更不安全?为什么签名验签过程是私钥签名公钥验签?
公私钥是一对儿,公钥加密只能私钥解密,只要保护好私钥就能保证安全性,没有私钥就无法解密。
签名验签是为了防止内容被篡改,是为了保证数据一定出自谁,持有私钥的人对内容进行签名就保证了此内容不能被篡改,此内容出自己我这里。
5)小结
本节调用了小程序支付接口与扫码支付(Native支付接口)。
由于学习的需要本项目使用Native支付接口。
但是要注意:开发小程序一定要对接微信小程序的支付接口,我们使用 的扫码支付是不符合小程序支付的用户需求的,这里仅是用于学习。
你们对接的哪个支付接口?怎么对接的?
提示:首先说明是对接的小程序支付接口,需要对接哪些接口及与第三方支付平台的交互流程。
如何保证一个外部接口的安全性?
对于登录、支付包含敏感信息的接口一定要使用https进行加密传输。
通过签名、验签防止内容篡改,我们对接第三方支付接口都会进行签名验签的过程,使用非对称加密算法 更安全,比如:SHA256 with RSA算法。
通过加密、解密防止内容暴露,通过非对称加密算法 更安全。
HTTPS协议加解密过程?
HTTPS协议结合了对称加密和非对称加密两种技术,以实现高效而安全的数据传输。下面详细解释这两种加密方式在HTTPS中的作用及工作原理:
非对称加密
非对称加密,也称为公钥加密,使用一对密钥:公钥和私钥。公钥可以公开分发,而私钥必须保密。非对称加密的主要优点是可以解决密钥分发的问题,即如何安全地将密钥从一方传递给另一方。然而,它的计算成本相对较高,不适合大量数据的加密。
在HTTPS中的应用
-
密钥交换:在HTTPS握手过程中,客户端使用服务器的公钥(从服务器的数字证书中获取)来加密一个随机生成的预主密钥(Pre-Master Secret)。这个预主密钥随后被发送到服务器。
-
密钥派生:服务器使用自己的私钥解密预主密钥。然后,客户端和服务器分别使用这个预主密钥以及握手过程中交换的随机数来独立地生成会话密钥(Session Key)。
对称加密
对称加密使用同一个密钥进行数据的加密和解密。与非对称加密相比,对称加密的计算效率更高,适合大量数据的加密。
总结
HTTPS协议巧妙地结合了对称加密和非对称加密的优点:
-
非对称加密用于安全地交换会话密钥,解决了密钥分发的问题。
-
对称加密则用于实际的数据传输,确保了高效率和高性能。
通过这种方式,HTTPS不仅能够提供数据的保密性和完整性,还能有效防止中间人攻击,确保通信双方的身份验证。这种方法在现代互联网安全中得到了广泛应用,是保护在线交易、个人隐私和其他敏感信息的重要手段。
1.2 测试支付接口
1)认识支付服务
在我们项目中针对支付业务专门开发了支付服务,由支付服务与微信进行接口对接,家政平台的订单服务只需和支付服务对接即可。
本项目的支付服务支持微信和支付宝的Native、jsapi等常用支付方式。
用户发起支付请求订单管理服务,订单管理服务请求支付服务进行支付,项目所有支付业务统一由支付服务去实现。如下图:
暂时无法在飞书文档外展示此内容
为什么单独开发一个支付服务呢?
一个项目或一个企业通常有很多系统,这些系统有很多都有支付的需求,如果每个系统都和微信、支付宝等这些第三方平台对接一遍很多工作是重复的,抽取了支付服务就是要把这个工作进行简化,简化开发支付业务,通常你到企业中一般都是和现成的支付系统进行对接。
2)部署支付服务
课程提供支付服务jzo2o-trade的git仓库地址为: https://gitee.com/jzo2o-v2/jzo2o-trade.git
先fork到自己的仓库中,再从自己仓库拉取。
拉取成功 切换到jzo2o分支。
jzo2o-trade创建完成如下图:
需要注意配置bootstrap-dev.yml中的nacos地址。
创建nacos配置文件jzo2o-trade.yaml,内容如下:
jzo2o:
job:
refund:
count: 100
trading:
count: 100
qrcode:
back-color: '#ffffff'
error-correction-level: M
fore-color: '#000000'
height: 300
margin: 2
width: 300
xxl-job:
port: 11604
rabbit-mq:
enable: true
示例图:
创建jzo2o-trade数据库,从课程资料中获取jzo2o-trade.sql并导入此脚本。
下边启动支付服务,启动成功访问接口文档:http://localhost:11505/trade/doc.html#/home
3)测试支付接口
- 交互流程
下边我们测试支付接口,用户点击支付按钮,前端请求订单服务,订单服务请求支付服务,最后支付服务请求微信的下单接口。
流程如下:
暂时无法在飞书文档外展示此内容
- 接口定义
下边我们测试支付服务的统一收单线下交易接口。
接口定义如下:
接口文档地址:http://localhost:11505/trade/doc.html#/default/内部接口 - Native支付/createDownLineTradingUsingPOST
接口路径:POST/trade/inner/native
请求数据类型 application/json
请求参数说明:
changeChannel:当用户先微信支付,然后又进行支付宝支付表示切换了支付渠道,此时传入true
enterpriseId:商户号,进入微信或支付宝商户平台获取。
memo: 备注信息
productAppId 业务系统标识
productOrderNo 是业务系统的订单号,本项目就是家政服务的订单号。
tradingAmount:支付金额
tradingChannel 支付渠道,可用值:ALI_PAY,WECHAT_PAY
响应参数说明:
placeOrderMsg 统一下单返回信息
tradingChannel:微信或支付宝
tradingOrderNo:支付服务生成的交易号
productOrderNo:业务系统的订单号,本项目就是家政服务的订单号。
qrCode:二维码base64
- 接口测试
下边我们使用接口文档的调试功能进行接口测试
请求下边的参数:
注意:保证每次请求支付下单传入productOrderNo不一致,productOrderNo表示商品的订单,同一个订单不能重复支付
{
"changeChannel": false,
"tradingChannel": "WECHAT_PAY",
"memo": "日常保洁",
"productOrderNo": 104,
"productAppId": "jzo2o.orders",
"enterpriseId": 1561414331,
"tradingAmount": 0.1
}
响应结果如下:
{
"qrCode": "data:image/png;base64,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",
"productOrderNo": "104",
"tradingOrderNo": "1836674158562390017",
"tradingChannel": "WECHAT_PAY",
"placeOrderMsg": "weixin://wxpay/bizpayurl?pr=N0WkUfuz1"
}
注意:这里要记住支付服务生成的交易单号tradingOrderNo,稍后查询交易结果使用。
复制qrCode粘贴到浏览器,出现二维码,如下图:
下边进行扫码支付。
4)支付结果查询
支付成功,下边测试 “根据交易单号查询交易单的交易结果”接口
接口文档URL:http://localhost:11505/trade/doc.html#/default/内部接口 - 交易单服务/findTradResultByTradingOrderNoUsingGET
接口定义如下:
交易单号是支付服务生成的,在下单接口响应中存在。
下边的结果,我们主要关注:
productOrderNo 业务系统订单号
tradingOrderNo 支付服务交易单号
tradingState 交易状态 (4表示已付款)
下边进行测试,传入交易单号:
响应结果:
{
"id": "1836688504315195394",
"openId": null,
"productOrderNo": "104",
"tradingOrderNo": "1836688500523544578",
"transactionId": "4200002373202409194529213788",
"tradingChannel": "WECHAT_PAY",
"tradingType": "FK",
"payeeName": null,
"payeeId": null,
"payerName": null,
"payerId": null,
"tradingAmount": "0.10",
"refund": null,
"isRefund": null,
"resultCode": "SUCCESS",
"resultMsg": "支付成功",
"resultJson": "{\"amount\":{\"currency\":\"CNY\",\"payer_currency\":\"CNY\",\"payer_total\":10,\"total\":10},\"appid\":\"wx6592a2db3f85ed25\",\"attach\":\"\",\"bank_type\":\"OTHERS\",\"mchid\":\"1561414331\",\"out_trade_no\":\"1836688500523544578\",\"payer\":{\"openid\":\"otdlR4_yoPINKcnvtdPlSUnk2XAc\"},\"promotion_detail\":[],\"success_time\":\"2024-09-19T16:48:19+08:00\",\"trade_state\":\"SUCCESS\",\"trade_state_desc\":\"支付成功\",\"trade_type\":\"NATIVE\",\"transaction_id\":\"4200002373202409194529213788\"}",
"placeOrderCode": "200",
"placeOrderMsg": "weixin://wxpay/bizpayurl?pr=ZFqPYkxz3",
"placeOrderJson": "{\"status\":200,\"body\":\"{\\\"code_url\\\":\\\"weixin://wxpay/bizpayurl?pr=ZFqPYkxz3\\\"}\"}",
"enterpriseId": "1561414331",
"memo": "日常保洁",
"qrCode": "",
"enableFlag": "YES",
"outRequestNo": null,
"operTionRefund": null,
"created": null,
"updated": null,
"tradingState": 4
}
5)测试退款接口
退款接口定义:
接口文档URL:http://localhost:11505/trade/doc.html#/default/内部接口 - 退款/refundTradingUsingPOST
接口路径:POST/trade/inner/refund-record/refund
请求参数中:交易单号还是下单时支付服务生成的交易单号,在生成二维码接口中响应。
退款金额:小于等于支付金额。
退款单号:每次退款的退款单号必须唯一,多次退款总和不能大于支付金额。
响应结果中有退款状态,还有支付服务添的退款单号以及第三方支付(微信、支付宝)生成的退款单号。
我们主要关注退款状态:1表示退款中,2表示退款成功 3表示退款失败。
测试接口:
6)小结
项目的支付服务有哪些接口?
1.3 理解支付服务的设计
1)表设计
首先熟悉支付服务的数据库表
- 支付渠道表
支付渠道表存储了第三方支付(微信、支付宝)的支付参数,如:商户号、证书序列号、api私钥等信息。
create table `jzo2o-trade`.pay_channel
(
id bigint not null comment '主键'
constraint `PRIMARY`
primary key,
channel_name varchar(32) null comment '通道名称',
channel_label varchar(32) null comment '通道唯一标记',
domain varchar(255) null comment '域名',
app_id varchar(32) collate utf8_bin not null comment '商户appid',
public_key varchar(2000) collate utf8_bin not null comment '支付公钥',
merchant_private_key varchar(2000) collate utf8_bin not null comment '商户私钥',
other_config varchar(1000) null comment '其他配置',
encrypt_key varchar(255) charset utf8mb4 null comment 'AES混淆密钥',
remark varchar(400) null comment '说明',
notify_url varchar(255) null comment '回调地址',
enable_flag varchar(10) null comment '是否有效',
enterprise_id bigint null comment '商户ID【系统内部识别使用】',
create_time datetime default CURRENT_TIMESTAMP null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '交易渠道表' collate = utf8mb4_unicode_ci;
- 交易单表
支付服务请求第三方支付下单成功向交易表写入一条记录
家政服务的一个订单可能对应支付服务的多条交易单,比如:用户用微信支付在交易单表生成一条交易单,如果微信支付失败再用支付宝支付时也会在交易单表中生成一条记录。
用户支付成功后支付服务更新交易单表的支付状态。
create table `jzo2o-trade`.trading
(
id bigint not null comment '主键'
constraint `PRIMARY`
primary key,
product_order_no bigint not null comment '业务系统订单号',
trading_order_no bigint not null comment '交易系统订单号【对于三方来说:商户订单】',
transaction_id varchar(50) null comment '第三方支付交易号',
trading_channel varchar(32) charset utf8mb4 not null comment '支付渠道【支付宝、微信、现金、免单挂账】',
trading_type varchar(22) not null comment '交易类型【付款、退款、免单、挂账】',
trading_state int not null comment '交易单状态【2-付款中,3-付款失败,4-已结算,5-取消订单,6-免单,7-挂账】',
payee_name varchar(50) null comment '收款人姓名',
payee_id bigint null comment '收款人账户ID',
payer_name varchar(50) null comment '付款人姓名',
payer_id bigint null comment '付款人Id',
trading_amount decimal(22, 2) not null comment '交易金额,单位:元',
refund decimal(12, 2) null comment '退款金额【付款后,单位:元',
is_refund varchar(32) charset utf8mb4 null comment '是否有退款:YES,NO',
result_code varchar(80) null comment '第三方交易返回编码【最终确认交易结果】',
result_msg varchar(255) null comment '第三方交易返回提示消息【最终确认交易信息】',
result_json varchar(2000) null comment '第三方交易返回信息json【分析交易最终信息】',
place_order_code varchar(80) null comment '统一下单返回编码',
place_order_msg varchar(255) null comment '统一下单返回信息',
place_order_json text null comment '统一下单返回信息json【用于生产二维码、Android ios唤醒支付等】',
enterprise_id bigint not null comment '商户号',
memo varchar(150) null comment '备注【订单门店,桌台信息】',
qr_code text null comment '二维码base64数据',
open_id varchar(36) collate utf8mb4_unicode_ci null comment 'open_id标识',
enable_flag varchar(10) null comment '是否有效',
create_time datetime default CURRENT_TIMESTAMP null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',
constraint trading_order_no
unique (trading_order_no) comment '支付订单号'
)
comment '交易订单表' charset = utf8;
- 退款记录表
用户申请退款在退款记录表写一条记录。
退款成功后支付服务更新退款状态。
create table `jzo2o-trade`.refund_record
(
id bigint not null comment '主键'
constraint `PRIMARY`
primary key,
trading_order_no bigint not null comment '交易系统订单号【对于三方来说:商户订单】',
product_order_no bigint not null comment '业务系统订单号',
refund_no bigint not null comment '本次退款订单号',
refund_id varchar(50) null comment '第三方支付的退款单号',
enterprise_id bigint not null comment '商户号',
trading_channel varchar(32) charset utf8mb4 not null comment '退款渠道【支付宝、微信、现金】',
refund_status int not null comment '退款状态:0-发起退款,1-退款中,2-成功, 3-失败',
refund_code varchar(80) charset utf8 null comment '返回编码',
refund_msg text charset utf8 null comment '返回信息',
memo varchar(150) charset utf8 null comment '备注【订单门店,桌台信息】',
refund_amount decimal(12, 2) not null comment '本次退款金额',
total decimal(12, 2) not null comment '原订单金额',
create_time datetime default CURRENT_TIMESTAMP null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',
constraint refund_no
unique (refund_no)
)
comment '退款记录表' collate = utf8mb4_unicode_ci;
2)数据流
黄色:业务系统
绿色:支付系统
粉色:第三方支付平台(微信、支付宝)
暂时无法在飞书文档外展示此内容
支付服务的四个接口:
支付接口:
收到支付请求后请求第三方支付的下单接口,并向交易单表新增记录。
查询交易结果接口:
请求第三方支付的查询支付结果并更新交易单表的支付状态。
接收第三方通知支付结果:
更新交易单表的支付状态。
退款接口:
新增退款记录
请求第三方退款结果查询接口查询退款状态,并更新退款状态。
支付结果通知任务:
通过MQ通知业务支付结果。
3)阅读代码
下边阅读支付接口与退款接口的核心代码:
支付接口(生成二维码)
首先查询交易单是否生成交易单,如果已生成直接返回
如果切换支付渠道且已存在原有支付渠道的交易单,则先关闭原支付渠道的交易单,避免重复支付。
使用分布式锁防止同一个交易单重复支付:
根据支付渠道调用不同的bean请求第三方支付
获取第三方返回的支付URL,支付服务生成支付二维码,插入交易单记录。
退款接口
没有退款记录时插入退款记录
请求第三方退款接口
支付通知任务
@Component
public class TradeJob {
@Value("${jzo2o.job.trading.count:100}")
private Integer tradingCount;
@Value("${jzo2o.job.refund.count:100}")
private Integer refundCount;
@Resource
private TradingService tradingService;
@Resource
private RefundRecordService refundRecordService;
@Resource
private BasicPayService basicPayService;
@Resource
private RabbitClient rabbitClient;
/**
* 分片广播方式查询支付状态
* 逻辑:每次最多查询{tradingCount}个未完成的交易单,交易单id与shardTotal取模,值等于shardIndex进行处理
*/
@XxlJob("tradingJob")
public void tradingJob() {
// 分片参数
int shardIndex = NumberUtil.max(XxlJobHelper.getShardIndex(), 0);
int shardTotal = NumberUtil.max(XxlJobHelper.getShardTotal(), 1);
List<Trading> list = this.tradingService.findListByTradingState(TradingStateEnum.FKZ, tradingCount);
if (CollUtil.isEmpty(list)) {
XxlJobHelper.log("查询到交易单列表为空!shardIndex = {}, shardTotal = {}", shardIndex, shardTotal);
return;
}
//定义消息通知列表,只要是状态不为【付款中】就需要通知其他系统
List<TradeStatusMsg> tradeMsgList = new ArrayList<>();
for (Trading trading : list) {
if (trading.getTradingOrderNo() % shardTotal != shardIndex) {
continue;
}
try {
//查询交易单
TradingDTO tradingDTO = this.basicPayService.queryTradingResult(trading.getTradingOrderNo());
if (TradingStateEnum.FKZ != tradingDTO.getTradingState()) {
TradeStatusMsg tradeStatusMsg = TradeStatusMsg.builder()
.tradingOrderNo(trading.getTradingOrderNo())
.productOrderNo(trading.getProductOrderNo())
.productAppId(trading.getProductAppId())
.transactionId(tradingDTO.getTransactionId())
.tradingChannel(tradingDTO.getTradingChannel())
.statusCode(tradingDTO.getTradingState().getCode())
.statusName(tradingDTO.getTradingState().name())
.info(tradingDTO.getMemo())//备注信息
.build();
tradeMsgList.add(tradeStatusMsg);
}else{
//如果是未支付,需要判断下时间,超过20分钟未支付的订单需要关闭订单以及设置状态为QXDD
long between = LocalDateTimeUtil.between(trading.getCreateTime(), LocalDateTimeUtil.now(), ChronoUnit.MINUTES);
if (between >= 20) {
try {
basicPayService.closeTrading(trading.getTradingOrderNo());
} catch (Exception e) {
log.error("超过20分钟未支付自动关单出现异常,交易单号:{}",trading.getTradingOrderNo());
}
}
}
} catch (Exception e) {
XxlJobHelper.log("查询交易单出错!shardIndex = {}, shardTotal = {}, trading = {}", shardIndex, shardTotal, trading, e);
}
}
if (CollUtil.isEmpty(tradeMsgList)) {
return;
}
//发送消息通知其他系统
String msg = JSONUtil.toJsonStr(tradeMsgList);
rabbitClient.sendMsg(MqConstants.Exchanges.TRADE, MqConstants.RoutingKeys.TRADE_UPDATE_STATUS, msg);
}
4)小结
支付服务的核心表有哪些?
如何防止重复支付?
2 家政订单支付功能
2.1 对接支付接口
2.1.1 支付接口定义
1)交互流程
用户点击支付按钮,前端请求订单服务,订单服务请求支付服务,最后支付服务请求微信的下单接口。
流程如下:
暂时无法在飞书文档外展示此内容
通过交互流程可知:
订单管理服务携带订单号等信息请求支付服务生成支付二维码,拿到交易单号将其和支付渠道更新到订单表。
最后订单管理服务将交易单信息及二维码返回给前端。
2)支付接口分析
下边我们实现订单管理服务的发起支付接口,接口位置如下图:
- 请求参数分析
用户在支付界面点击“确定支付”,弹出支付二维码,用户扫码支付。
要生成二维码需要由订单管理服务请求支付服务的支付接口,支付服务的支付接口如下:
接口路径:POST/trade/inner/native
请求数据类型 application/json
支付服务的支付接口需要以下参数:
productAppId业务系统标识
productOrderNo是业务系统的订单号,本项目就是家政服务的订单号。
tradingAmount:支付金额
enterpriseId:商户号,进入微信或支付宝商户平台获取。
memo: 备注信息
tradingChannel:微信支付传入WECHAT_PAY,支付宝支付传入ALI_PAY
changeChannel:当用户先微信支付,然后又进行支付宝支付表示切换了支付渠道,此时传入true
我们需要根据支付服务的支付接口的参数分析这些参数的数据来源
productOrderNo:订单号,由前端传入
tradingAmount:根据订单号查询订单信息即可拿到金额
enterpriseId:在nacos配置好
memo:程序拼装
tradingChannel:支付渠道,前端传入
changeChannel:根据订单号查询订单表的trading_channel字段来判断。第一次支付后将第一次支付的支付渠道更新至订单表,第二次如果切换了支付渠道通过trading_channel字段可以知道是否切换支付渠道。
所以综上分析,前端请求订单管理服务提供的支付接口需要传入:
productOrderNo: 订单id
tradingChannel:支付渠道。
- 分析响应参数
支付服务的支付接口的响应参数如下:
tradingChannel:微信或支付宝
tradingOrderNo:支付服务生成的交易号
productOrderNo:业务系统的订单号,本项目就是家政服务的订单号。
qrCode:二维码base64
要在前端展示二维码所以一定要响应支付二维码。
如果用户已经支付成功了,此时去点击“完成支付”要能够给前端返回支付结果为成功。
订单管理服务提供的支付接口响应结果如下:
支付二维码
支付状态(待支付、支付成功)
业务系统订单号
支付服务交易单号
支付渠道信息
3) 接口定义如下
下边定义订单管理服务提供的支付接口:
编写controller:
@RestController("consumerOrdersController")
@Api(tags = "用户端-订单相关接口")
@RequestMapping("/consumer/orders")
public class ConsumerOrdersController {
@Resource
private IOrdersCreateService ordersCreateService;
@Resource
private IOrdersManagerService ordersManagerService;
@PutMapping("/pay/{id}")
@ApiOperation("订单支付")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class)
})
public OrdersPayResDTO pay(@PathVariable("id") Long id, @RequestBody OrdersPayReqDTO ordersPayReqDTO) {
return null;
}
4)小结
订单管理服务的支付接口是怎么定义的?
根据调用的支付服务的支付接口去定义:
由于教学需要此支付接口通过支付服务请求微信生成支付二维码。
传入参数:
订单id
支付渠道
响应结果:
支付二维码
支付状态(待支付、支付成功)
业务系统订单号
支付服务交易单号
支付渠道信息
2.1.2 支付接口实现
下边实现支付接口。
1)配置支付参数
根据支付接口的分析,首先在jzo2o-orders-manager.yaml中配置支付参数
jzo2o:
trade:
aliEnterpriseId: 2088241317544335
wechatEnterpriseId: 1561414331
job:
autoEvaluateCount: 100
openPay: false
示例如下:
2)mapper
根据接口分析,请求支付服务生成支付二维码成功将交易单号和支付渠道更新到订单表中。
3) service
在service定义支付接口:
public interface IOrdersCreateService extends IService<Orders> {
....
/**
* 订单支付
*
* @param id 订单id
* @param ordersPayReqDTO 订单支付请求体
* @return 订单支付响应体
*/
OrdersPayResDTO pay(Long id, OrdersPayReqDTO ordersPayReqDTO);
首先在service类注入下边两个bean
tradeProperties:读取支付参数
nativePayApi: 远程调用支付服务的feign接口
@Resource
private TradeProperties tradeProperties;
@Resource
private NativePayApi nativePayApi;
支付方法实现如下:
/**
* 订单支付
*
* @param id 订单id
* @param ordersPayReqDTO 订单支付请求体
* @return 订单支付响应体
*/
@Override
public OrdersPayResDTO pay(Long id, OrdersPayReqDTO ordersPayReqDTO) {
Orders orders = baseMapper.selectById(id);
if (ObjectUtil.isNull(orders)) {
throw new CommonException(TRADE_FAILED, "订单不存在");
}
//订单的支付状态为成功直接返回
if (OrderPayStatusEnum.PAY_SUCCESS.getStatus() == orders.getPayStatus()
&& ObjectUtil.isNotEmpty(orders.getTradingOrderNo())) {
OrdersPayResDTO ordersPayResDTO = new OrdersPayResDTO();
ordersPayResDTO.setPayStatus(orders.getPayStatus());
ordersPayResDTO.setProductOrderNo(orders.getId());
ordersPayResDTO.setTradingOrderNo(orders.getTradingOrderNo());
ordersPayResDTO.setTradingChannel(orders.getTradingChannel());
return ordersPayResDTO;
} else {
//生成二维码
NativePayResDTO nativePayResDTO = generateQrCode(orders, ordersPayReqDTO.getTradingChannel());
OrdersPayResDTO ordersPayResDTO = BeanUtil.toBean(nativePayResDTO, OrdersPayResDTO.class);
return ordersPayResDTO;
}
}
//生成二维码
private NativePayResDTO generateQrCode(Orders orders, PayChannelEnum tradingChannel) {
//判断支付渠道
Long enterpriseId = ObjectUtil.equal(PayChannelEnum.ALI_PAY, tradingChannel) ?
tradeProperties.getAliEnterpriseId() : tradeProperties.getWechatEnterpriseId();
//构建支付请求参数
NativePayReqDTO nativePayReqDTO = new NativePayReqDTO();
//商户号
nativePayReqDTO.setEnterpriseId(enterpriseId);
//业务系统标识
nativePayReqDTO.setProductAppId("jzo2o.orders");
//家政订单号
nativePayReqDTO.setProductOrderNo(orders.getId());
//支付渠道
nativePayReqDTO.setTradingChannel(tradingChannel);
//支付金额
nativePayReqDTO.setTradingAmount(orders.getRealPayAmount());
//备注信息
nativePayReqDTO.setMemo(orders.getServeItemName());
//判断是否切换支付渠道
if (ObjectUtil.isNotEmpty(orders.getTradingChannel())
&& ObjectUtil.notEqual(orders.getTradingChannel(), tradingChannel.toString())) {
nativePayReqDTO.setChangeChannel(true);
}
//生成支付二维码
NativePayResDTO downLineTrading = nativePayApi.createDownLineTrading(nativePayReqDTO);
if(ObjectUtils.isNotNull(downLineTrading)){
log.info("订单:{}请求支付,生成二维码:{}",orders.getId(),downLineTrading.toString());
//将二维码更新到交易订单中
boolean update = lambdaUpdate()
.eq(Orders::getId, downLineTrading.getProductOrderNo())
.set(Orders::getTradingOrderNo, downLineTrading.getTradingOrderNo())
.set(Orders::getTradingChannel, downLineTrading.getTradingChannel())
.update();
if(!update){
throw new CommonException("订单:"+orders.getId()+"请求支付更新交易单号失败");
}
}
return downLineTrading;
}
4) controller
@PutMapping("/pay/{id}")
@ApiOperation("订单支付")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class)
})
public OrdersPayResDTO pay(@PathVariable("id") Long id, @RequestBody OrdersPayReqDTO ordersPayReqDTO) {
OrdersPayResDTO pay = ordersCreateService.pay(id, ordersPayReqDTO);
return pay;
}
5)接口测试
测试流程:
打开小程序,进入首页,点击一个服务进行预约下单,点击“确定支付”
预期结果:
是生成支付二维码,如下图:
向支付服务的交易单表插入一条记录,注意观察记录内容的准确性。
下边进行测试:
启动jzo2o-foundations服务
启动jzo2o-customer服务
启动jzo2o-publics服务
启动jzo2o-gateway服务
启动jzo2o-orders-manager服务
启动jzo2o-trade服务
打开小程序进行测试。
6)小结
支付接口是怎么开发的?
2.2 对接查询支付结果
2.2.1 接口定义
1)交互流程
在用户支付后用户点击“完成支付”此时前端请求订单服务的查询支付结果接口,如果支付成功则跳转到支付成功界面。
交互流程如下:
暂时无法在飞书文档外展示此内容
此接口对于支付中的订单最终由支付服务调用微信查询支付结果。
订单管理服务查询到支付结果后更新订单的支付状态。
2) 接口分析
- 传入参数
本接口要调用支付服务的支付结果查询接口,根据支付服务的支付结果查询接口的传入参数分析本接口的传入参数。
支付服务的支付结果查询接口需要传入交易单号。交易单号在订单表已经保存所以前端传入订单号即可拿到交易单号。
所以传入参数:订单号
- 响应结果
本接口的目的是查询支付结果,所以响应结果中要有支付结果,其它的参数就是订单号、交易单号等相关参数。
3)接口定义
controller方法定义:
@GetMapping("/pay/{id}/result")
@ApiOperation("查询订单支付结果")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class)
})
public OrdersPayResDTO payResult(@PathVariable("id") Long id) {
return null;
}
2.2.2 接口实现
1) mapper
拿到支付结果更新订单表的支付结果。
2) service
根据接口分析,查询支付结果需要做两部分的工作:
调用支付服务的查询支付结果接口获取支付结果。
将支付结果更新至订单表的支付状态字段。
定义接口方法如下:
public interface IOrdersCreateService extends IService<Orders> {
/**
* 请求支付服务查询支付结果
*
* @param id 订单id
* @return 订单支付结果
*/
OrdersPayResDTO getPayResultFromTradServer(Long id);
...
在OrdersCreateServiceImpl中注入tradingApi(远程查询交易单feign接口):
@Resource
private TradingApi tradingApi;
方法实现如下:
@Override
public OrdersPayResDTO getPayResultFromTradServer(Long id) {
//查询订单表
Orders orders = baseMapper.selectById(id);
if (ObjectUtil.isNull(orders)) {
throw new CommonException(TRADE_FAILED, "订单不存在");
}
//支付结果
Integer payStatus = orders.getPayStatus();
//未支付且已存在支付服务的交易单号此时远程调用支付服务查询支付结果
if (OrderPayStatusEnum.NO_PAY.getStatus() == payStatus
&& ObjectUtil.isNotEmpty(orders.getTradingOrderNo())) {
//远程调用支付服务查询支付结果
TradingResDTO tradingResDTO = tradingApi.findTradResultByTradingOrderNo(orders.getTradingOrderNo());
//如果支付成功这里更新订单状态
if (ObjectUtil.isNotNull(tradingResDTO)
&& ObjectUtil.equals(tradingResDTO.getTradingState(), TradingStateEnum.YJS)) {
//设置订单的支付状态成功
TradeStatusMsg msg = TradeStatusMsg.builder()
.productOrderNo(String.valueOf(orders.getId()))
.tradingChannel(tradingResDTO.getTradingChannel())
.statusCode(TradingStateEnum.YJS.getCode())
.tradingOrderNo(tradingResDTO.getTradingOrderNo())
.transactionId(tradingResDTO.getTransactionId())
.build();
owner.paySuccess(msg);
//构造返回数据
OrdersPayResDTO ordersPayResDTO = BeanUtils.toBean(msg , OrdersPayResDTO.class);
ordersPayResDTO.setPayStatus(OrderPayStatusEnum.PAY_SUCCESS.getStatus());
return ordersPayResDTO;
}
}
OrdersPayResDTO ordersPayResDTO = new OrdersPayResDTO();
ordersPayResDTO.setPayStatus(payStatus);
ordersPayResDTO.setProductOrderNo(orders.getId());
ordersPayResDTO.setTradingOrderNo(orders.getTradingOrderNo());
ordersPayResDTO.setTradingChannel(orders.getTradingChannel());
return ordersPayResDTO;
}
/**
* 支付成功, 其他信息暂且不填
*
* @param tradeStatusMsg 交易状态消息
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void paySuccess(TradeStatusMsg tradeStatusMsg) {
//查询订单
Orders orders = baseMapper.selectById(Long.parseLong(tradeStatusMsg.getProductOrderNo()));
if (ObjectUtil.isNull(orders)) {
throw new CommonException(TRADE_FAILED, "订单不存在");
}
//校验支付状态如果不是待支付状态则不作处理
if (ObjectUtil.notEqual(OrderPayStatusEnum.NO_PAY.getStatus(), orders.getPayStatus())) {
log.info("更新订单支付成功,当前订单:{}支付状态不是待支付状态", orders.getId());
return;
}
//校验订单状态如果不是待支付状态则不作处理
if (ObjectUtils.notEqual(OrderStatusEnum.NO_PAY.getStatus(),orders.getOrdersStatus())) {
log.info("更新订单支付成功,当前订单:{}状态不是待支付状态", orders.getId());
return;
}
//第三方支付单号校验
if (ObjectUtil.isEmpty(tradeStatusMsg.getTransactionId())) {
log.error("支付成功事件处理失败,缺少第三方支付单号,订单号:{}", orders.getId());
return;
}
//更新订单的支付状态及第三方交易单号等信息
boolean update = lambdaUpdate()
.eq(Orders::getId, orders.getId())//订单号
.eq(Orders::getPayStatus, OrderPayStatusEnum.NO_PAY.getStatus())//原支付状态
.eq(Orders::getOrdersStatus, OrderStatusEnum.NO_PAY.getStatus())//原订单状态
.set(Orders::getPayTime, LocalDateTime.now())//支付时间
.set(Orders::getTradingOrderNo, tradeStatusMsg.getTradingOrderNo())//交易单号
.set(Orders::getTradingChannel, tradeStatusMsg.getTradingChannel())//支付渠道
.set(Orders::getTransactionId, tradeStatusMsg.getTransactionId())//第三方支付交易号
.set(Orders::getPayStatus, OrderPayStatusEnum.PAY_SUCCESS.getStatus())//支付状态
.set(Orders::getOrdersStatus, OrderStatusEnum.DISPATCHING.getStatus())//订单状态更新为派单中
.update();
if(!update){
log.info("更新订单:{}支付成功失败", orders.getId());
throw new CommonException("更新订单"+orders.getId()+"支付成功失败");
}
}
3) controller
@GetMapping("/pay/{id}/result")
@ApiOperation("查询订单支付结果")
@ApiImplicitParams({
@ApiImplicitParam(name = "id", value = "订单id", required = true, dataTypeClass = Long.class)
})
public OrdersPayResDTO payResult(@PathVariable("id") Long id) {
//支付结果
OrdersPayResDTO ordersPayResDTO = ordersCreateService.getPayResultFromTradServer(id);
return ordersPayResDTO;
}
4)前后端联调
测试流程如下:
打开小程序,进入首页,点击一个服务进行预约下单,点击“确定支付”,用户扫码支付后点击“支付完成”。
预期结果:
如下图,显示支付成功:
然后界面跳转到下单成功界面。
下边开始测试:
启动jzo2o-foundations服务
启动jzo2o-customer服务。
启动jzo2o-publics服务。
启动jzo2o-gateway服务。
启动jzo2o-orders-manager服务。
启动jzo2o-trade服务。
进入小程序进行下单、支付、点击支付完成
预期结果:
查询orders表的对应记录,成功更新为:支付状态pay_status为4表示支付成功,orders_status状态为100表示派单中、支付服务交易单号trading_order_no、第三方交易单号transaction_id。
支付服务的交易单表对应记录的支付结果字段trading_state为4表示成功,并且记录了第三方的交易号transaction_id字段。
2.3 接收支付通知
2.3.1 代码实现
用户支付成功后支付服务 获取支付结果后会通知业务系统,业务系统收到支付结果只处理属于自己的支付通知,根据支付结果更新订单表的支付状态,本节实现订单管理服务接收支付通知更新支付状态功能。
1)交互流程
订单管理服务接收支付通知的交互流程如下:
支付服务将支付结果发送到MQ,订单管理服务监听MQ,收到支付结果,更新订单表的支付状态。
暂时无法在飞书文档外展示此内容
这里有个问题是:支付服务作为项目的公共支付服务,对接支付服务的可能不止家政服务订单还可能有其它收费订单,比如:体检项目订单服务等等,支付服务如何将属于每个收费订单的支付结果通知给它们呢?
- 首先在请求支付服务支付接口中需要传入product_app_id,它表示请求支付业务系统的应用标识,此应用标识会存储到支付服务的交易单表
暂时无法在飞书文档外展示此内容
- 支付服务通知支付结果时将交易单中的product_app_id一起发给各个监听MQ的微服务。
具体的方法是:
支付服务向jzo2o.exchange.topic.trade交换机发送消息,Routing Key=UPDATE_STATUS
绑定此交换机的有多个队列,每个队列是不同的收费订单支付通知队列,如下图:
当支付服务向jzo2o.exchange.topic.trade交换机发送一条支付通知消息,所有绑定此交换机的队列且Routing Key=UPDATE_STATUS都会收到支付通知。
业务系统收到支付结果后解析出product_app_id,判断是否属于自己的支付结果通知,如果是则进行处理。
整体交互流程如下:
暂时无法在飞书文档外展示此内容
2) 编写代码
编写代码监听MQ支付通知,首先需要知道支付服务发送到MQ的消息格式是什么,查看源代码:
通过查阅源代码,支付服务发送的支付通知数据格式是JSON,具体内容是TradeStatusMsg列表(List
下边编写订单管理服务监听MQ支付通知的代码,监听jzo2o.queue.orders.trade.update.Status队列,routing Key为UPDATE_STATUS。
代码如下:
package com.jzo2o.orders.manager.listener;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.TypeReference;
import cn.hutool.json.JSONUtil;
import com.jzo2o.api.trade.enums.TradingStateEnum;
import com.jzo2o.common.constants.MqConstants;
import com.jzo2o.common.model.msg.TradeStatusMsg;
import com.jzo2o.orders.manager.service.IOrdersCreateService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;
/**
* 监听mq消息,接收支付结果
*
* @author itcast
**/
@Slf4j
@Component
public class TradeStatusListener {
@Resource
private IOrdersCreateService ordersCreateService;
/**
* 更新支付结果
* 支付成功
*
* @param msg 消息
*/
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = MqConstants.Queues.ORDERS_TRADE_UPDATE_STATUS),
exchange = @Exchange(name = MqConstants.Exchanges.TRADE, type = ExchangeTypes.TOPIC),
key = MqConstants.RoutingKeys.TRADE_UPDATE_STATUS
))
public void listenTradeUpdatePayStatusMsg(String msg) {
log.info("接收到支付结果状态的消息 ({})-> {}", MqConstants.Queues.ORDERS_TRADE_UPDATE_STATUS, msg);
//将msg转为java对象
List<TradeStatusMsg> tradeStatusMsgList= JSON.parseArray(msg, TradeStatusMsg.class);
// 只处理家政服务的订单且是支付成功的
List<TradeStatusMsg> msgList = tradeStatusMsgList.stream().filter(v -> v.getStatusCode().equals(TradingStateEnum.YJS.getCode()) && "jzo2o.orders".equals(v.getProductAppId())).collect(Collectors.toList());
if (CollUtil.isEmpty(msgList)) {
return;
}
//修改订单状态
msgList.forEach(m -> ordersCreateService.paySuccess(m));
}
}
3)小结
项目中有用到MQ吗?怎么用的?
用户支付成功,订单状态因为网络波动-修改订单状态失败。这个咋样解决
首先在用户端有一个支付完成的按钮,如果用户支付成功但是显示未支付此时用户会点击“支付完成”按钮,用户点击“支付完成”按钮后程序会主动请求支付服务查询支付结果,最终会请求微信拿到最新的支付结果。
即使用户没有点击“支付完成”也没事,支付服务通过MQ将支付结果通知给家政订单服务,如果订单服务没有正常消费成功此时消息仍然在MQ,会继续发给订单服务,订单服务获取了支付结果就会更新订单的支付状态。
2.3.2 测试
1)支付通知定时任务
在xxl-job中配置为支付服务配置支付通知的定时任务
首先配置执行器:
添加任务:
启动任务:
2)前后端测试
本次测试流程如下:
打开小程序,进入首页,点击一个服务进行预约下单,点击“确定支付”,生成支付二维码,用户扫码支付。
如下图,注意支付完成不要点击“支付完成”,等待程序自动接收支付通知。
下边进行测试:
启动jzo2o-foundations服务
启动jzo2o-customer服务。
启动jzo2o-publics服务。
启动jzo2o-gateway服务。
启动jzo2o-orders-manager服务。
启动jzo2o-trade服务。
打开小程序下单、支付。
注意支付完成不要点击“支付完成”,等待程序自动接收支付通知。
预期结果:
支付服务正常发送消息到MQ,发到 jzo2o.queue.orders.trade.update.Status队列。
订单管理服务正常接收到MQ的消息,在TradeStatusListener类中打断点可正常解析消息。
处理完成观察数据库的数据是否正确:
查询orders表的对应记录,成功更新为:支付状态pay_status为4表示支付成功,orders_status状态为100表示派单中、支付服务交易单号trading_order_no、第三方交易单号transaction_id。
支付服务的交易单表对应记录的支付结果字段trading_state为4表示成功,并且记录了第三方的交易号transaction_id字段。
示例:
订单服务收到支付结果
3 家政项目取消订单
3.1 取消订单功能分析设计
目标:
设计取消订单接口。
退款流程设计。
1)需求分析
下边我们分析在订单的不同状态下去取消订单其执行的逻辑,如下图:
暂时无法在飞书文档外展示此内容
订单在不同状态下执行取消订单操作的需求:
待支付:
支付超时系统自动取消。
用户取消订单,订单状态改为已取消。
派单中:
用户和运营人员都可以取消订单,订单状态改为已关闭。
到达服务时间还没有派单成功系统自动取消。
取消订单后自动退款。
删除抢单池记录。(抢单模块用到)
待服务:
用户和运营人员都可以取消订单,订单状态改为已关闭。
取消订单后自动退款。
服务中:
只有运营人员可以取消,订单状态改为已关闭。
取消订单后自动退款。
订单完成:
只有运营人员可以取消,订单状态改为已关闭。
取消订单后自动退款。
2)退款流程设计
根据需求分析,当订单为已支付状态时,取消订单后进行自动退款,此时需要调用支付服务的申请退款接口。
流程如下:
暂时无法在飞书文档外展示此内容
取消订单执行如下操作:
1、更新订单状态
待支付状态下取消订单后更新订单状态为“已取消”
派单中状态下取消订单后更新订单状态为“已关闭”
2、保存取消订单记录,记录取消的原因等信息。
3、远程调用支付服务的退款接口申请退款。
取消派单中的订单存在如下问题:
远程调用退款接口操作不放在事务方法中,避免影响数据库性能。
如果远程调用退款接口失败了将无法退款,这个怎么处理?
以上问题采用异步退款的方式来解决:
如下图:
暂时无法在飞书文档外展示此内容
取消订单执行如下操作:
1、使用数据库事务控制,保存以下数据
更新订单状态。
保存取消订单记录表,记录取消订单的原因等信息。
保存退款记录表。
2、定时任务扫描退款记录表,对未退款的记录请求支付服务进行退款,退款成功更新订单的退款状态,并删除退款记录。
说明:
由定时任务去更新退款的状态,因为调用了退款接口只是申请退款了,退款结果可能还没有拿到,通过定时任务再次请求支付服务的退款接口,拿到退款结果。
3) 表结构
订单取消记录表:
create table `jzo2o-orders`.orders_canceled
(
id bigint not null comment '订单id'
primary key,
canceller_id bigint null comment '取消人',
canceler_name varchar(50) null comment '取消人名称',
canceller_type int null comment '取消人类型,1:普通用户,4:运营人员',
cancel_reason varchar(50) null comment '取消原因',
cancel_time datetime null comment '取消时间',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '订单取消表' charset = utf8mb4;
订单退款记录表:存储 了待退款的记录
create table `jzo2o-orders`.orders_refund
(
id bigint not null comment '订单id'
primary key,
trading_order_no bigint null comment '支付服务交易单号',
real_pay_amount decimal(10, 2) null comment '实付金额',
create_time datetime default CURRENT_TIMESTAMP null comment '创建时间'
)
comment '订单退款表' charset = utf8mb4;
4)小结
项目的退款功能怎么实现的?
3.2 取消派单中订单实现
1)分析
前边我们实现了取消待支付的订单方法,下边我们再定义取消派单中订单的方法。
取消派单中的订单需要进行退款操作。
2)定义取消派单中的订单service
当订单状态为派单中,取消此类订单需要进行退款操作,根据退款流程,需要做以下操作:
-
添加取消订单记录。
-
更新订单状态为“已关闭”。
-
更新订单退款状态为退款中。
-
添加退款记录。
定义service方法实现上边3个操作,并且进行事务控制。
代码如下:
public class OrdersManagerServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersManagerService {
@Resource
private IOrdersRefundService ordersRefundService;
//派单中状态取消订单
@Transactional(rollbackFor = Exception.class)
public void cancelByDispatching(OrderCancelDTO orderCancelDTO) {
//保存取消订单记录
OrdersCanceled ordersCanceled = BeanUtil.toBean(orderCancelDTO, OrdersCanceled.class);
ordersCanceled.setCancellerId(orderCancelDTO.getCurrentUserId());
ordersCanceled.setCancelerName(orderCancelDTO.getCurrentUserName());
ordersCanceled.setCancellerType(orderCancelDTO.getCurrentUserType());
ordersCanceled.setCancelTime(LocalDateTime.now());
ordersCanceledService.save(ordersCanceled);
//更新订单状态为关闭订单
OrderUpdateStatusDTO orderUpdateStatusDTO = OrderUpdateStatusDTO.builder().id(orderCancelDTO.getId())
.originStatus(OrderStatusEnum.DISPATCHING.getStatus())
.targetStatus(OrderStatusEnum.CLOSED.getStatus())
.refundStatus(OrderRefundStatusEnum.REFUNDING.getStatus())//退款状态为退款中
.build();
int result = ordersCommonService.updateStatus(orderUpdateStatusDTO);
if (result <= 0) {
throw new DbRuntimeException("取消待服务订单处理失败");
}
//添加退款记录
OrdersRefund ordersRefund = new OrdersRefund();
ordersRefund.setId(orderCancelDTO.getId());
ordersRefund.setTradingOrderNo(orderCancelDTO.getTradingOrderNo());
ordersRefund.setRealPayAmount(orderCancelDTO.getRealPayAmount());
ordersRefundService.save(ordersRefund);
//更新订单表中的退款状态为退款中
boolean update = lambdaUpdate().eq(Orders::getId, orderCancelDTO.getId())
.set(Orders::getRefundStatus, OrderRefundStatusEnum.REFUNDING.getStatus())
.update();
if (!update) {
//抛出异常
log.error("订单退款失败");
throw new CommonException("订单退款失败");
}
}
...
完善取消订单service方法
/**
* 取消订单
*
* @param orderCancelDTO 取消订单模型
*/
@Override
public void cancel(OrderCancelDTO orderCancelDTO) {
//订单id
Long id = orderCancelDTO.getId();
//查询订单
Orders orders = queryById(id);
//如果订单为空则抛出异常
if (ObjectUtils.isNull(orders)) {
throw new CommonException("订单不存在");
}
//订单状态
Integer ordersStatus = orders.getOrdersStatus();
//填充数据
orderCancelDTO.setTradingOrderNo(orders.getTradingOrderNo());
orderCancelDTO.setRealPayAmount(orders.getRealPayAmount());
//订单状态为待支付时
if (OrderStatusEnum.NO_PAY.getStatus().equals(ordersStatus)) {
//取消待支付订单
//如果订单存在优惠金额并且优惠金额大于0
if (ObjectUtils.isNotNull(orders.getDiscountAmount()) && orders.getDiscountAmount().compareTo(new BigDecimal(0)) > 0) {
owner.cancelWithCoupon(orderCancelDTO);
}else{
//取消待支付订单
owner.cancelNoPayOrder(orderCancelDTO);
}
}else if(OrderStatusEnum.DISPATCHING.getStatus().equals(ordersStatus)){//派单中
if (ObjectUtils.isNotNull(orders.getDiscountAmount()) && orders.getDiscountAmount().compareTo(new BigDecimal(0)) > 0) {
owner.cancelWithCoupon(orderCancelDTO);
}else{
//取消派单中的订单
owner.cancelByDispatching(orderCancelDTO);
}
}else{
throw new CommonException("订单状态异常");
}
}
//存在优惠券的情况取消订单,本方法是分布式事务
@GlobalTransactional
public void cancelWithCoupon(OrderCancelDTO orderCancelDTO) {
//查询订单信息
Orders orders = getById(orderCancelDTO.getId());
//退回优惠券
CouponUseBackReqDTO couponUseBackReqDTO = new CouponUseBackReqDTO();
couponUseBackReqDTO.setUserId(orderCancelDTO.getCurrentUserId());
couponUseBackReqDTO.setOrdersId(orderCancelDTO.getId());
couponApi.useBack(couponUseBackReqDTO);
//如果订单状态为待支付
if (OrderStatusEnum.NO_PAY.getStatus()==orders.getOrdersStatus()) {
//取消待支付订单
owner.cancelNoPayOrder(orderCancelDTO);
}else if(OrderStatusEnum.DISPATCHING.getStatus()==orders.getOrdersStatus()){
owner.cancelByDispatching(orderCancelDTO);
}else{
throw new CommonException("订单状态异常");
}
}
3)测试
测试流程:
1.创建订单
2.支付成功
3.取消派单中的订单
预期结果:
保存取消订单记录
修改订单状态为关闭
添加退款记录
3.3 定时任务请求退款接口
1)代码实现
取消派单中的订单进行自动退款,这里通过定时任务请求退款接口:
在OrdersHandler 类中定义定时任务方法。
定时任务根据退款记录去请求第三方支付服务的退款接口,根据退款结果进行处理,如果退款成功将更新订单的退款状态、删除退款记录。
package com.jzo2o.orders.manager.handler;
@Slf4j
@Component
public class OrdersHandler {
@Resource
private RefundRecordApi refundRecordApi;
//解决同级方法调用,事务失效问题
@Resource
private OrdersHandler ordersHandler;
@Resource
private IOrdersRefundService ordersRefundService;
@Resource
private OrdersMapper ordersMapper;
/**
* 订单退款异步任务
*/
@XxlJob(value = "handleRefundOrders")
public void handleRefundOrders() {
//查询退款中订单
List<OrdersRefund> ordersRefundList = ordersRefundService.queryRefundOrderListByCount(100);
for (OrdersRefund ordersRefund : ordersRefundList) {
//调用支付服务进行退款
ExecutionResultResDTO executionResultResDTO = null;
try {
executionResultResDTO = refundRecordApi.refundTrading(ordersRefund.getTradingOrderNo(),ordersRefund.getId(), ordersRefund.getRealPayAmount());
} catch (Exception e) {
e.printStackTrace();
}
if(executionResultResDTO!=null){
//退款后处理订单相关信息
ordersHandler.refundOrder(ordersRefund, executionResultResDTO);
}
}
}
/**
* 更新退款状态
* @param ordersRefund
* @param executionResultResDTO
*/
@Transactional(rollbackFor = Exception.class)
public void refundOrder(OrdersRefund ordersRefund, ExecutionResultResDTO executionResultResDTO) {
//根据响应结果更新退款状态
int refundStatus = OrderRefundStatusEnum.REFUNDING.getStatus();//退款中
if (ObjectUtil.equal(RefundStatusEnum.SUCCESS.getCode(), executionResultResDTO.getRefundStatus())) {
//退款成功
refundStatus = OrderRefundStatusEnum.REFUND_SUCCESS.getStatus();
} else if (ObjectUtil.equal(RefundStatusEnum.FAIL.getCode(), executionResultResDTO.getRefundStatus())) {
//退款失败
refundStatus = OrderRefundStatusEnum.REFUND_FAIL.getStatus();
}
//如果是退款中状态,程序结束
if (ObjectUtil.equal(refundStatus, OrderRefundStatusEnum.REFUNDING.getStatus())) {
return;
}
//非退款中状态,更新订单的退款状态
LambdaUpdateWrapper<Orders> updateWrapper = new LambdaUpdateWrapper<Orders>()
.eq(Orders::getId, ordersRefund.getId())
.eq(Orders::getRefundStatus, OrderRefundStatusEnum.REFUNDING.getStatus())
.set(Orders::getRefundStatus, refundStatus)
.set(ObjectUtil.isNotEmpty(executionResultResDTO.getRefundId()), Orders::getRefundId, executionResultResDTO.getRefundId())
.set(ObjectUtil.isNotEmpty(executionResultResDTO.getRefundNo()), Orders::getRefundNo, executionResultResDTO.getRefundNo());
int update = ordersMapper.update(null,updateWrapper);
//非退款中状态,删除申请退款记录,删除后定时任务不再扫描
if(update>0){
//非退款中状态,删除申请退款记录,删除后定时任务不再扫描
ordersRefundService.removeById(ordersRefund.getId());
}
}
...
2) 测试
配置xxl-job
测试流程:
新创建一个订单,并支付成功。
进入订单列表,点击订单信息进入订单详情页面,点击“取消订单”
点击取消订单预期结果:
订单取消记录表orders_canceled是否成功插入一条记录。
退款记录表orders_refund中是否成功插入一条记录
更新订单状态为已关闭(已关闭),退款状态为退款中。
定时任务执行后预期结果:
请求支付服务的退款接口成功,微信退款成功。
退款成功后删除orders_refund中记录。
更新订单表中的退款状态(退款成功或退款失败)、支付服务的退款单号、微信的退款单号。
3.4 取消订单优化
3.4.1 策略模式入门
由于订单的状态非常多,不同订单状态下取消订单的逻辑也不相同,稍后我们使用策略模式实现取消订单的逻辑。
1)技术分析
- 根据上边的需求,下边分析订单取消功能。
订单的状态不同则取消订单执行的逻辑可能不同
取消待支付的订单:
最终的结果是更改订单状态为已取消,因为没有支付所以不涉及退款。
消派单中的订单:
用户和运营人员都可以取消订单,订单状态改为已关闭。
到达服务时间还没有派单成功系统自动取消。
取消订单后自动退款。
删除抢单池记录。
操作用户不同其权限也不同
普通用户:可取消待支付、派单中、待服务的订单。
运营人员:可取消除待支付状态下的所有订单。
根据需求下边这样写是否合适?
根据用户类型进行判断,并且根据订单状态进行判断。
根据上边的需求可写下边的代码,思考有什么问题?
@Override
public void cancel(OrderCancelDTO orderCancelDTO) {
//查询订单信息
Orders orders = getById(orderCancelDTO.getId());
if (ObjectUtil.isNull(orders)) {
throw new DbRuntimeException("找不到要取消的订单,订单号:{}",orderCancelDTO.getId());
}
//将订单中的交易单号信息拷贝到orderCancelDTO
orderCancelDTO.setTradingOrderNo(orders.getTradingOrderNo());
orderCancelDTO.setRealPayAmount(orders.getRealPayAmount());
//订单状态
Integer ordersStatus = orders.getOrdersStatus();
//获取当前用户
CurrentUserInfo currentUserInfo = UserContext.currentUser();
//用户类型
Integer userType = currentUserInfo.getUserType();
if(UserType.C_USER==userType){
//普通用户取消订单
if(OrderStatusEnum.NO_PAY.getStatus()==ordersStatus){ //订单状态为待支付
owner.cancelByNoPay(orderCancelDTO);
}else if(OrderStatusEnum.DISPATCHING.getStatus()==ordersStatus){ //订单状态为派单中
owner.cancelByDispatching(orderCancelDTO);
//新启动一个线程请求退款
ordersHandler.requestRefundNewThread(orders.getId());
}else if(OrderStatusEnum.NO_SERVE.getStatus()==ordersStatus){ //订单状态为待服务
//...
}else{
throw new CommonException("当前订单状态不支持取消");
}
}else if(UserType.OPERATION==userType){
if(OrderStatusEnum.DISPATCHING.getStatus()==ordersStatus) { //订单状态为派单中
//...
}else if(OrderStatusEnum.NO_SERVE.getStatus()==ordersStatus){//订单状态为待服务
}
//else if(){}
//....
}
}
虽然这样写代码也能实现需求但是扩展性很差,任意状态下取消订单的需求有变更都需要更改此方法,或者用户取消订单的权限有变更也需要更改此方法。
2)策略模式入门
针对上边这种因为不同的场景执行取消订单的逻辑不同可以使用策略模式实现。
策略模式作为一种软件设计模式,指对象有某个行为,但是在不同的场景中,该行为有不同的实现算法。
策略模式以下部分组成:
抽象策略角色: 策略类,通常由一个接口或者抽象类实现。
具体策略角色:包装了相关的算法和行为。
环境角色:持有一个策略类的引用,最终给客户端调用。
下边是一个例子:
暂时无法在飞书文档外展示此内容
策略接口
package com.jzo2o.orders.manager.strategy;
// 定义策略接口
public interface PaymentStrategy {
void pay(BigDecimal amount);
}
具体策略类
package com.jzo2o.orders.manager.strategy;
/**
* @author Mr.M
* @version 1.0
* @description 信用卡支付策略类
* @date 2023/11/18 18:58
*/
public class CreditCardPayment implements PaymentStrategy {
private String cardNumber;
public CreditCardPayment(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public void pay(BigDecimal amount) {
System.out.println("信用卡:"+cardNumber+"支付金额:"+amount);
}
}
/**
* @author Mr.M
* @version 1.0
* @description 微信支付策略类
* @date 2023/11/18 18:58
*/
public class WeixinPayment implements PaymentStrategy {
private String account;
public WeixinPayment(String account) {
this.account = account;
}
@Override
public 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);
}
}
客户端代码
package com.jzo2o.orders.manager.strategy;
/**
* @author Mr.M
* @version 1.0
* @description 使用策略模式的例子
* @date 2023/11/18 19:03
*/
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));
}
}
3.4.2 编写取消订单策略类
1)类结构
学习了策略模式我们可以将取消订单定义为策略接口,针对不同场景下取消订单的逻辑定义为一个一个的策略类,如果哪个场景下的策略有变化只需要修改该策略类即可,如果增加场景也只需要增加策略类。
如下图:
暂时无法在飞书文档外展示此内容
2) 定义取消订单策略接口
取消策略接口需要指定用户类型、订单状态及取消信息,根据用户类型和订单状态决定取消订单的逻辑。
package com.jzo2o.orders.manager.strategy;
import com.jzo2o.orders.manager.model.dto.OrderCancelDTO;
/**
* 订单取消策略类
*
* @author itcast
* @create 2023/8/7 17:05
**/
public interface OrderCancelStrategy {
/**
* 订单取消
*
* @param orderCancelDTO 订单取消模型
*/
void cancel(OrderCancelDTO orderCancelDTO);
}
3) 编写策略类
每个策略类定义的bean名称为“用户类型:订单状态”。
举例:
下边定义策略类是普通用户针对待支付的订单进行取消。
@Component("1:NO_PAY"):定义bean的名称为1:NO_PAY,1表示普通用户,NO_PAY表示待支付订单。
取消待支付的订单执行的动作有:更改订单的状态为已取消,添加取消订单记录。
package com.jzo2o.orders.manager.strategy.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.db.DbRuntimeException;
import com.jzo2o.common.expcetions.ForbiddenOperationException;
import com.jzo2o.orders.base.config.OrderStateMachine;
import com.jzo2o.orders.base.enums.OrderRefundStatusEnum;
import com.jzo2o.orders.base.enums.OrderStatusChangeEventEnum;
import com.jzo2o.orders.base.enums.OrderStatusEnum;
import com.jzo2o.orders.base.model.domain.OrdersCanceled;
import com.jzo2o.orders.base.model.dto.OrderSnapshotDTO;
import com.jzo2o.orders.base.model.dto.OrderUpdateStatusDTO;
import com.jzo2o.orders.base.service.IOrdersCommonService;
import com.jzo2o.orders.manager.model.dto.OrderCancelDTO;
import com.jzo2o.orders.manager.service.IOrdersCanceledService;
import com.jzo2o.orders.manager.strategy.OrderCancelStrategy;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* 普通用户对待支付状态订单取消
*
* @author itcast
* @create 2023/8/7 17:10
**/
@Component("1:NO_PAY")
public class CommonUserNoPayOrderCancelStrategy implements OrderCancelStrategy {
@Resource
private IOrdersCanceledService ordersCanceledService;
@Resource
private IOrdersCommonService ordersCommonService;
/**
* 订单取消
*
* @param orderCancelDTO 订单取消模型
*/
@Override
public void cancel(OrderCancelDTO orderCancelDTO) {
//保存取消订单记录
OrdersCanceled ordersCanceled = BeanUtil.toBean(orderCancelDTO, OrdersCanceled.class);
ordersCanceled.setCancellerId(orderCancelDTO.getCurrentUserId());
ordersCanceled.setCancelerName(orderCancelDTO.getCurrentUserName());
ordersCanceled.setCancellerType(orderCancelDTO.getCurrentUserType());
ordersCanceled.setCancelTime(LocalDateTime.now());
ordersCanceledService.save(ordersCanceled);
//更新订单状态为取消订单
OrderUpdateStatusDTO orderUpdateStatusDTO = OrderUpdateStatusDTO.builder()
.id(orderCancelDTO.getId())
.originStatus(OrderStatusEnum.NO_PAY.getStatus())
.targetStatus(OrderStatusEnum.CANCELED.getStatus())
.build();
int result = ordersCommonService.updateStatus(orderUpdateStatusDTO);
if (result <= 0) {
throw new DbRuntimeException("订单取消事件处理失败");
}
}
}
取消派单中订单策略类:
package com.jzo2o.orders.manager.strategy.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.db.DbRuntimeException;
import com.jzo2o.api.trade.enums.RefundStatusEnum;
import com.jzo2o.common.expcetions.ForbiddenOperationException;
import com.jzo2o.orders.base.config.OrderStateMachine;
import com.jzo2o.orders.base.enums.OrderRefundStatusEnum;
import com.jzo2o.orders.base.enums.OrderStatusChangeEventEnum;
import com.jzo2o.orders.base.enums.OrderStatusEnum;
import com.jzo2o.orders.base.model.domain.OrdersCanceled;
import com.jzo2o.orders.base.model.domain.OrdersRefund;
import com.jzo2o.orders.base.model.dto.OrderSnapshotDTO;
import com.jzo2o.orders.base.model.dto.OrderUpdateStatusDTO;
import com.jzo2o.orders.base.service.IOrdersCommonService;
import com.jzo2o.orders.manager.model.dto.OrderCancelDTO;
import com.jzo2o.orders.manager.service.IOrdersCanceledService;
import com.jzo2o.orders.manager.service.IOrdersRefundService;
//import com.jzo2o.orders.manager.service.ISeizeDispatchService;
import com.jzo2o.orders.manager.strategy.OrderCancelStrategy;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* 普通用户派单状态取消订单
*
* @author itcast
* @create 2023/8/7 17:10
**/
@Component("1:DISPATCHING")
public class CommonUserDispatchingOrderCancelStrategy implements OrderCancelStrategy {
@Resource
private IOrdersCanceledService ordersCanceledService;
@Resource
private IOrdersRefundService ordersRefundService;
@Resource
private IOrdersCommonService ordersCommonService;
/**
* 订单取消
*
* @param orderCancelDTO 订单取消模型
*/
@Override
public void cancel(OrderCancelDTO orderCancelDTO) {
//保存取消订单记录
OrdersCanceled ordersCanceled = BeanUtil.toBean(orderCancelDTO, OrdersCanceled.class);
ordersCanceled.setCancellerId(orderCancelDTO.getCurrentUserId());
ordersCanceled.setCancelerName(orderCancelDTO.getCurrentUserName());
ordersCanceled.setCancellerType(orderCancelDTO.getCurrentUserType());
ordersCanceled.setCancelTime(LocalDateTime.now());
ordersCanceledService.save(ordersCanceled);
//更新订单状态为关闭订单
OrderUpdateStatusDTO orderUpdateStatusDTO = OrderUpdateStatusDTO.builder().id(orderCancelDTO.getId())
.originStatus(OrderStatusEnum.DISPATCHING.getStatus())
.targetStatus(OrderStatusEnum.CLOSED.getStatus())
.refundStatus(OrderRefundStatusEnum.REFUNDING.getStatus())//退款状态为退款中
.build();
int result = ordersCommonService.updateStatus(orderUpdateStatusDTO);
if (result <= 0) {
throw new DbRuntimeException("待服务订单关闭事件处理失败");
}
//添加退款记录
OrdersRefund ordersRefund = new OrdersRefund();
ordersRefund.setId(orderCancelDTO.getId());
ordersRefund.setTradingOrderNo(orderCancelDTO.getTradingOrderNo());
ordersRefund.setRealPayAmount(orderCancelDTO.getRealPayAmount());
ordersRefundService.save(ordersRefund);
}
}
4)编写策略的环境类
在环境类中管理了策略接口
package com.jzo2o.orders.manager.strategy;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.jzo2o.common.expcetions.ForbiddenOperationException;
import com.jzo2o.orders.base.enums.OrderStatusEnum;
import com.jzo2o.orders.base.model.domain.Orders;
import com.jzo2o.orders.manager.service.IOrdersManagerService;
import com.jzo2o.orders.manager.model.dto.OrderCancelDTO;
import com.jzo2o.orders.manager.strategy.OrderCancelStrategy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
/**
* @author itcast
*/
@Slf4j
@Component
public class OrderCancelStrategyManager{
@Resource
private IOrdersManagerService ordersManagerService;
//key格式:userType+":"+orderStatusEnum,例:1:NO_PAY
private final Map<String, OrderCancelStrategy> strategyMap = new HashMap<>();
@PostConstruct
public 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.setId(orders.getId());
orderCancelDTO.setCurrentUserId(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) 方法即执行取消订单操作。
5)了解其它策略类
在课程资料中的源码目录中“取消订单策略类”里放在所有取消订单的策略。
在讲完系统优化将其它策略类拷贝至com.jzo2o.orders.manager.strategy.impl下。
3.4.3 取消订单使用策略模式
1) 修改取消订单方法
由于取消优惠券核销需要使用分布式事务控制,分布式事务控制影响性能,这里将本地事务提交与分布式事务提交分开编写代码。
@Slf4j
@Service
public class OrdersManagerServiceImpl extends ServiceImpl<OrdersMapper, Orders> implements IOrdersManagerService {
...
/**
* 取消订单
*
* @param orderCancelDTO 取消订单模型
*/
@Override
public void cancel(OrderCancelDTO orderCancelDTO) {
//订单id
Long id = orderCancelDTO.getId();
//查询订单
Orders orders = queryById(id);
//如果订单为空则抛出异常
if (ObjectUtils.isNull(orders)) {
throw new CommonException("订单不存在");
}
//填充数据
orderCancelDTO.setTradingOrderNo(orders.getTradingOrderNo());
orderCancelDTO.setRealPayAmount(orders.getRealPayAmount());
if (ObjectUtils.isNotNull(orders.getDiscountAmount()) && orders.getDiscountAmount().compareTo(new BigDecimal(0)) > 0) {
owner.cancelWithCoupon(orderCancelDTO);
}else{
//取消待支付订单
owner.cancelWithoutCoupon(orderCancelDTO);
}
}
@GlobalTransactional
public void cancelWithCoupon(OrderCancelDTO orderCancelDTO) {
//退回优惠券
CouponUseBackReqDTO couponUseBackReqDTO = new CouponUseBackReqDTO();
couponUseBackReqDTO.setUserId(orderCancelDTO.getCurrentUserId());
couponUseBackReqDTO.setOrdersId(orderCancelDTO.getId());
couponApi.useBack(couponUseBackReqDTO);
//使用策略模式取消订单
orderCancelStrategyManager.cancel(orderCancelDTO);
}
@Transactional
public void cancelWithoutCoupon(OrderCancelDTO orderCancelDTO) {
orderCancelStrategyManager.cancel(orderCancelDTO);
}
...
2)测试
启动网关
启动jzo2o-orders-manager
启动jzo2o-foundations
启动jzo2o-customer
启动小程序
测试流程:
下单
取消订单
断点跟踪:
在策略环境对象中包括了所有策略实现对象。
继续执行,根据订单状态和用户类型找到符合的策略对象:
3 ) 小结
项目中用到设计模式了吗,具体说说?
1 查阅微信的产品文档
如何使用小程序进行支付?
下边通过调研小程序支付接口梳理交互流程。
打开微信小程序支付的产品文档:https://pay.weixin.qq.com/docs/merchant/products/mini-program-payment/introduction.html
2 前期准备
2.1 注册商户
首先参照接入前的准备进行申请:
进行小程序运营的企业需要申请成为微信的普通商户,为企业提供支付服务的需要注册成为普通服务商户。
本项目作为公司的自研项目并进行运营需要申请成为普通商户,地址:https://pay.weixin.qq.com/index.php/core/home/login?return_url=%2F
步骤:
1、申请成为商户
点击“成为商户”填写法人、公司等信息等待审核。
2、申请AppID
对于普通商户,该社交载体可以是公众号(什么是公众号 (opens new window)),小程序(什么是小程序 (opens new window))或App。
本项目需要申请一个小程序账号,拿到AppID。
3、申请mchid(商户号)
进入商户平台申请:https://pay.weixin.qq.com/index.php/core/info
4、绑定AppID及mchid
AppID和mchid全部申请完毕后,需要建立两者之间的绑定关系。
2.2 开通微信支付
-
申请小程序开发者账号,进行微信认证,获取AppID登录《微信公众平台》 (opens new window),注册一个小程序的开发者账号。小程序账号申请指引(opens new window)
-
小程序开通微信支付,即申请或复用微信支付商户号,申请完小程序后,登录小程序后台 (opens new window)。点击左侧导航栏的微信支付,在页面中进行开通。
点击开通按钮后,有2种方式可以获取微信支付能力,新申请微信支付商户号或绑定一个已有的微信支付商户号,请根据你的业务需要和具体情况选择,只能二选一。
3 小程序支付接口
3.1 总体流程
前期准备工作完毕下边调研小程序支付接口。
接口地址:https://pay.weixin.qq.com/docs/merchant/products/mini-program-payment/apilist.html
API列表
td {white-space:nowrap;border:0.5pt solid #dee0e3;font-size:10pt;font-style:normal;font-weight:normal;vertical-align:middle;word-break:normal;word-wrap:normal;}| 功能列表 | 描述 |
| 小程序下单 | 通过本接口提交微信支付小程序支付订单。 |
| 小程序调起支付 | 通过小程序下单接口获取到发起支付的必要参数prepay_id,可以按照接口定义中的规则,调起小程序支付。 |
| 支付通知 | 微信支付通过支付通知接口将用户支付成功消息通知给商户。 |
| 微信支付订单号查询订单 | 通过此接口查询订单状态。 |
| 商户订单号查询订单 | 通过此接口查询订单状态。 |
| 关闭订单 | 通过此接口关闭待支付订单。 |
| 退款申请 | 商户可以通过该接口将支付金额退还给买家。 |
| 查询单笔退款(通过商户退款单号) | 提交退款申请后,通过调用该接口查询退款状态。 |
| 退款结果通知 | 微信支付通过退款通知接口将用户退款成功消息通知给商户。 |
| 申请资金账单 | 商户可以通过该接口获取资金账单文件的下载地址。 |
| 申请交易账单 | 商户可以通过该接口获取交易账单文件的下载地址。 |
| 下载交易/资金账单 | 通过申请交易/资金账单获取到download_url在该接口获取到对应的账单。 |
上边表格加粗的表示必须对接的接口。
下边的流程是业务系统(凡是和微信支付对接的系统统称为业务系统)与微信支付接口的交互流程,列出了支付、支付结果查询、退款三个接口的交互流程。
业务系统请求微信支付下单要将业务系统自己的订单号和订单金额告诉微信支付,只要这样在支付完成后才可以根据业务系统的订单号去查询支付结果。
比如:用户在家政平台下单,然后进行支付,家政平台服务端程序需要调用 微信支付的下单接口,将家政平台的订单号与订单金额传给微信下单接口。
暂时无法在飞书文档外展示此内容
3.2 小程序下单
当点击支付时,业务系统向微信发起下单请求。
请求方式:【POST】/v3/pay/transactions/jsapi
请求域名:【主域名】https://api.mch.weixin.qq.com
请求方向:业务系统--->微信
请求参数
除了appid、mchid商户id等必要参数以外,与业务相关的最重要的就是out_trade_no和amount,它是业务系统中的订单号及订单金额。
业务系统请求微信支付下单要将业务系统自己的订单号和订单金额告诉微信支付,只要这样在支付完成后才可以根据业务系统的订单号去查询支付结果。
比如:用户在家政平台下单,然后进行支付,家政平台服务端程序需要调用 微信支付的下单接口,将家政平台的订单号与订单金额传给微信下单接口。
-
HeaderHTTP头参数
-
Authorization必填string
请参考 签名认证 生成认证信息
- Accept必填string
请设置为 application/json
- Content-Type必填string
请设置为 application/json
-
Body包体参数
-
appid必填string(32)
【公众号ID】 公众号ID/小程序ID
- mchid必填string(32)
【直连商户号】 直连商户号
- description必填string(127)
【商品描述】 商品描述
- out_trade_no必填string(32)
【商户订单号】 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一。
- amount必填CommReqAmountInfo
【订单金额】 订单金额信息
.....
应答参数
200OK
- prepay_id必填string(64)
【预支付交易会话标识】 预支付交易会话标识。用于后续接口调用中使用,该值有效期为2小时
签名认证
在head参数中有一个Authorization,它表示请求接口的签名认证信息,是按照微信要求生成的加密串和一些基本信息。
签名是对原始数据通过签名算法生成的一段数据(签名串),用于证明数据的真实性和完整性。签名通常使用密钥进行生成,这个密钥可以是对称密钥或非对称密钥。
验签是对签名串进行验证的过程,用于确认数据的真实性和完整性。验签的过程通常使用与签名过程中相对应的公钥进行解密。
签名和验签是为了防止内容被篡改。
如何生成请求签名:https://pay.wechatpay.cn/wiki/doc/apiv3/wechatpay/wechatpay4_0.shtml
过程如下:
按照规则生成原始信息
使用签名算法(SHA256 with RSA)、私钥对原始信息生成签名串
如何验证签名:https://pay.weixin.qq.com/docs/merchant/development/interface-rules/signature-verification.html
过程如下:
获取原始信息和签名串
使用签名算法(SHA256 with RSA )、公钥对验证原始信息与签名串是否一致,如果一致则验证通过。
签名和验签是为了防止内容被篡改。
"SHA256 with RSA" 是一种常见的数字签名算法组合,它结合了SHA-256哈希函数与RSA公钥加密算法。这种组合广泛应用于安全通信协议(如TLS/SSL)、文件验证、软件发布等多个领域,以确保数据的完整性和真实性。
工作原理
消息哈希:首先,使用SHA-256算法对原始消息进行哈希处理,生成一个固定长度(256位)的消息摘要。这个过程可以显著减少需要加密的数据量,同时保证任何微小的变化都能产生完全不同的哈希值,从而提高安全性。
签名生成:接着,发送方利用自己的私钥对上述消息摘要进行加密,生成数字签名。这一过程基于RSA算法,确保只有持有对应公钥的一方才能解密并验证签名的有效性。
签名验证:接收方收到消息及其数字签名后,先用相同的SHA-256算法计算接收到的消息的哈希值,然后使用发送方的公钥解密数字签名,恢复出原始的消息摘要。如果两个哈希值相同,则说明消息未被篡改,且确实来自声称的发送者。
在微信提供的SDK程序中此部分由SDK处理了无需程序员手动编码。
地址:https://pay.wechatpay.cn/wiki/doc/apiv3/wechatpay/wechatpay6_0.shtml
3.3 小程序调起支付
小程序下单成功后微信返回一个prepay_id即预支付会话标识,小程序使用微信支付提供的api方法调起小程序支付。
调用wx.requestPayment(OBJECT)发起微信支付
接口名称: wx.requestPayment,详见小程序API文档(opens new window)
Object请求参数说明:
-
timeStamp必填string(32)
-
时间戳,标准北京时间,时区为东八区,自1970年1月1日 0点0分0秒以来的秒数。注意:部分系统取到的值为毫秒级,需要转换成秒(10位数字)。
-
nonceStr必填string(32)
-
随机字符串,不长于32位。
-
package必填string(128)
-
小程序下单接口返回的
prepay_id参数值,提交格式如:prepay_id=*** -
signType必填string(32)
-
签名类型,默认为RSA,仅支持RSA。
-
paySign必填string(512)
-
签名,使用字段
appid、timeStamp、nonceStr、package计算得出的签名值 签名所使用的appid,为【小程序下单】时传入的appid,微信支付会校验下单与调起支付所使用的appid的一致性。
3.4 支付通知
微信支付通过支付通知接口将用户支付成功消息通知给商户的业务系统。
这里要注意的是最终业务系统要根据支付结果更新订单的支付状态,所以带着这个需求去查阅接口。
请求方式: 【POST】
请求方向:微信----》业务系统
回调URL: 即业务系统接收支付结果的URL,在请求微信支付下单时传给微信的,通过下单接口中的请求参数“notify_url”来设置的。
支付结果通知是以POST 方法访问商户设置的通知URL,通知的数据以JSON 格式通过请求主体(BODY)传输。
通过内容如下:
Body包体参数
- id必填string(36)
通知的唯一ID。
- create_time必填string(32)
通知创建的时间,遵循rfc3339标准格式,格式为yyyy-MM-DDTHH:mm:ss+TIMEZONE,yyyy-MM-DD表示年月日,T出现在字符串中,表示time元素的开头,HH:mm:ss.表示时分秒,TIMEZONE表示时区(+08:00表示东八区时间,领先UTC 8小时,即北京时间)。例如:2015-05-20T13:29:35+08:00表示北京时间2015年05月20日13点29分35秒。
- event_type必填string(32)
通知的类型,支付成功通知的类型为TRANSACTION.SUCCESS。
- resource_type必填string(32)
通知的资源数据类型,支付成功通知为encrypt-resource。
- resource必填object
通知资源数据。
- summary必填string(64)
回调摘要
resource解密后字段
Body包体参数
在resource字段中有一个out_trade_no,正是业务系统的订单号,trade_state是支付结果,我们可以根据这两个参数去更新订单的支付结果。
- appid必填string(32)
直连商户申请的公众号或移动应用AppID。
- mchid必填string(32)
商户的商户号,由微信支付生成并下发。
- out_trade_no必填string(32)
商户系统内部订单号,可以是数字、大小写字母_-*的任意组合且在同一个商户号下唯一。
- transaction_id必填string(32)
微信支付系统生成的订单号。
- trade_type必填string(16)
交易类型,枚举值: JSAPI:公众号支付 NATIVE:扫码支付 App:App支付 MICROPAY:付款码支付 MWEB:H5支付 FACEPAY:刷脸支付
- trade_state必填string(32)
交易状态,枚举值: SUCCESS:支付成功 REFUND:转入退款 NOTPAY:未支付 CLOSED:已关闭 REVOKED:已撤销(付款码支付) USERPAYING:用户支付中(付款码支付) PAYERROR:支付失败(其他原因,如银行返回失败)
- trade_state_desc必填string(256)
交易状态描述。
- bank_type必填string(32)
银行类型,采用字符串类型的银行标识。银行标识请参考《银行类型对照表》。
- attach选填string(128)
附加数据,在查询API和支付通知中原样返回,可作为自定义参数使用,实际情况下只有支付完成状态才会返回该字段。
- success_time必填string(64)
支付完成时间,遵循rfc3339标准格式,格式为yyyy-MM-DDTHH:mm:ss+TIMEZONE,yyyy-MM-DD表示年月日,T出现在字符串中,表示time元素的开头,HH:mm:ss表示时分秒,TIMEZONE表示时区(+08:00表示东八区时间,领先UTC 8小时,即北京时间)。例如:2015-05-20T13:29:35+08:00表示,北京时间2015年5月20日 13点29分35秒。
- payer必填object
支付者信息
- amount必填object
订单金额信息
3.5 商户通过订单号查询订单
商户可以通过查询订单接口主动查询订单状态
这里要注意的是最终业务系统要根据支付结果更新订单的支付状态,所以带着这个需求去查阅接口。
支持商户:【普通商户】
请求方式:【GET】/v3/pay/transactions/out-trade-no/{out_trade_no}
请求域名:【主域名】https://api.mch.weixin.qq.com
请求方向:业务系统--->微信
请求参数
HeaderHTTP头参数
- Authorization必填string
请参考 签名认证 生成认证信息
- Accept必填string
请设置为 application/json
Path路径参数
- out_trade_no必填string(32)
【商户订单号】 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一。
Query查询参数
- mchid必填string(32)
【直连商户号】 直连商户的商户号,由微信支付生成并下发。
应答参数
在应答参数中有一个out_trade_no,正是业务系统的订单号,trade_state是支付结果,我们可以根据这两个参数去更新订单的支付结果。
200OK
- appid选填string(32)
【公众号ID】 公众号ID
- mchid必填string(32)
【直连商户号】 直连商户号
- out_trade_no必填string(32)
【商户订单号】 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一,详见【商户订单号】。
- transaction_id选填string(32)
【微信支付订单号】 微信支付系统生成的订单号。
- trade_type选填string(16)
【交易类型】 交易类型,枚举值: * JSAPI:公众号支付 * NATIVE:扫码支付 * APP:APP支付 * MICROPAY:付款码支付 * MWEB:H5支付 * FACEPAY:刷脸支付
- trade_state必填string(32)
【交易状态】 交易状态,枚举值: * SUCCESS:支付成功 * REFUND:转入退款 * NOTPAY:未支付 * CLOSED:已关闭 * REVOKED:已撤销(仅付款码支付会返回) * USERPAYING:用户支付中(仅付款码支付会返回) * PAYERROR:支付失败(仅付款码支付会返回)
- trade_state_desc必填string(256)
【交易状态描述】 交易状态描述
4 Native支付接口
打开Native下单接口:
https://pay.weixin.qq.com/docs/merchant/apis/native-payment/direct-jsons/native-prepay.html
4.1 总体流程
扫码支付的流程与小程序支付基本类似,只是小程序支付改为了用户扫码支付,流程如下:
暂时无法在飞书文档外展示此内容
4.2 下单
支持商户:【普通商户】
请求方式:【POST】/v3/pay/transactions/native
请求域名:【主域名】https://api.mch.weixin.qq.com
请求方向:业务系统--->微信
请求参数
HeaderHTTP头参数
- Authorization必填string
请参考 签名认证 生成认证信息
- Accept必填string
请设置为 application/json
- Content-Type必填string
请设置为 application/json
Body包体参数
- appid必填string(32)
【公众号ID】 公众号ID
- mchid必填string(32)
【直连商户号】 直连商户号
- description必填string(127)
【商品描述】 商品描述
- out_trade_no必填string(32)
【商户订单号】 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一
- amount必填CommReqAmountInfo
【订单金额】 订单金额
应答参数
200OK
- code_url必填string(64)
【二维码链接】 此URL用于生成支付二维码,然后提供给用户扫码支付。
使用时按照URL格式转成二维码即可
4.3 支付通知
同小程序支付通知。
4.4 商户订单号查询订单
除了接口地址不同其它与小程序支付相同。
支持商户:【普通商户】
请求方式:【GET】/v3/pay/transactions/out-trade-no/{out_trade_no}
请求域名:【主域名】https://api.mch.weixin.qq.com
请求方向:业务系统--->微信
请求参数
HeaderHTTP头参数
- Authorization必填string
请参考 签名认证 生成认证信息
- Accept必填string
请设置为 application/json
Path路径参数
- out_trade_no必填string(32)
【商户订单号】 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一。
Query查询参数
- mchid必填string(32)
【直连商户号】 直连商户的商户号,由微信支付生成并下发。
应答参数
200OK
- appid选填string(32)
【公众号ID】 公众号ID
- mchid必填string(32)
【直连商户号】 直连商户号
- out_trade_no必填string(32)
【商户订单号】 商户系统内部订单号,只能是数字、大小写字母_-*且在同一个商户号下唯一,详见【商户订单号】。
- transaction_id选填string(32)
【微信支付订单号】 微信支付系统生成的订单号。
- trade_type选填string(16)
【交易类型】 交易类型,枚举值: * JSAPI:公众号支付 * NATIVE:扫码支付 * APP:APP支付 * MICROPAY:付款码支付 * MWEB:H5支付 * FACEPAY:刷脸支付
- trade_state必填string(32)
【交易状态】 交易状态,枚举值: * SUCCESS:支付成功 * REFUND:转入退款 * NOTPAY:未支付 * CLOSED:已关闭 * REVOKED:已撤销(仅付款码支付会返回) * USERPAYING:用户支付中(仅付款码支付会返回) * PAYERROR:支付失败(仅付款码支付会返回)
- trade_state_desc必填string(256)
【交易状态描述】 交易状态描述
5 SDK调研
介绍
为了方便进行支付接口对接,可通过SDK程序快速进行开发。
SDK介绍地址:https://pay.wechatpay.cn/wiki/doc/apiv3/wechatpay/wechatpay6_0.shtml
开发者可以根据自己的需要,选择对应的库。
-
wechatpay-java(推荐)wechatpay-apache-httpclient,适用于Java开发者。
-
wechatpay-php(推荐)、wechatpay-guzzle-middleware,适用于PHP开发者。
-
wechatpay-go,适用于Go开发者
本项目使用wechatpay-apache-httpclient
使用示例
加入以下依赖
<dependency>
<groupId>com.github.wechatpay-apiv3</groupId>
<artifactId>wechatpay-apache-httpclient</artifactId>
<version>0.4.9</version>
</dependency>
名词解释
-
商户API证书,是用来证实商户身份的。证书中包含商户号、证书序列号、证书有效期等信息,由证书授权机构(Certificate Authority ,简称CA)签发,以防证书被伪造或篡改。如何获取请见商户API证书。
-
商户API私钥。商户申请商户API证书时,会生成商户私钥,并保存在本地证书文件夹的文件apiclient_key.pem中。注:不要把私钥文件暴露在公共场合,如上传到Github,写在客户端代码等。
-
微信支付平台证书。平台证书是指由微信支付负责申请的,包含微信支付平台标识、公钥信息的证书。商户可以使用平台证书中的公钥进行应答签名的验证。获取平台证书需通过获取平台证书列表接口下载。
-
证书序列号。每个证书都有一个由CA颁发的唯一编号,即证书序列号。如何查看证书序列号请看这里。
-
API v3密钥。为了保证安全性,微信支付在回调通知和平台证书下载接口中,对关键信息进行了AES-256-GCM加密。API v3密钥是加密时使用的对称密钥。商户可以在【商户平台】->【API安全】的页面设置该密钥。
开始
如果你使用的是HttpClientBuilder或者HttpClients#custom()来构造HttpClient,你可以直接替换为WechatPayHttpClientBuilder。
import com.wechat.pay.contrib.apache.httpclient.WechatPayHttpClientBuilder;
//...
WechatPayHttpClientBuilder builder = WechatPayHttpClientBuilder.create()
.withMerchant(merchantId, merchantSerialNumber, merchantPrivateKey)
.withWechatPay(wechatPayCertificates);
// ... 接下来,你仍然可以通过builder设置各种参数,来配置你的HttpClient
// 通过WechatPayHttpClientBuilder构造的HttpClient,会自动的处理签名和验签
CloseableHttpClient httpClient = builder.build();
// 后面跟使用Apache HttpClient一样
CloseableHttpResponse response = httpClient.execute(...);
参数说明(前三个必须):
-
merchantId商户号。 -
merchantSerialNumber商户API证书的证书序列号。 -
merchantPrivateKey商户API私钥,如何加载商户API私钥请看常见问题。 -
wechatPayCertificates微信支付平台证书列表。你也可以使用后面章节提到的“定时更新平台证书功能”,而不需要关心平台证书的来龙去脉。
示例:获取平台证书
你可以使用WechatPayHttpClientBuilder构造的HttpClient发送请求和应答了。
URIBuilder uriBuilder = new URIBuilder("https://api.mch.weixin.qq.com/v3/certificates");
HttpGet httpGet = new HttpGet(uriBuilder.build());
httpGet.addHeader("Accept", "application/json");
CloseableHttpResponse response = httpClient.execute(httpGet);
String bodyAsString = EntityUtils.toString(response.getEntity());
System.out.println(bodyAsString);
示例:JSAPI下单
注:
-
我们使用了 jackson-databind 演示拼装 Json,你也可以使用自己熟悉的 Json 库
-
请使用你自己的测试商户号、appid 以及对应的 openid
HttpPost httpPost = new HttpPost("https://api.mch.weixin.qq.com/v3/pay/transactions/jsapi");
httpPost.addHeader("Accept", "application/json");
httpPost.addHeader("Content-type","application/json; charset=utf-8");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode rootNode = objectMapper.createObjectNode();
rootNode.put("mchid","1900009191")
.put("appid", "wxd678efh567hg6787")
.put("description", "Image形象店-深圳腾大-QQ公仔")
.put("notify_url", "https://www.weixin.qq.com/wxpay/pay.php")
.put("out_trade_no", "1217752501201407033233368018");
rootNode.putObject("amount")
.put("total", 1);
rootNode.putObject("payer")
.put("openid", "oUpF8uMuAJO_M2pxb1Q9zNjWeS6o");
objectMapper.writeValue(bos, rootNode);
httpPost.setEntity(new StringEntity(bos.toString("UTF-8"), "UTF-8"));
CloseableHttpResponse response = httpClient.execute(httpPost);
String bodyAsString = EntityUtils.toString(response.getEntity());
System.out.println(bodyAsString);
示例:查单
URIBuilder uriBuilder = new URIBuilder("https://api.mch.weixin.qq.com/v3/pay/transactions/id/4200000889202103303311396384?mchid=1230000109");
HttpGet httpGet = new HttpGet(uriBuilder.build());
httpGet.addHeader("Accept", "application/json");
CloseableHttpResponse response = httpClient.execute(httpGet);
String bodyAsString = EntityUtils.toString(response.getEntity());
System.out.println(bodyAsString);
示例:关单
HttpPost httpPost = new HttpPost("https://api.mch.weixin.qq.com/v3/pay/transactions/out-trade-no/1217752501201407033233368018/close");
httpPost.addHeader("Accept", "application/json");
httpPost.addHeader("Content-type","application/json; charset=utf-8");
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode rootNode = objectMapper.createObjectNode();
rootNode.put("mchid","1900009191");
objectMapper.writeValue(bos, rootNode);
httpPost.setEntity(new StringEntity(bos.toString("UTF-8"), "UTF-8"));
CloseableHttpResponse response = httpClient.execute(httpPost);
String bodyAsString = EntityUtils.toString(response.getEntity());
System.out.println(bodyAsString);
学习目标:
能说出什么是状态机
能说出如何实现一个订单状态机
能够基于状态机组件实现订单状态机
能够说出项目为什么进行分库分表
能够说出分库分表的四种形式
能够说出分库分表常用的技术方案
能够开发ShardingSphere-JDBC入门程序
能够说出家政项目分库分表的技术方案
能够完成对订单数据库进行分库分表
能够说出订单查询的优化方案
能够优化用户端订单列表
能够对运营端订单列表进行优化
能够说出订单快照的实现方案
能够使用订单快照优化订单详情接口
能够说出项目的冷热分离方案
能够对订单数据进行冷热分离
能够对订单订单按年分表
实战:
-
取消订单使用状态机
-
运营端订单列表优化
-
历史订单实现按年分表。
1 状态机
1.1 什么是状态机
目标:能说出什么是状态机
1)当前存在的问题
在预约下单模块设计订单状态共有7种,如下图:
暂时无法在飞书文档外展示此内容
目前我们使用了待支付、派单中两种状态,在代码中我们发现存在对订单状态进行硬编码的情况:
public void paySuccess(TradeStatusMsg tradeStatusMsg) {
...
//订单状态为待支付时当支付成功更新为派单中
if (ObjectUtil.equal(0, orders.getOrdersStatus())) {
...
}
}
随着开发的深入这种代码会越来越多,比如在实现对订单进行关闭时代码会写成如下的形式:
if(订单状态==已完成){
//运营人员在订单完成时取消订单
//执行此场景下的业务逻辑
//更新订单状态为派单中
update(id,已关闭)
)
if(订单状态==服务中){
//运营人员在服务中时取消订单
//执行此场景下的业务逻辑
//更新订单状态为已关闭
update(id,已关闭)
)
...
以上代码存在问题如下:
在业务代码中对订单状态进行硬编码如果有一天更改了业务逻辑就需要更改代码,不方便进行系统扩展和维护。
另外对订单状态的管理是散落在很多地方不方便对订单状态进行统一管理和维护。
2) 使用状态机解决问题
针对以上问题如何解决呢?
我们可以使用状态机对订单状态进行统一管理。
什么是状态机?
上图在UML中叫状态图(又叫状态机图),UML是软件开发中的一种建模语言,用来辅助进行软件设计,常用的如:类图、对象、状态图、序列图等,注意状态机图并不是状态机,状态机是一种数学模型,应用在自动化控制、计算机科学、通信等很多领域,简单理解状态机就是对状态进行统一管理的数学模型。
我们画的状态图是状态机在计算机科学中的应用方法,还有状态机设计模式也是状态机在软件领域的应用方法。
状态机设计模式是状态机在软件中的应用,状态机设计模式描述了一个对象在内部状态发生变化时如何改变其行为,将状态之间的变更定义为事件,将事件暴露出去,通过执行状态变更事件去更改状态,这是状态机设计模式的核心内容。
理解状态机设计模式需要理解四个要素:现态、事件、动作、次态。
(参考地址:https://baike.baidu.com/item/状态机/6548513?fr=ge_ala )
1、现态:是指当前所处的状态。
2、次态:条件满足后要迁往的新状态。
3、事件:当一个条件被满足,状态会由现态变为新的状态,事件发生会触发一个动作,或者执行一次状态的迁移。
4、动作:发生事件执行的动作,动作执行完毕后,可以迁移到新的状态,也可以仍旧保持原状态。动作不是必需的,当条件满足后,也可以不执行任何动作,直接迁移到新状态。
我们拿待支付状态到派单中状态举例:
现态:订单当前处于待支付状态那么现态为待支付。
次态:派单中。
事件:用户支付成功为事件,支付成功是条件,当条件满足进行状态迁移。
动作:将订单状态由待支付更改为派单中。
使用状态机优化代码:
使用状态机之后对代码进行以下优化。
支付成功更改订单状态的代码优化如下:
if(支付状态==支付成功){
//调用状态机执行支付成功事件
orderStateMachine.changeStatus(id,支付成功事件);
}
订单取消的代码优化如下:
orderStateMachine.changeStatus(id,订单完成时取消订单事件);
我们发现使用状态机的代码并没有对订单状态进行硬编码,只是指定了订单id和事件名称,执行changeStatus方法后自动更改订单的状态。
3)小结
什么是状态机,它解决了什么问题?
1.2 实现订单状态机
1.2.1 编写订单状态机
目标:
能够基于状态机组件实现订单状态机
1) 添加状态机组件依赖
本项目基于状态机设计模式开发了状态机组件,代码在jzo2o-framework中,如果在订单管理服务中实现订单状态机需要添加状态机的依赖。
在jzo2o-orders-base工程的pom.xml中添加状态机组件的依赖
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-statemachine</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
添加完成刷新pom.xml
下边使用状态机组件管理订单状态。
2)订单状态枚举类
阅读订单状态枚举类,它实现了StatusDefine 状态接口,不论是现态还是次态都需要实现状态接口。
定义每个枚举需要注意见名知意,比如:NO_PAY(0, "待支付", "NO_PAY")表示待支付状态。
订单状态枚举类如下:
package com.jzo2o.orders.base.enums;
import com.jzo2o.statemachine.core.StatusDefine;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author itcast
*/
@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;
}
}
3)状态变更事件枚举类
所有状态之间存在的变更都需要定义状态变更事件,它实现了StatusChangeEvent 状态变更事件接口,事件对应状态机四要素的事件
代码如下,重点看PAYED:
PAYED(OrderStatusEnum.NO_PAY, OrderStatusEnum.DISPATCHING, "支付成功", "payed")表示由NO_PAY(未支付)状态变化为DISPATCHING(派单中)状态,事件名称为“支付成功”(payed)。
package com.jzo2o.orders.base.enums;
import com.jzo2o.statemachine.core.StatusChangeEvent;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author itcast
*/
@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.FINISHED, "完成服务", "complete_serve"),
// EVALUATE(OrderStatusEnum.NO_EVALUATION, OrderStatusEnum.FINISHED, "评价完成", "evaluate"),
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_NO_EVALUATION_ORDER(OrderStatusEnum.NO_EVALUATION, OrderStatusEnum.CLOSED, "待评价订单关闭", "close_no_evaluation_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;
}
4)定义订单快照类
快照是订单变化瞬间的状态及相关信息。
比如:001号订单创建成功此时记录它的快照信息(订单号、下单人、订单详细信息、订单状态等),当001号订单支付成功由待支付状态变化为派单中状态此时也会记录它的快照信息(订单号、下单人、支付状态、支付相关信息,订单状态等相关信息),由此可以看出订单快照可以追溯订单的历史变化信息,只要状态发生变化便会记录快照。
快照基础类型是StateMachineSnapshot,如果我们要实现订单快照则需要定义一个订单快照类OrderSnapshotDTO 去继承StateMachineSnapshot类型,代码如下:
package com.jzo2o.orders.base.model.dto;
import com.jzo2o.statemachine.core.StateMachineSnapshot;
import io.swagger.annotations.ApiModelProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 订单快照
*
* @author itcast
* @create 2023/8/19 10:30
**/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderSnapshotDTO extends StateMachineSnapshot {
/**
* 订单id
*/
private Long id;
/**
* 订单所属人
*/
private Long userId;
/**
* 服务类型id
*/
private Long serveTypeId;
/**
* 服务类型名称
*/
private String serveTypeName;
/**
* 服务项id
*/
private Long serveItemId;
/**
* 服务项名称
*/
private String serveItemName;
/**
* 服务项图片
*/
private String serveItemImg;
/**
* 服务单位
*/
private Integer unit;
/**
* 服务id
*/
private Long serveId;
/**
* 订单状态,0:待支付,100:派单中,200:待服务,300:服务中,500:订单完成,600:订单取消,700已关闭
*/
private Integer ordersStatus;
/**
* 支付状态,2:待支付,4:支付成功
*/
private Integer payStatus;
/**
* 退款,0:发起退款,1:退款中,2:退款成功 3:退款失败
*/
private Integer refundStatus;
/**
* 单价
*/
private BigDecimal price;
/**
* 购买数量
*/
private Integer purNum;
/**
* 订单总金额
*/
private BigDecimal totalAmount;
/**
* 实际支付金额
*/
private BigDecimal realPayAmount;
/**
* 优惠金额
*/
private BigDecimal discountAmount;
/**
* 城市编码
*/
private String cityCode;
/**
* 服务详细地址
*/
private String serveAddress;
/**
* 联系人手机号
*/
private String contactsPhone;
/**
* 联系人姓名
*/
private String contactsName;
/**
* 服务开始时间
*/
private LocalDateTime serveStartTime;
/**
* 经度
*/
private String lon;
/**
* 纬度
*/
private String lat;
/**
* 支付时间
*/
private LocalDateTime payTime;
/**
* 评价时间
*/
private LocalDateTime evaluationTime;
/**
* 订单创建时间
*/
private LocalDateTime createTime;
/**
* 订单更新时间
*/
private LocalDateTime updateTime;
/**
* 支付服务交易单号
*/
private Long tradingOrderNo;
/**
* 支付服务退款单号
*/
private Long refundNo;
/**
* 支付渠道【支付宝、微信、现金、免单挂账】
*/
private String tradingChannel;
/**
* 三方流水,微信支付订单号或支付宝订单号
*/
private String thirdOrderId;
/**
* 退款三方流水,微信支付订单号或支付宝订单号
*/
private String thirdRefundOrderId;
/**
* 取消人id
*/
private Long cancellerId;
/**
* 取消人名称
*/
private String cancelerName;
/**
* 取消人类型
*/
private Integer cancellerType;
/**
* 取消时间
*/
private LocalDateTime cancelTime;
/**
* 取消原因
*/
private String cancelReason;
/**
* 实际服务完成时间
*/
private LocalDateTime realServeEndTime;
/**
* 评价状态
*/
private Integer evaluationStatus;
/**
* 超时时间
*/
private LocalDateTime overTime;
@Override
public String getSnapshotId() {
return String.valueOf(id);
}
@Override
public Integer getSnapshotStatus() {
return ordersStatus;
}
@Override
public void setSnapshotId(String snapshotId) {
this.id = Long.parseLong(snapshotId);
}
@Override
public void setSnapshotStatus(Integer snapshotStatus) {
this.ordersStatus = snapshotStatus;
}
}
5)定义事件变更动作类
当执行状态变更事件会伴随着执行具体的动作,此部分对应状态机四要素中的动作。
定义订单支付成功动作类,实现StatusChangeHandler接口,泛型中指定快照类型。
此动作是订单支付成功执行的动作。
动作类的bean名称为"状态机名称_事件名称",例如下边的动作类bean的名称为order_payed,表示order状态机的payed事件。
package com.jzo2o.orders.base.handler;
import cn.hutool.db.DbRuntimeException;
import com.jzo2o.orders.base.enums.OrderPayStatusEnum;
import com.jzo2o.orders.base.enums.OrderStatusEnum;
import com.jzo2o.orders.base.model.dto.OrderSnapshotDTO;
import com.jzo2o.orders.base.model.dto.OrderUpdateStatusDTO;
import com.jzo2o.orders.base.service.IOrdersCommonService;
import com.jzo2o.statemachine.core.StateMachineSnapshot;
import com.jzo2o.statemachine.core.StatusChangeEvent;
import com.jzo2o.statemachine.core.StatusChangeHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* 订单支付成功处理器
*
* @author itcast
* @create 2023/8/17 18:08
**/
@Slf4j
@Component("order_payed")
public class OrderPayedHandler implements StatusChangeHandler<OrderSnapshotDTO> {
/**
* 订单支付处理逻辑
*
* @param bizId 业务id
* @param bizSnapshot 快照
*/
@Override
public 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("支付事件处理失败");
}
}
}
6)定义订单状态机类
AbstractStateMachine状态机抽象类是状态机的核心类,是具体的状态机要继承的抽象类,比如我们实现订单状态机就需要继承AbstractStateMachine抽象类。
暂时无法在飞书文档外展示此内容
成员变量:
初始状态:设置初始状态,比如订单的初始状态为待支付。
状态机名称:返回的状态机的标识,比如订单状态机返回“order”作为订单状态机的名称。
方法:
返回状态机名称:返回状态机的名称
返回初始状态:返回初始状态
启动状态机:开始进行状态机管理,通常在新建实例时调用此方法,比如:新建一个订单调用此方法将订单状态设置为初始状态,传入参数:业务主键(如订单id)
变更状态:调用此方法更改状态
后处理方法:当状态变更后统一执行的逻辑。
下边定义订单状态机类继承此抽象类,代码如下:
package com.jzo2o.orders.base.config;
/**
* 订单状态机
*
* @author itcast
* @create 2023/8/4 11:20
**/
@Component
public class OrderStateMachine extends AbstractStateMachine<OrderSnapshotDTO> {
/**
* 构建订单状态机对象
* @param stateMachinePersister 状态机持久化service
* @param bizSnapshotService 订单快照service
* @param redisTemplate
*/
public OrderStateMachine(StateMachinePersister stateMachinePersister, BizSnapshotService bizSnapshotService, RedisTemplate redisTemplate) {
super(stateMachinePersister, bizSnapshotService, redisTemplate);
}
/**
* 设置状态机名称
*
* @return 状态机名称
*/
@Override
protected String getName() {
return "order";
}
@Override
protected void postProcessor(OrderSnapshotDTO orderSnapshotDTO) {
}
/**
* 设置状态机初始状态
*
* @return 状态机初始状态
*/
@Override
protected OrderStatusEnum getInitState() {
return OrderStatusEnum.NO_PAY;
}
}
7)状态机表设计
状态机使用MySQL对状态进行持久化,涉及到如下表:
状态机持久化表:
每个订单对应状态机表中的一条记录。
state_machine_name :针对订单的状态机起个名称叫order,针对服务单的状态机可以起个名称为serve。
biz_id:存储订单id
state:记录该订单的当前状态
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;
状态机快照表:
一个订单在快照表有多条记录,每变一个状态会记录该状态下的快照信息(即订单相关的详细信息)便于查询订单变化的历史记录。
state_machine_name :同上
biz_id :同上
db_shard_id:暂时用不到
state:对应快照的状态
biz_data:快照信息(json格式),用在订单状态机就是记录订单相关的信息。
create table `jzo2o-orders`.biz_snapshot
(
id bigint auto_increment comment '主键'
constraint `PRIMARY`
primary key,
state_machine_name varchar(50) null comment '状态机名称',
biz_id varchar(50) null comment '业务id',
db_shard_id bigint null comment '分库键',
state varchar(50) null comment '状态代码',
biz_data varchar(5000) null comment '业务数据',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '业务数据快照' charset = utf8mb4;
8)小结
如何实现一个订单状态机?
1.2.2 测试订单状态机
目标:
测试状态机的方法实现订单状态变更。
能说出如何实现一个订单状态机。
1) 加载订单状态机
在orders-base工程的AutoImportConfiguration类中配置导入订单状态机。
2)测试启动状态机
下边在jzo2o-orders-manager下编写测试代码。
调用OrderStateMachine的start()方法启动一个订单的状态机,启动状态机表示订单用状态机管理状态,启动状态机后会设置订单的初始状态。
package com.jzo2o.orders.manager.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jzo2o.orders.base.config.OrderStateMachine;
import com.jzo2o.orders.base.model.domain.Orders;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.Resource;
import java.util.List;
@SpringBootTest
@Slf4j
public class OrderStateMachineTest {
@Resource
private OrderStateMachine orderStateMachine;
@Test
public void test_start() {
//启动状态机,指定订单id
String start = orderStateMachine.start("101");
log.info("返回初始状态:{}", start);
}
}
执行测试方法,对101订单启动状态机管理,启动后101号订单的状态为NO_PAY待支付状态。
观察state_persister表有一条101号订单的状态持久化记录,每条订单对应state_persister表的一条记录。
观察biz_snapshot表有一条101号订单的快照信息,一条订单在biz_snapshot表对应多个条记录,每次订单状态变更都会产生一个快照。
注意:如果报错:“java.lang.IllegalStateException: 已存在状态,不可初始化” ,说明101号订单的状态机已启动,可以更改订单号测试其它订单状态机启动。
3)测试状态变更方法
根据状态变更事件定义可知,执行测试方法后101订单的状态由NO_PAY(待支付)变更为DISPATCHING(派单中)。
定义测试方法
@Test
public void test_changeStatus() {
//状态变更
orderStateMachine.changeStatus("101",OrderStatusChangeEventEnum.PAYED);
}
执行此方法,预期结果:
执行PAYED事件对应的动作,在动作类中打断点
如果动作方法执行成功state_persister表中101订单的状态变更为DISPATCHING。
biz_snapshot表多了一条101号订单的快照信息。
因为我们指定的订单号在订单表不存在所以此动作类执行失败,导致状态机变更状态失败。
我们可以暂时将动作类的代码屏蔽,再次测试。
下边对102号订单进行状态机管理
@Test
public void test_orderStatMmachine() {
//启动状态机
String start = orderStateMachine.start("102");
//状态变更
orderStateMachine.changeStatus("102",OrderStatusChangeEventEnum.PAYED);
}
执行测试:
1)观察状态机持久化表是否有102订单的状态记录。
2)观察快照表是否有102订单的记录。
3)观察控制台,状态变更处理器的执行信息是否输出,有输出说明处理器已执行
4)阅读源码
进入jzo2o-framework下的jzo2o-statemachine工程,阅读AbstractStateMachine类的源码,通过阅读代码理解状态机组件的运行过程。
阅读启动状态机方法:
首先判断该订单是否启动状态,如果没有启动则向状态机表插入记录,否则抛出异常"已存在状态,不可初始化".
接下来保存订单快照。
/**
* 状态机初始化,不保存快照
*
* @param bizId 业务id
* @return 初始化状态代码
*/
public String start(String bizId) {
return start(null, bizId, initState, null);
}
/**
* 启动状态机,并设置当前状态和保存业务快照,快照分库分表
*
* @param dbShardId 分库键
* @param bizId 业务id
* @param statusDefine 当前状态
* @param bizSnapshot 快照
* @return 当前状态代码
*/
public String start(Long dbShardId, String bizId, StatusDefine statusDefine, T bizSnapshot) {
//1.初始化状态机状态
String currentState = stateMachinePersister.getCurrentState(name, bizId);
if (ObjectUtil.isEmpty(currentState)) {
stateMachinePersister.init(name, bizId, statusDefine);
} else {
throw new IllegalStateException("已存在状态,不可初始化");
}
//2.保存业务快照
if (bizSnapshot == null) {
bizSnapshot = ReflectUtil.newInstance(getSnapshotClass());
}
//设置快照id
bizSnapshot.setSnapshotId(bizId);
//设置快照状态
bizSnapshot.setSnapshotStatus(statusDefine.getStatus());
//快照转json
String bizSnapshotString = JSONUtil.toJsonStr(bizSnapshot);
if (ObjectUtil.isNotEmpty(bizSnapshot)) {
bizSnapshotService.save(dbShardId, name, bizId, statusDefine, bizSnapshotString);
}
//执行后处理方法
postProcessor(bizSnapshot);
return statusDefine.getCode();
}
阅读状态变更方法:
状态变更前会判断订单的当前状态是否和事件定义的源状态一致,如果不一致则说明当前订单的状态不能通过该事件去更新状态,此时将终止状态变更,否则将通过状态变更处理器去更新订单的状态。
/**
* 变更状态并保存快照,快照不进行分库
*
* @param bizId 业务id
* @param statusChangeEventEnum 状态变换事件
*/
public void changeStatus(String bizId, StatusChangeEvent statusChangeEventEnum) {
changeStatus(null, bizId, statusChangeEventEnum, null);
}
/**
* 变更状态并保存快照,快照分库分表
*
* @param dbShardId 分库键
* @param bizId 业务id
* @param statusChangeEventEnum 状态变换事件
* @param bizSnapshot 业务数据快照(json格式)
*/
public void changeStatus(Long dbShardId, String bizId, StatusChangeEvent statusChangeEventEnum, T bizSnapshot) {
//1.查询当前状态
String statusCode = getCurrentState(bizId);
//2.校验起止状态是否与事件匹配
if (ObjectUtil.isNotEmpty(statusChangeEventEnum.getSourceStatus()) && ObjectUtil.notEqual(statusChangeEventEnum.getSourceStatus().getCode(), statusCode)) {
throw new CommonException(HTTP_INTERNAL_ERROR, "状态机起止状态与事件不匹配");
}
//3.获取状态处理程序bean
//事件代码
String eventCode = statusChangeEventEnum.getCode();
StatusChangeHandler bean = null;
try {
bean = SpringUtil.getBean(name + "_" + eventCode, StatusChangeHandler.class);
} catch (Exception e) {
log.info("不存在‘{}’StatusChangeHandler", name + "_" + eventCode);
}
if (bizSnapshot == null) {
bizSnapshot = ReflectUtil.newInstance(getSnapshotClass());
}
//设置快照id
bizSnapshot.setSnapshotId(bizId);
//设置目标状态
bizSnapshot.setSnapshotStatus(statusChangeEventEnum.getTargetStatus().getStatus());
if (ObjectUtil.isNotNull(bean)) {
//4.执行状态变更
bean.handler(bizId, statusChangeEventEnum, bizSnapshot);
}
//5.状态持久化
stateMachinePersister.persist(name, bizId, statusChangeEventEnum.getTargetStatus());
//6、存储快照
if (ObjectUtil.isNotEmpty(bizSnapshot)) {
//构建新的快照信息
bizSnapshot = buildNewSnapshot(bizId, bizSnapshot, statusChangeEventEnum.getSourceStatus());
String newBizSnapShotString = JSONUtil.toJsonStr(bizSnapshot);
bizSnapshotService.save(dbShardId, name, bizId, statusChangeEventEnum.getTargetStatus(), newBizSnapShotString);
}
//7.清理快照缓存
String key = "JZ_STATE_MACHINE:" + name + ":" + bizId;
redisTemplate.delete(key);
//执行后处理方法
postProcessor(bizSnapshot);
}
5)小结
本节测试了状态机的两个方法:
- 启动状态机
启动状态机表示开始用状态机管理状态,并且设置初始状态。
- 状态变更
使用状态机进行状态变更只需要指定状态变更事件。不用在业务代码中硬编码。
能说出如何实现一个订单状态机。
1.2.3 使用订单状态机
目标:
下单时使用状态机
在支付成功时使用状态机
1)下单时启动状态机
下单后创建一个新订单,使用状态机的启动方法表示用状态机对该订单的状态开始进行管理。
首先在OrdersCreateServiceImpl中注入状态机:
@Resource
private OrderStateMachine orderStateMachine;
代码如下:
@Transactional(rollbackFor = Exception.class)
public void add(Orders orders) {
boolean save = this.save(orders);
if (!save) {
throw new DbRuntimeException("下单失败");
}
//构建快照对象
OrderSnapshotDTO orderSnapshotDTO = BeanUtil.toBean(baseMapper.selectById(orders.getId()), OrderSnapshotDTO.class);
//状态机启动
orderStateMachine.start(null,String.valueOf(orders.getId()),orderSnapshotDTO);
}
注意:状态机的操作方法放在业务方法中且和业务方法处于一个事务中。
2)支付成功使用状态机
支付成功通过状态机将订单状态由待支付更新派单中。
- 定义状态变更动作类
在动作类中更新订单的状态,在动作类中更新订单的状态要比在多处业务代码中对订单状态硬编码要强的多,因为可以在动作类中统一对订单状态进行管理。
除了更新订单状态以外还需要填充订单快照的相关信息,这里主要是支付相关的信息,包括:支付状态、支付时间、支付服务的交易单号、第三方支付的交易单号等。
代码如下:
注意动作类的bean名称规则是:状态机名称_事件名称
order_payed:状态机名称为order,中间用“_”分隔,payed为状态变更事件名称(从OrderStatusChangeEventEnum中查找)
package com.jzo2o.orders.base.handler;
import cn.hutool.db.DbRuntimeException;
import com.jzo2o.orders.base.enums.OrderPayStatusEnum;
import com.jzo2o.orders.base.enums.OrderStatusEnum;
import com.jzo2o.orders.base.model.dto.OrderSnapshotDTO;
import com.jzo2o.orders.base.model.dto.OrderUpdateStatusDTO;
import com.jzo2o.orders.base.service.IOrdersCommonService;
import com.jzo2o.statemachine.core.StateMachineSnapshot;
import com.jzo2o.statemachine.core.StatusChangeEvent;
import com.jzo2o.statemachine.core.StatusChangeHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* 订单支付成功处理器
*
* @author itcast
* @create 2023/8/17 18:08
**/
@Slf4j
@Component("order_payed")
public class OrderPayedHandler implements StatusChangeHandler<OrderSnapshotDTO> {
@Resource
private IOrdersCommonService ordersService;
/**
* 订单支付处理逻辑
*
* @param bizId 业务id
* @param statusChangeEventEnum 状态变更事件
* @param bizSnapshot 快照
*/
@Override
public 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(bizSnapshot.getPayTime())
.tradingOrderNo(bizSnapshot.getTradingOrderNo())
.transactionId(bizSnapshot.getThirdOrderId())
.tradingChannel(bizSnapshot.getTradingChannel())
.build();
int result = ordersCommonService.updateStatus(orderUpdateStatusDTO);
if (result <= 0) {
throw new DbRuntimeException("支付事件处理失败");
}
}
}
- 在支付成功方法中使用状态机
使用状态机执行支付成功状态变更。
@Transactional(rollbackFor = Exception.class)
public void paySuccess(TradeStatusMsg tradeStatusMsg) {
//查询订单
Orders orders = baseMapper.selectById(Long.parseLong(tradeStatusMsg.getProductOrderNo()));
//2:待支付,4:支付成功
if (ObjectUtil.notEqual(OrderPayStatusEnum.NO_PAY.getStatus(), orders.getPayStatus())) {
log.info("当前订单:{},不是待支付状态", orders.getId());
return;
}
//第三方支付单号校验
if (ObjectUtil.isEmpty(tradeStatusMsg.getTransactionId())) {
throw new CommonException("支付成功通知缺少第三方支付单号");
}
// 修改订单状态和支付状态
OrderSnapshotDTO orderSnapshotDTO = OrderSnapshotDTO.builder()
.payTime(LocalDateTime.now())
.tradingOrderNo(tradeStatusMsg.getTradingOrderNo())
.tradingChannel(tradeStatusMsg.getTradingChannel())
.thirdOrderId(tradeStatusMsg.getTransactionId())
.build();
orderStateMachine.changeStatus( String.valueOf(orders.getId()), OrderStatusChangeEventEnum.PAYED, orderSnapshotDTO);
}
3)测试
测试流程:
创建一个订单
进行支付,支付成功
预期结果:
- 创建订单
新创建一个订单,观察状态机持久化表和状态机快照表是否有订单相关的记录。
示例:
状态机持久化表:
{
"id": 1731873761497014273,
"state_machine_name": "order",
"biz_id": "2312050000000000058",
"state": "NO_PAY",
"create_time": "2023-12-05 11:11:47",
"update_time": "2023-12-05 11:11:47"
}
状态机快照表:
{
"id": 1731873761516351488,
"state_machine_name": "order",
"biz_id": "2312050000000000058",
"db_shard_id": 1,
"state": "NO_PAY",
"biz_data": "{\"ordersStatus\":0,\"cityCode\":\"010\",\"serveItemName\":\"日常保洁\",\"serveId\":1693815624114970626,\"discountAmount\":0,\"lon\":\"116.23189\",\"serveAddress\":\"北京市北京市昌平区北京市昌平区城北街道北京市昌平区政府信息公开办公室北京市昌平区人民政府\",\"contactsName\":\"苗先生\",\"serveItemId\":1685894105234755585,\"price\":1,\"serveItemImg\":\"https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png\",\"realPayAmount\":1,\"serveStartTime\":1701757800000,\"id\":2312050000000000058,\"lat\":\"40.221\",\"serveTypeId\":1678649931106705409,\"updateTime\":1701745907000,\"userId\":1716346406098296832,\"totalAmount\":1,\"unit\":1,\"contactsPhone\":\"13333333333\",\"createTime\":1701745907000,\"payStatus\":2,\"purNum\":1,\"serveTypeName\":\"保洁清洗\"}",
"create_time": "2023-12-05 11:11:47",
"update_time": "2023-12-05 11:11:47"
}
订单表:
{
"id": 2312050000000000058,
"user_id": 1716346406098296832,
"serve_type_id": 1678649931106705409,
"serve_type_name": "保洁清洗",
"serve_item_id": 1685894105234755585,
"serve_item_name": "日常保洁",
"serve_item_img": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png",
"unit": 1,
"serve_id": 1693815624114970626,
"orders_status": 0,
"pay_status": 2,
"refund_status": null,
"price": 1.00,
"pur_num": 1,
"total_amount": 1.00,
"real_pay_amount": 1.00,
"discount_amount": 0.00,
"city_code": "010",
"serve_address": "北京市北京市昌平区北京市昌平区城北街道北京市昌平区政府信息公开办公室北京市昌平区人民政府",
"contacts_phone": "13333333333",
"contacts_name": "苗先生",
"serve_start_time": "2023-12-05 14:30:00",
"lon": 116.23189,
"lat": 40.221,
"pay_time": null,
"evaluation_time": null,
"evaluation_status": 0,
"trading_order_no": null,
"transaction_id": null,
"refund_no": null,
"refund_id": null,
"trading_channel": null,
"display": 1,
"sort_by": 1701757800058,
"real_serve_end_time": null,
"create_time": "2023-12-05 11:11:47",
"update_time": "2023-12-05 11:11:47"
}
- 支付成功
观察该订单的状态是否变为派单中。
观察状态机持久化表中该订单状态是否为派单中。
观察状态机快照表中该订单的快照信息是否正确。
示例:
状态机持久化表
{
"id": 1731873761497014273,
"state_machine_name": "order",
"biz_id": "2312050000000000058",
"state": "DISPATCHING",
"create_time": "2023-12-05 11:11:47",
"update_time": "2023-12-05 11:14:31"
}
状态机快照表:
{
"id": 1731873761516351488,
"state_machine_name": "order",
"biz_id": "2312050000000000058",
"db_shard_id": 1,
"state": "NO_PAY",
"biz_data": "{\"ordersStatus\":0,\"cityCode\":\"010\",\"serveItemName\":\"日常保洁\",\"serveId\":1693815624114970626,\"discountAmount\":0,\"lon\":\"116.23189\",\"serveAddress\":\"北京市北京市昌平区北京市昌平区城北街道北京市昌平区政府信息公开办公室北京市昌平区人民政府\",\"contactsName\":\"苗先生\",\"serveItemId\":1685894105234755585,\"price\":1,\"serveItemImg\":\"https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png\",\"realPayAmount\":1,\"serveStartTime\":1701757800000,\"id\":2312050000000000058,\"lat\":\"40.221\",\"serveTypeId\":1678649931106705409,\"updateTime\":1701745907000,\"userId\":1716346406098296832,\"totalAmount\":1,\"unit\":1,\"contactsPhone\":\"13333333333\",\"createTime\":1701745907000,\"payStatus\":2,\"purNum\":1,\"serveTypeName\":\"保洁清洗\"}",
"create_time": "2023-12-05 11:11:47",
"update_time": "2023-12-05 11:11:47"
},
{
"id": 1731874449264766976,
"state_machine_name": "order",
"biz_id": "2312050000000000058",
"db_shard_id": 1,
"state": "DISPATCHING",
"biz_data": "{\"tradingChannel\":\"WECHAT_PAY\",\"ordersStatus\":100,\"payTime\":1701746020020,\"cityCode\":\"010\",\"serveItemName\":\"日常保洁\",\"serveId\":1693815624114970626,\"discountAmount\":0,\"lon\":\"116.23189\",\"serveAddress\":\"北京市北京市昌平区北京市昌平区城北街道北京市昌平区政府信息公开办公室北京市昌平区人民政府\",\"contactsName\":\"苗先生\",\"serveItemId\":1685894105234755585,\"price\":1,\"serveItemImg\":\"https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png\",\"thirdOrderId\":\"4200002069202312059443886005\",\"realPayAmount\":1,\"serveStartTime\":1701757800000,\"id\":2312050000000000058,\"lat\":\"40.221\",\"serveTypeId\":1678649931106705409,\"updateTime\":1701745907000,\"userId\":1716346406098296832,\"tradingOrderNo\":1731874360980430850,\"totalAmount\":1,\"unit\":1,\"contactsPhone\":\"13333333333\",\"createTime\":1701745907000,\"payStatus\":2,\"purNum\":1,\"serveTypeName\":\"保洁清洗\"}",
"create_time": "2023-12-05 11:14:31",
"update_time": "2023-12-05 11:14:31"
}
订单的状态
{
"id": 2312050000000000058,
"user_id": 1716346406098296832,
"serve_type_id": 1678649931106705409,
"serve_type_name": "保洁清洗",
"serve_item_id": 1685894105234755585,
"serve_item_name": "日常保洁",
"serve_item_img": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png",
"unit": 1,
"serve_id": 1693815624114970626,
"orders_status": 100,
"pay_status": 4,
"refund_status": null,
"price": 1.00,
"pur_num": 1,
"total_amount": 1.00,
"real_pay_amount": 1.00,
"discount_amount": 0.00,
"city_code": "010",
"serve_address": "北京市北京市昌平区北京市昌平区城北街道北京市昌平区政府信息公开办公室北京市昌平区人民政府",
"contacts_phone": "13333333333",
"contacts_name": "苗先生",
"serve_start_time": "2023-12-05 14:30:00",
"lon": 116.23189,
"lat": 40.221,
"pay_time": "2023-12-05 11:13:40",
"evaluation_time": null,
"evaluation_status": 0,
"trading_order_no": 1731874360980430850,
"transaction_id": "4200002069202312059443886005",
"refund_no": null,
"refund_id": null,
"trading_channel": "WECHAT_PAY",
"display": 1,
"sort_by": 1701757800058,
"real_serve_end_time": null,
"create_time": "2023-12-05 11:11:47",
"update_time": "2023-12-05 11:14:31"
}
4)小结
项目使用状态机实现什么功能?为什么这样做?
1.2.4 取消订单使用状态机
1)修改策略类
下边在取消订单策略类中使用状态机修改订单状态。
package com.jzo2o.orders.manager.strategy.impl;
import cn.hutool.core.bean.BeanUtil;
import com.jzo2o.orders.base.config.OrderStateMachine;
import com.jzo2o.orders.base.enums.OrderStatusChangeEventEnum;
import com.jzo2o.orders.base.enums.OrderStatusEnum;
import com.jzo2o.orders.base.model.domain.OrdersCanceled;
import com.jzo2o.orders.base.model.dto.OrderSnapshotDTO;
import com.jzo2o.orders.base.model.dto.OrderUpdateStatusDTO;
import com.jzo2o.orders.base.service.IOrdersCommonService;
import com.jzo2o.orders.manager.model.dto.OrderCancelDTO;
import com.jzo2o.orders.manager.service.IOrdersCanceledService;
import com.jzo2o.orders.manager.strategy.OrderCancelStrategy;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
/**
* @author Mr.M
* @version 1.0
* @description c端用户取消待支付订单策略类
* @date 2024/11/6 17:38
*/
@Component("1:NO_PAY")
public class CommonUserNoPayOrderCancelStrategy implements OrderCancelStrategy {
@Resource
private IOrdersCommonService ordersCommonService;
@Resource
private IOrdersCanceledService ordersCanceledService;
@Resource
private OrderStateMachine orderStateMachine;
@Override
@Transactional
public void cancel(OrderCancelDTO orderCancelDTO) {
// //保存取消订单记录
// OrdersCanceled ordersCanceled = BeanUtil.toBean(orderCancelDTO, OrdersCanceled.class);
// ordersCanceled.setCancellerId(orderCancelDTO.getCurrentUserId());
// ordersCanceled.setCancelerName(orderCancelDTO.getCurrentUserName());
// ordersCanceled.setCancellerType(orderCancelDTO.getCurrentUserType());
// ordersCanceled.setCancelTime(LocalDateTime.now());
// ordersCanceledService.save(ordersCanceled);
// //更新订单状态
// OrderUpdateStatusDTO orderUpdateStatusDTO = new OrderUpdateStatusDTO();
// //订单id
// orderUpdateStatusDTO.setId(orderCancelDTO.getId());
// //原始订单状态
// orderUpdateStatusDTO.setOriginStatus(OrderStatusEnum.NO_PAY.getStatus());
// //目标状态
// orderUpdateStatusDTO.setTargetStatus(OrderStatusEnum.CANCELED.getStatus());
// Integer integer = ordersCommonService.updateStatus(orderUpdateStatusDTO);
// if (integer == 0) {
// throw new RuntimeException("订单取消执行失败");
// }
//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);
}
}
2)复制策略类
其它策略类实现方法类似,在通过状态机更新订单状态时需要指定事件名称。
拷贝课程资料下的“取消订单策略类”下的策略类到 jzo2o-orders-mamanger工程下的com.jzo2o.orders.manager.strategy.impl包下,阅读取消订单使用的策略类,找到使用状态机修改订单状态的代码。
2)测试
测试流程:
下单
支付
取消订单
预期结果:
下单成功:
检查状态机state_persister表和biz_snapshot表是否成功插入记录。
已支付状态取消订单:
观察state_persister表中状态是否变更为CANCELED(已取消)
观察biz_snapshot表中是否多了条快照信息。
退款成功。
2 分库分表
2.1 什么是分库分表
1)当前遇到的问题
随着订单数据的增加,当MySQL单表存储数据达到一定量时其存储及查询性能会下降,在阿里的《Java 开发手册》中提到MySQL单表行数超过 500 万行或者单表容量超过 2GB时建议进行分库分表,分库分表可以简单理解为原来一个表存储数据现在改为通过多个数据库及多个表去存储,这就相当于原来一台服务器提供服务现在改成多台服务器组成集群共同提供服务,从而增加了服务能力。
这里说的500 万行或单表容量超过 2GB并不是定律,只是根据生产经验而言,为什么MySQL单表当达到一定数量时性能会下降呢?我们知道为了提高表的查询性能会增加索引,MySQL在使用索引时会将索引加入内存,如果数据量非常大内存肯定装不下,此时就会从磁盘去查询索引就会产生很多的磁盘IO,从而影响性能,这些和表的设计及服务器的硬件配置都有关,所以如果当表的数据量达到一定程度并且还在不断的增加就需要考虑进行分库分表了。
2)什么是分库分表
下边通过一个例子来说明什么是分库分表。
下边是一个电商系统的数据库,涉及了店铺、商品的相关业务。
随着公司业务快速发展,数据库中的数据量猛增,访问性能也变慢了,如何优化呢?
我们可以把数据分散在不同的数据库中,使得单一数据库的数据量变小来缓解单一数据库的性能问题,从而达到提升数据库性能的目的,如下图:将电商数据库拆分为若干独立的数据库,并且对于大表也拆分为若干小表,通过这种数据库 拆分的方法来解决数据库的性能问题
分库分表就是为了解决由于数据量过大而导致数据库性能降低的问题,将原来独立的数据库拆分成若干数据库组成
,将数据大表拆分成若干数据表组成,使得单一数据库、单一数据表的数据量变小,从而达到提升数据库性能的目
的。
3)分库分表的四种方式
分库分表包括分库和分表两个部分,在生产中通常包括:垂直分库、水平分库、垂直分表、水平分表四种方式。
垂直分表
下图是商品查询列表:
用户在浏览商品列表时,只有对某商品感兴趣时才会查看该商品的详细描述。因此,商品信息中商品描述字段访问
频次较低,且该字段存储占用空间较大,访问单个数据IO时间较长;商品信息中商品名称、商品图片、商品价格等 其他字段数据访问频次较高。
由于这两种数据的特性不一样,因此考虑将商品信息表拆分如下:
将访问频次低的商品描述信息单独存放在一张表中,访问频次较高的商品基本信息单独放在一张表中。
垂直分表是将一个表按照字段分成多表,每个表存储其中一部分字段,比如按冷热字段进行拆分。
垂直分表带来的好处是:充分发挥热门数据的操作效率,商品信息的操作的高效率不会被商品描述的低效率所拖累。
通常我们按以下原则进行垂直拆分:
-
把不常用的字段单独放在一张表;
-
把text,blob等大字段拆分出来放在附表中;
-
经常组合查询的列放在一张表中;
垂直分库
通过垂直分表性能得到了一定程度的提升,但是还没有达到要求,并且磁盘空间也快不够了,因为数据还是始终限制在一台服务器,库内垂直分表只解决了单一表数据量过大的问题,但没有将表分布到不同的服务器上,因此每个 表还是竞争同一个物理机的CPU、内存、网络IO、磁盘。
经过思考,他把原有的SELLER_DB(卖家库),分为了PRODUCT_DB(商品库)和STORE_DB(店铺库),并把这两个库分 散到不同服务器,如下图:
由于商品信息与商品描述业务耦合度较高,因此一起被存放在PRODUCT_DB(商品库);而店铺信息相对独立,因此 单独被存放在STORE_DB(店铺库)。
垂直分库是指按照业务将表进行分类,分布到不同的数据库上面,每个库可以放在不同的服务器上,它的核心理念 是专库专用,微服务架构下通常会对数据库进行垂直分为,不同业务数据放在单独的数据库中,比如:客户信息数据库、订单数据库等。
它带来的提升是:
1、解决业务层面的耦合,业务清晰
2、能对不同业务的数据进行分级管理、维护、监控、扩展等
3、高并发场景下,垂直分库一定程度的提升IO、降低单机硬件资源的瓶颈。
垂直分库通过将表按业务分类,然后分布在不同数据库,并且可以将这些数据库部署在不同服务器上,从而达到多 个服务器共同分摊压力的效果,但是依然没有解决单表数据量过大的问题。
水平分库
经过垂直分库后,数据库性能问题得到一定程度的解决,但是随着业务量的增长,PRODUCT_DB(商品库)单库存储数据已经超出预估。粗略估计,目前有8w店铺,每个店铺平均150个不同规格的商品,再算上增长,那商品数量得 往1500w+上预估,并且PRODUCT_DB(商品库)属于访问非常频繁的资源,单台服务器已经无法支撑。此时该如何 优化?
再次分库?但是从业务角度分析,目前情况已经无法再次垂直分库。
尝试水平分库,将店铺ID为单数的和店铺ID为双数的商品信息分别放在两个库中。
也就是说,要操作某条数据,先分析这条数据所属的店铺ID。如果店铺ID为双数,将此操作映射至 RRODUCT_DB1(商品库1);如果店铺ID为单数,将操作映射至RRODUCT_DB2(商品库2)。
水平分库是把同一个表的数据按一定规则拆到不同的数据库中,每个库可以放在不同的服务器上,比如:单数订单在db_orders_0数据库,偶数订单在db_orders_1数据库。
它带来的提升是:
1、解决了单库大数据,高并发的性能瓶颈。
2、提高了系统的稳定性及可用性。
当一个应用难以再细粒度的垂直切分,或切分后数据量行数巨大,存在单库读写、存储性能瓶颈,这时候就需要进行水平分库了,经过水平切分的优化,往往能解决单库存储量及性能瓶颈。但由于同一个表被分配在不同的数据库,需要额外进行数据操作的路由工作,因此大大提升了系统复杂度。
水平分表
按照水平分库的思路把PRODUCT_DB_X(商品库)内的表也可以进行水平拆分,其目的也是为解决单表数据量大 的问题,如下图:
与水平分库的思路类似,不过这次操作的目标是表,商品信息及商品描述被分成了两套表。如果商品ID为双数,将 此操作映射至商品信息1表;如果商品ID为单数,将操作映射至商品信息2表。此操作要访问表名称为 商品信息[商品ID%2 + 1] 。
水平分表是在同一个数据库内,把同一个表的数据按一定规则拆到多个表中,比如:0到500万的订单在orders_0数据、500万到1000万的订单在orders_1数据表。水平分表优化了单一表数据量过大而产生的性能问题。
一般来说,在系统设计阶段就应该根据业务耦合松紧来确定垂直分库,垂直分表方案,在数据量及访问压力不是特 别大的情况,首先考虑缓存、读写分离、索引技术等方案。若数据量极大,且持续增长,再考虑水平分库水平分表方案。
4)小结
项目为什么进行分库分表?
分库分表有哪些形式?
2.2 分库分表带来的问题
1)事务一致性问题
由于分库分表把数据分布在不同库甚至不同服务器,不可避免会带来分布式事务问题。
2)跨节点关联查询
在没有分库前,我们检索商品时可以通过以下SQL对店铺信息进行关联查询:
SELECT p.*,r.[地理区域名称],s.[店铺名称],s.[信誉]
FROM [商品信息] p
LEFT JOIN [地理区域] r ON p.[产地] = r.[地理区域编码]
LEFT JOIN [店铺信息] s ON s.id = p.[所属店铺]
WHERE...ORDER BY...LIMIT...
但垂直分库后[商品信息]和[店铺信息]不在一个数据库,甚至不在一台服务器,无法进行关联查询。
可将原关联查询分为两次查询,第一次查询的结果集中找出关联数据id,然后根据id发起第二次请求得到关联数据,最后将获得到的数据进行拼装。
3) 跨节点分页、排序函数
跨节点多库进行查询时,limit分页、order by排序等问题,就变得比较复杂了。需要先在不同的分片节点中将数据进行排序并返回,然后将不同分片返回的结果集进行汇总和再次排序。
如,进行水平分库后的商品库,按ID倒序排序分页,取第一页:
以上流程是取第一页的数据,性能影响不大,但由于商品信息分布在各数据库的数据可能是随机的,如果是取第 N页,需要将所有节点前N页数据都取出来合并,再进行整体的排序,操作效率可想而知。所以请求页数越大,系 统的性能也会越差。
在使用Max、Min、Sum、Count之类的函数进行计算的时候,与排序分页同理,也需要先在每个分片上执行相应 的函数,然后将各个分片的结果集进行汇总和再次计算,最终将结果返回。
4)主键避重
在分库分表环境中,由于表中数据同时存在不同数据库中,主键值平时使用的自增长将无用武之地,某个分区数据库生成的ID无法保证全局唯一。因此需要单独设计全局主键,以避免跨库主键重复问题。
5)公共表
实际的应用场景中,参数表、数据字典表等都是数据量较小,变动少,而且属于高频联合查询的依赖表。例子中地理区域表也属于此类型。
可以将这类表在每个数据库都保存一份,所有对公共表的更新操作都同时发送到所有分库执行。
2.2 分库分表解决方案
由于分库分表之后,数据被分散在不同的数据库、服务器。因此,对数据的操作也就无法通过常规方式完成,并且 它还带来了一系列的问题。好在,这些问题不是所有都需要我们在应用层面上解决,市面上有很多中间件可供我们 选择。
1)技术方案
AI: java中分库分表解决方案有哪些
在Java应用中,当单个数据库的表数据量达到一定规模后,可能会遇到性能瓶颈,如查询速度慢、锁竞争等问题。这时,通常会采用分库分表(Sharding)的策略来解决这些问题。分库分表可以将大量数据分散到多个数据库或多个表中,以提高系统的可扩展性和性能。
以下是几种常见的Java中的分库分表解决方案:
手工编码:
直接在代码层面实现逻辑,根据一定的规则(如用户ID)进行计算,决定数据应该存储在哪个数据库或者表中。
缺点是手工编码较复杂,需要在各个地方维护分库分表的逻辑,一旦规则改变,需要修改的地方较多。
使用中间件/代理层:
这类方案通常会在应用程序与数据库之间提供一个代理层,通过代理来处理分库分表的逻辑。
MyCat:是一个开源的数据库中间件,支持SQL解析和路由功能,可以实现透明化的分库分表。
ShardingSphere(原Apache ShardingSphere):这是一个分布式数据库框架,提供了Java程序中的分库分表解决方案,支持多种分片策略,并且可以作为JDBC客户端使用,也可以作为数据库代理运行。
ORM框架支持:
MyBatis + MyBatis-Spring/Sharding-JDBC:结合使用MyBatis框架和Spring框架,利用Sharding-JDBC来实现分库分表的功能。
Hibernate + Ehcache/Infinispan:虽然Hibernate本身并不直接支持分库分表,但可以通过配置多数据源以及缓存策略来间接实现分库分表的效果。
云服务提供的解决方案:
- 如果使用的是云服务,如阿里云、腾讯云等,它们往往提供了一整套的解决方案,包括但不限于读写分离、分库分表等功能。
数据库自带的分区功能:
- 如果使用的数据库本身支持分区功能(如MySQL的分区表),可以通过这种方式来实现将一个表中的数据分到多个数据文件中,但在一定程度上也能缓解性能压力。
在实际项目中,选择分库分表方案时,通常会考虑到技术成熟度、社区支持、易用性、性能等因素。以下是一些在实际应用中较为常用的分库分表解决方案:
-
ShardingSphere(Apache ShardingSphere):
-
随着ShardingSphere成为Apache的顶级项目,其稳定性和功能得到了广泛的认可。它支持多种分片算法,可以作为JDBC客户端直接集成到应用程序中,也可以作为数据库代理部署,提供透明化的分库分表能力。
-
ShardingSphere的优势在于它的灵活性和扩展性,支持水平分片、垂直分片等多种模式,并且集成了读写分离、数据加密等功能。
-
-
MyCat:
-
MyCat是一个轻量级的数据库中间件,适用于那些希望快速实现分库分表的企业。尽管它的功能相比ShardingSphere来说可能略显简单,但它依然提供了足够的灵活性来满足大多数应用场景的需求。
-
MyCat适合于已经有稳定架构的企业,希望在不改变太多现有代码的基础上引入分库分表机制。
-
-
手工编码实现:
- 在一些特定场景下,企业可能会选择自己实现分库分表逻辑。这种做法的好处是可以完全控制整个流程,但缺点是需要投入大量的时间和精力去维护这些逻辑,而且容易出现各种边界情况的问题。
-
云服务提供的解决方案:
-
对于使用云服务的企业来说,云平台通常会提供一整套的解决方案,包括自动化的分库分表、负载均衡等。例如,阿里云的DRDS就是一个典型的代表,它可以帮助用户轻松地管理和扩展数据库。
-
云平台的优势在于它们通常会有专业的团队来维护和更新,减轻了企业的运维负担。
-
总的来说,ShardingSphere由于其强大的功能和灵活性,在很多场景下成为了首选。而MyCat则因其轻量级的特点,在一些中小企业或者对性能要求不是很高的项目中也有一定的使用率。当然,具体选择哪种方案还是要根据项目的实际情况来定。
2) ShardingSphere介绍
Apache ShardingSphere 是一款分布式的数据库生态系统, 可以将任意数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密等能力对原有数据库进行增强。
Apache ShardingSphere 设计哲学为 Database Plus,旨在构建异构数据库上层的标准和生态。 它关注如何充分合理地利用数据库的计算和存储能力,而并非实现一个全新的数据库。 它站在数据库的上层视角,关注它们之间的协作多于数据库自身。
官方文档:https://shardingsphere.apache.org/document/current/cn/overview/
Apache ShardingSphere 由 ShardingSphere-JDBC 和 ShardingSphere-Proxy组成。
ShardingSphere-JDBC
ShardingSphere-JDBC 定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务。 它使用客户端直连数据库,以 jar 包形式提供服务,无需额外部署和依赖,可理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。
-
适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC;
-
支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, HikariCP 等;
-
支持任意实现 JDBC 规范的数据库,目前支持 MySQL,PostgreSQL,Oracle,SQLServer 以及任何可使用 JDBC 访问的数据库。
如下图:
ShardingSphere-JDBC是一个框架,对jdbc访问数据库进行了封装,实现了分库分表的功能。
Governance Center的作用有点类似于微服务架构中的配置中心
ShardingSphere-Proxy
ShardingSphere-Proxy 定位为透明化的数据库代理端,通过实现数据库二进制协议,对异构语言提供支持。 目前提供 MySQL 和 PostgreSQL 协议,透明化数据库操作,对 DBA 更加友好。
-
向应用程序完全透明,可直接当做 MySQL/PostgreSQL 使用;
-
兼容 MariaDB 等基于 MySQL 协议的数据库,以及 openGauss 等基于 PostgreSQL 协议的数据库;
-
适用于任何兼容 MySQL/PostgreSQL 协议的的客户端,如:MySQL Command Client, MySQL Workbench, Navicat 等。
如下图:
ShardingSphere-Proxy和MyCat的作用一样,是一个数据库代理,应用程序原来是直接访问数据库,现在通过访问ShardingSphere-Proxy实现对数据分库分表。
我们的课程讲解ShardingSphere-JDBC,因为它的功能更强大,灵活性更好。
哪个应用得更多?
ShardingSphere-JDBC 和 ShardingSphere-Proxy哪个应用的更多?
实际上,选择哪一个取决于具体的应用场景和技术背景。如果应用程序已经使用了Spring框架,并且愿意在应用程序内部做少量的配置修改,那么ShardingSphere-JDBC是一个不错的选择。相反,如果希望保持应用程序代码不变,或者应用程序本身不适合修改JDBC访问逻辑,那么ShardingSphere-Proxy可能更适合。
在实际应用中,ShardingSphere-JDBC因为其更简单的集成方式而被广泛应用,特别是在已有Spring框架的环境中。但是随着人们对非侵入式解决方案的需求增加,ShardingSphere-Proxy也逐渐受到了关注,尤其是在需要与多种不同应用程序集成的情况下。
本项目讲解ShardingSphere-JDBC的应用方法。
2.3 ShardingSphere-JDBC入门
2.3.1 搭建SpringBoot工程
1)入门程序说明
本节我们对订单库进行分库分表,通过快速入门程序的开发,快速体验ShardingSphere-JDBC的使用
方法。
开发流程是:
先创建一个普通的SpringBoot工程,可以实现向数据库表插入数据。
再改造为使用ShardingSphere-JDBC对数据库分库分表。
2)创建数据库
创建数据库:shardingsphere-test-01、shardingsphere-test-02
分别在这两个数据库中创建t_order_1、t_order_2、t_order表,这两个表就是对订单表的水平分表。
运行下边的脚本:
DROP TABLE IF EXISTS `t_order_1`;
CREATE TABLE `t_order_1`
(
`id` bigint(20) NOT NULL COMMENT '订单id',
`price` decimal(10, 2) NOT NULL COMMENT '订单价格',
`user_id` bigint(20) NOT NULL COMMENT '下单用户id',
`status` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '订单状态',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB
CHARACTER SET = utf8
COLLATE = utf8_general_ci
ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS `t_order_2`;
CREATE TABLE `t_order_2`
(
`id` bigint(20) NOT NULL COMMENT '订单id',
`price` decimal(10, 2) NOT NULL COMMENT '订单价格',
`user_id` bigint(20) NOT NULL COMMENT '下单用户id',
`status` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '订单状态',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB
CHARACTER SET = utf8
COLLATE = utf8_general_ci
ROW_FORMAT = Dynamic;
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order`
(
`id` bigint(20) NOT NULL COMMENT '订单id',
`price` decimal(10, 2) NOT NULL COMMENT '订单价格',
`user_id` bigint(20) NOT NULL COMMENT '下单用户id',
`status` varchar(50) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '订单状态',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB
CHARACTER SET = utf8
COLLATE = utf8_general_ci
ROW_FORMAT = Dynamic;
创建成功
3)创建SpringBoot工程
创建一个普通的SpringBoot工程shardingsphere-test:
pom.xml
springboot和mybatis-plus的版本使用和项目一样的版本,如下:
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.10</spring-boot.version>
<mybatis-plus.version>3.4.3.2</mybatis-plus.version>
</properties>
pom.xml内容如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.itcast</groupId>
<artifactId>shardingsphere-test</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>shardingsphere-test</name>
<description>shardingsphere-test</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.10</spring-boot.version>
<mybatis-plus.version>3.4.3.2</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!--数据库-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.32</version>
</dependency>
<!--mybatis-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!--lombok-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.26</version>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
<configuration>
<mainClass>cn.itcast.shardingsphere.ShardingsphereTestApplication</mainClass>
<skip>true</skip>
</configuration>
<executions>
<execution>
<id>repackage</id>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
生成mapper和模型类
使用工具基于表生成模型类和mapper
生成过程略,生成成功如下图:
配置application.yaml
server:
port: 8080
spring:
application:
name: shardingsphere-test
profiles:
active: dev
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.101.68:3306/shardingsphere-test-01?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&stringtype=unspecified
username: root
password: mysql
type: com.zaxxer.hikari.HikariDataSource
hikari:
auto-commit: true
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
minimum-idle: 3
maximum-pool-size: 10
pool-name: HikariCP
启动类
package cn.itcast.shardingsphere;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan("cn.itcast.shardingsphere.mapper")
public class ShardingsphereTestApplication {
public static void main(String[] args) {
SpringApplication.run(ShardingsphereTestApplication.class, args);
}
}
测试
编写测试方法,向t_order表插入数据
package cn.itcast.shardingsphere.mapper;
import cn.itcast.shardingsphere.model.domain.TOrder;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class TOrderMapperTest {
@Autowired
private TOrderMapper tOrderMapper;
@Test
void testInsert() {
for (long i = 0; i < 10; i++) {
tOrderMapper.insert(new TOrder().setUserId(i).setPrice(BigDecimal.valueOf(100.0)).setStatus("ok"));
}
}
}
2.3.2 实现分库分表
1)添加ShardingSphere-JDBC依赖
下边集成 ShardingSphere-JDBC实现分库分表。
shardingsphere-jdbc用5.4.0版本
添加shardingsphere-jdbc依赖
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.7.10</spring-boot.version>
<shardingsphere-jdbc.version>5.4.0</shardingsphere-jdbc.version>
<snakeyaml.version>1.33</snakeyaml.version>
<mybatis-plus.version>3.4.3.2</mybatis-plus.version>
</properties>
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>${shardingsphere-jdbc.version}</version>
</dependency>
<dependency>
<groupId>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>${snakeyaml.version}</version>
</dependency>
org.yaml:用于解析ShardingSphere-JDBC的配置文件语法。
2)配置专属JDBC驱动
原来我们在application.yaml中配置的JDBC驱动如下:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://192.168.101.68:3306/shardingsphere-test-01?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&stringtype=unspecified
username: root
password: mysql
集成ShardingSphere-JDBC需要修改为ShardingSphere-JDBC提供的JDBC驱动。
屏蔽原来的datasource,改为如下:
datasource:
# driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://192.168.101.68:3306/shardingsphere-test-01?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai&stringtype=unspecified
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-jdbc-dev.yml
3)配置分库分表策略(重要)
下边配置shardingsphere-jdbc-dev.yml文件,在此文件配置分库分表的策略。
配置数据源
首先配置数据源,因为目标是分两个数据库,所以配置两个数据源。
在resources下创建shardingsphere-jdbc-dev.yml文件,配置内容如下:
dataSources:
db-orders-0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://192.168.101.68:3306/shardingsphere-test-01?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
username: root
password: mysql
db-orders-1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://192.168.101.68:3306/shardingsphere-test-02?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
username: root
password: mysql
配置数据分片规则
下边配置数据分片规则,非常重要。
分片规则流程:
首先配置逻辑表,在逻辑表中配置数据结点、分表策略和分库策略。
配置逻辑表
参考此文档查看配置项的意义:https://shardingsphere.apache.org/document/current/cn/user-manual/shardingsphere-jdbc/yaml-config/rules/sharding/
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: db-orders-${0..1}.t_order_${1..2}
tableStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: orders_table_inline
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: orders_database_inline
t_order:逻辑表名,与模型类中指定的表名一致:
@TableName("t_order")
public class Order implements Serializable {
actualDataNodes: 配置实际数据结点,由数据源+真实表名组成
tableStrategy:分表策略
shardingColumn:指定分片键,这里指定分片键为id表示根据主键进行水平分表。
shardingAlgorithmName:分片算法名称,自定义名称,下边会单独定义此算法。
databaseStrategy:分库策略
shardingColumn:指定分片键,这里指定分片键为user_id表示根据user_id进行分库。
shardingAlgorithmName:分片算法名称,自定义名称,下边会单独定义此算法。
配置分库分表算法
与tables平级,配置shardingAlgorithms。
内容如下:
rules:
- !SHARDING
tables:
t_order:
actualDataNodes: db-orders-${0..1}.t_order_${1..2}
tableStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: orders_table_inline
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: orders_database_inline
shardingAlgorithms:
# 订单-分库算法
orders_database_inline:
type: INLINE
props:
# 分库算法表达式
algorithm-expression: db-orders-${user_id % 2}
# 分库支持范围查询
allow-range-query-with-inline-sharding: true
# 订单-分表算法
orders_table_inline:
type: INLINE
props:
# 分表算法表达式
algorithm-expression: t_order_${id % 2 + 1}
# 允许范围查询
allow-range-query-with-inline-sharding: true
orders_database_inline:分库算法名称,对应上边配置的shardingAlgorithmName: orders_database_inline。
algorithm-expression: db-orders-${user_id % 2}: 根据 db-orders-${user_id % 2}表达式决定数据库
举例:user_id为1,通过计算user_id % 2得出1,数据库为db-orders-1
user_id为2,通过计算user_id % 2得出0,数据库为db-orders-0
orders_table_inline:分表算法名称,对应上边配置的shardingAlgorithmName: orders_table_inline
algorithm-expression: t_order_${id % 2 + 1}: 根据 t_order_${id % 2 + 1}表达决定实际的表名。
举例:id为1,通过计算id % 2 + 1得出2,表名为:t_order_2
id为2,通过计算id % 2 + 1得出1,表名为:t_order_1
完整的配置文件内容如下:
dataSources:
db-orders-0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://192.168.101.68:3306/shardingsphere-test-01?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
username: root
password: mysql
db-orders-1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://192.168.101.68:3306/shardingsphere-test-02?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
username: root
password: mysql
rules:
- !TRANSACTION
defaultType: BASE
- !SHARDING
tables:
t_order:
actualDataNodes: db-orders-${0..1}.t_order_${1..2}
tableStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: orders_table_inline
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: orders_database_inline
shardingAlgorithms:
# 订单-分库算法
orders_database_inline:
type: INLINE
props:
# 分库算法表达式
algorithm-expression: db-orders-${user_id % 2}
# 分库支持范围查询
allow-range-query-with-inline-sharding: true
# 订单-分表算法
orders_table_inline:
type: INLINE
props:
# 分表算法表达式
algorithm-expression: t_order_${id % 2 + 1}
# 允许范围查询
allow-range-query-with-inline-sharding: true
# id生成器
keyGenerators:
snowflake:
type: SNOWFLAKE
props:
sql-show: true
4) 插入测试
首先向订单表插入10条数据,将user_id固定为1,根据分库策略数据将写入db-orders-1数据库。
根据 t_order_${id % 2 + 1}可知,根据订单id决定插入的表,订单id为偶数则插入t_order_1,奇数则插入t_order_2表。
观察日志:
首先解析逻辑SQL,现根据分片规则生成真实SQL。
2024-09-26 14:05:58.239 INFO 15532 --- [ main] ShardingSphere-SQL : Logic SQL: INSERT INTO t_order ( id,
user_id,
price,
status ) VALUES ( ?,
?,
?,
? )
2024-09-26 14:05:58.239 INFO 15532 --- [ main] ShardingSphere-SQL : Actual SQL: db-orders-0 ::: INSERT INTO t_order_1 ( id,
user_id,
price,
status ) VALUES (?, ?, ?, ?) ::: [1839184587743883266, 2, 5.0, WAIT_PAY]
...
通过日志可以发现id为奇数的被插入到t_order_2表,为偶数的被插入到t_order_1表,达到预期目标。
再向订单表插入10条数据,将user_id固定为2, 根据分库策略数据将写入db-orders-0数据库。
请自行进行测试并观察结果是否符合预期。
5)查询测试
编写测试方法:
根据订单id进行查询。
@Test
public void testSelect(){
//订单id
Long id = 1L;
//根据订单id和用户id查询订单
List<TOrder> orders = tOrderMapper.selectList(new LambdaQueryWrapper<TOrder>()
.eq(TOrder::getId, id)
);
System.out.println(orders);
}
运行测试观察日志:
2024-09-26 15:58:23.734 INFO 41948 --- [ main] ShardingSphere-SQL : Logic SQL: SELECT id,user_id,price,status FROM t_order
WHERE (id = ?)
2024-09-26 15:58:23.734 INFO 41948 --- [ main] ShardingSphere-SQL : Actual SQL: db-orders-0 ::: SELECT id,user_id,price,status FROM t_order_2
WHERE (id = ?) ::: [1]
2024-09-26 15:58:23.734 INFO 41948 --- [ main] ShardingSphere-SQL : Actual SQL: db-orders-1 ::: SELECT id,user_id,price,status FROM t_order_2
WHERE (id = ?) ::: [1]
为什么最终执行两条SQL?
因为ShardingSphere-JDBC也不清楚id为1数据在哪个数据库,所以会从两个数据库都去查询。
如果查询代码改为如下:
@Test
public void testSelect(){
//订单id
Long id = 1L;
//用户id
Long userId = 2L;
//根据订单id和用户id查询订单
List<TOrder> orders = tOrderMapper.selectList(new LambdaQueryWrapper<TOrder>()
.eq(TOrder::getId, id)
.eq(TOrder::getUserId, userId)
);
System.out.println(orders);
}
请自己运行测试,观察日志,什么生成几条SQL呢? 思考原因。
6)小结
如何使用ShardingSphere-JDBC实现分表分表?
2.2 家政项目实现订单分库分表
2.2.1 搭建分库分表环境
1)分库分表方案
对订单数据进行分表分表。
1、Hash方式
拿分库举例:将订单号除以数据库个数求余数,假如有3个数据库实例,计算表达式为:db_订单号%3, 比如:10号订单会存入到db_1数据库,11号订单存储到db_2数据库。
此方式的优点是:数据均匀。
缺点:扩容时需要迁移数据。比如:3个数据库改为4个数据库,此时计算表达式为:db_订单号%4,10号订单存储到db_2数据库,11号订单存储到db_3数据库,此时就需要进行数据迁移,将10号订单由db_1迁移到db_2。
2、rang方式(范围方式)
比如:0到500万到db_1数据库,500万到1000万到db_2数据库,依次类推。
此方式的优点:扩容时不需要迁移数据。
缺点:存在数据热点问题,因为订单号是从0开始依次往上累加,前期所有的数据都是访问db_1数据库,db_1的压力较大。
3、综合方案
综合1、2方案的优缺点制定综合方案。
分库方案:设计三个数据库,根据用户id哈希,分库表达式为:db_用户id % 3
参考历史经验,前期设计三个数据库,每个数据库使用主从结构部署,可以支撑项目五年左右的运行,虽然哈希存在数据迁移问题,在很长一段时间也不用考虑这个问题。
分表方案:根据订单范围分表,0---1500万落到table_0,1500万---3000万落到table_1,依次类推。
根据范围分表不存在数据库迁移问题,方便系统扩容。
整体方案如下图:
暂时无法在飞书文档外展示此内容
2)创建数据库
订单数据库分为三个库 :jzo2o-orders-0、jzo2o-orders-1、jzo2o-orders-2
下边分别向三个数据库导入jzo2o-orders-sharding.sql(从课程资料下的SQL脚本目录获取)。
每个数据库对orders、biz_snapshot、orders_serve进行分表(暂分3个表),其它表为广播表(即在每个数据库都存在且数据是完整的),如下图:
3)添加依赖
在jzo2o-framework下的jzo2o-shardingsphere-jdbc中添加了具体shardingsphere的依赖,所以我们只需要在orders-base工程引入jzo2o-shardingsphere-jdbc的依赖即可,如下:
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-shardingsphere-jdbc</artifactId>
</dependency>
4) 配置分片规则
在orders-base工程的resources下配置shardingsphere-jdbc-dev.yml(直接从项目源码目录下拷贝shardingsphere-jdbc-dev.yml)。
配置文件如下:
jzo2o-orders-0、jzo2o-orders-1、jzo2o-orders-2表示三个数据源对应三个订单数据库。
每个数据库中对orders、orders_serve、biz_snapshot进行分表。
详细如下:
dataSources:
jzo2o-orders-0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://192.168.101.68:3306/jzo2o-orders-0?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
username: root
password: mysql
jzo2o-orders-1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://192.168.101.68:3306/jzo2o-orders-1?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
username: root
password: mysql
jzo2o-orders-2:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
jdbcUrl: jdbc:mysql://192.168.101.68:3306/jzo2o-orders-2?useUnicode=true&characterEncoding=UTF-8&autoReconnect=true&serverTimezone=Asia/Shanghai
username: root
password: mysql
rules:
- !TRANSACTION
defaultType: BASE
- !SHARDING
tables:
orders:
actualDataNodes: jzo2o-orders-${0..2}.orders_${0..2}
tableStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: orders_table_inline
databaseStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: orders_database_inline
orders_serve:
actualDataNodes: jzo2o-orders-${0..2}.orders_serve_${0..2}
tableStrategy:
standard:
shardingColumn: id
shardingAlgorithmName: orders_serve_table_inline
databaseStrategy:
standard:
shardingColumn: serve_provider_id
shardingAlgorithmName: orders_serve_database_inline
biz_snapshot:
actualDataNodes: jzo2o-orders-${0..2}.biz_snapshot_${0..2}
tableStrategy:
standard:
shardingColumn: biz_id
shardingAlgorithmName: biz_snapshot_table_inline
databaseStrategy:
standard:
shardingColumn: db_shard_id
shardingAlgorithmName: biz_snapshot_database_inline
shardingAlgorithms:
# 订单-分库算法
orders_database_inline:
type: INLINE
props:
# 分库算法表达式
algorithm-expression: jzo2o-orders-${user_id % 3}
# 分库支持范围查询
allow-range-query-with-inline-sharding: true
# 订单-分表算法
orders_table_inline:
type: INLINE
props:
# 分表算法表达式
algorithm-expression: orders_${(int)Math.floor(id % 10000000000 / 15000000)}
# 允许范围查询
allow-range-query-with-inline-sharding: true
# 服务单-分库算法
orders_serve_database_inline:
type: INLINE
props:
# 分库算法表达式
algorithm-expression: jzo2o-orders-${serve_provider_id % 3}
# 允许范围查询
allow-range-query-with-inline-sharding: true
# 服务单-分表算法
orders_serve_table_inline:
type: INLINE
props:
# 允许范围查询
algorithm-expression: orders_serve_${(int)Math.floor(id % 10000000000 / 15000000)}
# 允许范围查询
allow-range-query-with-inline-sharding: true
# 快照-分库算法
biz_snapshot_database_inline:
type: INLINE
props:
# 分库算法表达式
algorithm-expression: jzo2o-orders-${db_shard_id % 3}
# 允许范围查询
allow-range-query-with-inline-sharding: true
# 快照-分表算法
biz_snapshot_table_inline:
type: INLINE
props:
# 允许范围查询
algorithm-expression: biz_snapshot_${(int)Math.floor((Long.valueOf(biz_id)) % 10000000000 / 15000000)}
# 允许范围查询
allow-range-query-with-inline-sharding: true
# id生成器
keyGenerators:
snowflake:
type: SNOWFLAKE
- !BROADCAST
tables:
- breach_record
- orders_canceled
- orders_refund
- orders_dispatch
- orders_seize
- serve_provider_sync
- state_persister
- orders_dispatch_receive
- undo_log
- history_orders_sync
- history_orders_serve_sync
props:
sql-show: true
说明如下:
dataSources:数据源
jzo2o-orders-x:与actualDataNodes对应。
下边以orders表为例说明分库分表策略:
分库键:user_id
分库表达式:jzo2o-orders-${user_id % 3}
根据用户id计算落到哪个数据库
分表键:id
分表表达式:orders_${(int)Math.floor(id % 10000000000 / 15000000)}
按1500万为单位进行分表,比如:订单号2311020000000000019,为19位,表达式的值为19,匹配表orders_0,如果表达式的值大于1500万小于3000万匹配表orders_1。
tables:
orders:
#由数据源名 + 表名组成(参考 Inline 语法规则)
actualDataNodes: jzo2o-orders-${0..2}.orders_${0..2}
tableStrategy:#分表策略
standard:
shardingColumn: id #分片列名称
shardingAlgorithmName: orders_table_inline # 分片算法名称
databaseStrategy:#分库策略
standard:
shardingColumn: user_id
shardingAlgorithmName: orders_database_inline
shardingAlgorithms:
# 订单-分库算法
orders_database_inline:
type: INLINE
props:
# 分库算法表达式
algorithm-expression: jzo2o-orders-${user_id % 3}
# 分库支持范围查询
allow-range-query-with-inline-sharding: true
# 订单-分表算法
orders_table_inline:
type: INLINE
props:
# 分表算法表达式
algorithm-expression: orders_${(int)Math.floor(id % 10000000000 / 15000000)}
# 允许范围查询
allow-range-query-with-inline-sharding: true
!BROADCAST:指定广播表
广播表在jzo2o-orders-0、jzo2o-orders-1、jzo2o-orders-2每个数据库的数据一致。
5) 小结
项目的分库分表是怎么做的?
2.2.2 测试分库分表
1)配置数据源
进入nacos在jzo2o-orders-manager.yaml中配置数据源使用ShardingSphereDriver:
spring:
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-jdbc-${spring.profiles.active}.yml
如下图:
完整配置如下:
spring:
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-jdbc-${spring.profiles.active}.yml
mybatis-plus:
configuration:
default-enum-type-handler: com.baomidou.mybatisplus.core.handlers.MybatisEnumTypeHandler
page:
max-limit: 1000
global-config:
field-strategy: 0
db-config:
logic-delete-field: isDeleted
id-type: assign_id
xxl-job:
port: 9998
canal:
enable: true
sync:
application-name: ${spring.application.name}
rabbit-mq:
routing-keys: canal-mq-jzo2o-orders-manager
exchange: exchange.canal-jzo2o
queue: canal-mq-jzo2o-orders-manager
rabbit-mq:
enable: true
jzo2o:
trade:
aliEnterpriseId: 2088241317544335
wechatEnterpriseId: 1561414331
job:
autoEvaluateCount: 100
openPay: true
2)状态机分库分表
由于对状态机进行了分库分表,需要修改创建订单方法中启动状态机代码:
使用start(Long dbShardId, String bizId, T bizSnapshot) 方法启动状态机,传入分片键user_id。
@Transactional(rollbackFor = Exception.class)
public void add(Orders orders) {
....
//状态机启动
orderStateMachine.start(orders.getUserId(), orders.getId().toString(), orderSnapshotDTO);
}
支付成功调用状态机变更状态方法:
使用:changeStatus(Long dbShardId, String bizId, StatusChangeEvent statusChangeEventEnum, T bizSnapshot)变更状态, 传入分片键user_id。
@Transactional(rollbackFor = Exception.class)
public void paySuccess(TradeStatusMsg tradeStatusMsg) {
....
orderStateMachine.changeStatus(orders.getUserId(), String.valueOf(orders.getId()), OrderStatusChangeEventEnum.PAYED, orderSnapshotDTO);
}
3)测试
测试流程:
下单、支付、取消订单
预期结果:
下单成功:根据用户id分库,写入jzo2o-orders-x的其中一个数据库,根据订单号分表,写入orders_x其中一个订单表。
状态机:根据用户id分库,写入jzo2o-orders-x的其中一个数据库,根据biz_id字段分表,写入biz_snapshot_x中一个状态表机。state_persister为广播表不进行分表在三个数据库数据一致。
支付及取消订单业务操作正常。
orders_canceled表为广播表不进行分表在三个数据库数据一致。
3 订单查询优化
3.1 订单查询优化方案
1) 为什么要优化订单查询?
1)订单查询是一个高频接口,并且订单表数据量大。
2)面向C端用户的订单查询接口其访问量非常大。
3)对于运营端的订单查询接口虽然访问量不大但由于订单数据较多也需要进行优化,提高查询性能。
2) 订单详情优化方案
针对单条订单信息查询接口可以使用缓存进行优化。
对于单条订单信息查询接口通过快照查询接口查询订单的详情信息。
参考AbstractStateMachine类的String getCurrentSnapshotCache(String bizId)快照查询方法,将快照信息缓存到 redis提供查询效率。
根据订单Id查询缓存信息,先从缓存查询,如果缓存没有则查询快照表的数据然后保存到缓存中。缓存设置了过期时间是30分钟。
当订单状态变更,此时订单最新状态的快照有变更,会删除快照缓存,当再次查询快照时从数据库查询最新的快照信息进行缓存。
暂时无法在飞书文档外展示此内容
3) 用户端订单列表优化方案
用户端通过小程序查询订单列表,界面上没有分页查询的按钮,用户端查询订单列表可采用滚动查询的方法。
滚动查询就是一次查询指定数量的记录,不用进行count查询,省去count查询的消耗。
具体方案如下:
- 首先查询符合条件的订单ID。
由于是滚动查询需要传入滚动ID,这里我们在订单表使用排序字段sort_by作为滚动ID。
滚动ID是一种递增的序列号,按服务预约时间降序排列且滚动ID具有唯一性,滚动ID的规则是:服务预约时间+订单号后5位。
滚动查询方式: 按SORT_BY降序,取小于SORT_BY的n条记录 第一页传入SORT_BY参数为空 从第二页开始传入上一页最后一条记录的SORT_BY
示例:
暂时无法在飞书文档外展示此内容
- 使用覆盖索引优化
根据查询条件查询符合条件的订单ID,这里使用覆盖索引优化的方法。
我们知道在InnoDB存储引擎中有两种索引存储形式:
聚集索引:查询条件只有主键的情况会通过聚集索引查询。
非聚集索引:查询条件有多个,此时为了提高查询效率可以创建多个字段的联合索引,根据非聚集索引找到符合条件主键,如果要查询的列只有索引字段则通过非聚集索引直接拿到字段值返回,如果要查询列有一部分在索引之外此时会进行回表查询聚集索引最终拿到数据。
示例:
user表(id、name、 age、address)
对name、age创建联合索引。
sql1:
select id、name、age from user where name=? and age =?
该查询直接从索引中拿到符合条件的数据,不存在回表查询。
sql2:
select * from user where name=? and age =?
该查询列是select * ,address没有包含在索引中,where条件通过联合索引找到符合条件的主键,再通过主键回表查询聚集索引,最终拿到数据。
覆盖索引是什么呢?
覆盖索引是一种优化手段,上边的sql1就是实现了覆盖索引。
覆盖索引(covering index)指一个查询语句的执行只需要从非聚集索引中就可以得到查询记录,而不需要回表去查询聚集索引,可以称之为实现了索引覆盖。
根据上边的需求,我们根据查询条件建立联合索引,通过联合索引找到符合条件的订单ID(主键),从索引中找到的符合条件的订单ID无需回表查询聚集索引。
4)运营端订单列表优化方案
运营端订单列表由于访问量不大这里无需使用缓存,实现方案是首先通过覆盖索引查询符合条件的订单ID,再根据订单ID查询聚集索引拿到数据。
我们根据查询条件创建的联合字段索引是非聚集索引,先从非聚集索引中查询到符合条件的ID,再根据订单ID从聚集索引中查找数据。
6) 小结
订单查询是如何优化的?
3.2 优化订单详情接口
1)阅读状态机快照查询代码
在jzo2o-framework的状态机工程jzo2o-statemachine中提供了查询快照缓存的方法,具体在状态机抽象类AbstractStateMachine中,如下图:
此方法查询的是当前状态下的快照。
/**
* 获取当前状态的快照缓存
*
* @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;
}
状态机生成快照后会清理快照缓存,保证再次查询快照缓存时可以查询到最新的快照信息。
在saveSnapshot()方法中保存快照方法和changeStatus()方法中提供了清理快照的代码。
具体方法如下:
public void saveSnapshot(Long dbShardId, String bizId, StatusDefine statusDefine, T bizSnapshot)
public void changeStatus(Long dbShardId, String bizId, StatusChangeEvent statusChangeEventEnum, T bizSnapshot)
2) 订单详情查询优化
找到“根据订单id查询”接口,找到调用的查询订单详情的service方法:
@Override
public OrderResDTO getDetail(Long id) {
Orders orders = queryById(id);
//懒加载方式取消支付超时的 订单
orders = canalIfPayOvertime(orders);
OrderResDTO orderResDTO = BeanUtil.toBean(orders, OrderResDTO.class);
return orderResDTO;
}
上边的代码是查询数据库,现在改为查询快照缓存方法,如下:
@Override
public OrderResDTO getDetail(Long id) {
//查询订单
// Orders orders = queryById(id);
//从快照表查询快照
String currentSnapshotJson = orderStateMachine.getCurrentSnapshotCache(String.valueOf(id));
OrderSnapshotDTO orderSnapshotDTO = JsonUtils.toBean(currentSnapshotJson, OrderSnapshotDTO.class);
//懒加载方式取消支付超时的 订单
orderSnapshotDTO = canalIfPayOvertime(orderSnapshotDTO);
OrderResDTO orderResDTO = BeanUtil.toBean(orderSnapshotDTO, OrderResDTO.class);
return orderResDTO;
}
/**
* 如果支付过期则取消订单
* @param orderSnapshotDTO
*/
private OrderSnapshotDTO canalIfPayOvertime(OrderSnapshotDTO orderSnapshotDTO){
//订单到达超时时间则自动取消
if(orderSnapshotDTO.getOrdersStatus()==OrderStatusEnum.NO_PAY.getStatus() && orderSnapshotDTO.getOverTime().isBefore(LocalDateTime.now())){
//todo 查询最新支付状态,如果仍是未支付进行取消订单
//取消订单
OrderCancelDTO orderCancelDTO = new OrderCancelDTO();
orderCancelDTO.setId(orderSnapshotDTO.getId());
orderCancelDTO.setCurrentUserId(orderSnapshotDTO.getUserId());
orderCancelDTO.setCurrentUserType(UserType.SYSTEM);
orderCancelDTO.setCancelReason("订单超时支付,自动取消");
cancel(orderCancelDTO);
// orders = getById(orders.getId());
//查询订单快照
String currentSnapshot = orderStateMachine.getCurrentSnapshotCache(String.valueOf(orderSnapshotDTO.getId()));
orderSnapshotDTO = JSONUtil.toBean(currentSnapshot, OrderSnapshotDTO.class);
return orderSnapshotDTO;
}
return orderSnapshotDTO;
}
3)测试
测试流程:
进入小程序订单列表界面,找到一个未支付或派单中的订单点击进入订单详情页面。
观察redis是否有快照缓存数据。
预期结果:
查询一次订单信息后快照信息正常缓存,缓存key为:JZ_STATE_MACHINE:order+订单id
示例:
订单变更状态后再次查询订单信息可以得到最新的状态,这说明最新数据已经同步到缓存中。
4)小结
订单快照是怎么实现的?
3.2 优化用户端订单列表
3.2.1 创建索引
1)理解滚动ID
滚动ID在用户端订单列表查询中至关重要,由于需要按服务预约时间降序排列且滚动ID具有唯一性,滚动ID的规则是:服务预约时间+订单号后5位
根据上边的方案使用滚动ID进行查询的伪代码如下:
select * from 订单表 where 滚动ID<? and 用户id=? order by 滚动ID desc
在创建订单时向订单表保存滚动ID为:服务开始时间(毫秒)+订单后5位
如下代码:
long sortBy = DateUtils.toEpochMilli(orders.getServeStartTime()) + orders.getId() % 100000;
orders.setSortBy(sortBy);
2) 创建联合索引
在创建索引之前需要明确最终执行的SQL语句,如下:
SELECT id
FROM orders_0
WHERE (orders_status = ? AND user_id = ? AND display = 1 and sort_by<? )
ORDER BY sort_by DESC
LIMIT 10
在查询全部订单时 “orders_status = ?” 条件不需要,即执行SQL
SELECT id
FROM orders_0
WHERE ( user_id = ? AND display = 1 and sort_by<? )
ORDER BY sort_by DESC
LIMIT 10
按照最左前缀法则,查询条件必须包括最左边的索引列并将索引字段的顺序和SQL语句where 条件字段的顺序一致,下边创建两个联合索引:
创建索引query_index_0,满足最上边的SQL语句。
create index query_index_0
on `jzo2o-orders-1`.orders_0 (orders_status asc, user_id asc, display asc, sort_by desc);
创建索引query_index_1,满足最下边的SQL语句。
create index query_index_1
on `jzo2o-orders-1`.orders_0 (user_id asc, display asc, sort_by desc);
并且要注意上边两个SQL的查询都是按sort_by 降序排列,所以索引中 sort_by 为desc降序。
3)执行计划
- 什么是执行计划
索引创建完成如何知道这个SQL语句是否用到这个索引呢?并且跟踪我们的需求最终要实现覆盖索引。
通过MySQL的执行计划即可知道该SQL语句是否用上了我们创建的索引。
MySQL 的执行计划(Execution Plan)是 MySQL 查询优化器生成的一个描述查询执行方式的计划。它是一个详细的执行策略,指示 MySQL 数据库引擎如何执行查询以及如何获取所需的数据。
当你执行一个 SQL 查询时,MySQL 查询优化器负责决定如何最有效地执行这个查询。优化器会考虑多个因素,包括表的索引、表的大小、查询中的过滤条件等,然后生成一个执行计划,描述了查询的执行步骤和顺序。
使用 EXPLAIN 关键字来获取 MySQL 查询的执行计划,语法格式如下:
EXPLAIN sql语句
下边我们获取第一个SQL的执行计划,第二个SQL的执行计划大家可以自行测试。
对第一个SQL我们加上一些模拟的参数,使用explain获取它的执行计划:
explain
SELECT id
FROM orders_0
WHERE (orders_status = 0 AND user_id = 1716346406098296832 AND display = 1 and sort_by<1698924600022)
ORDER BY sort_by DESC
LIMIT 10
输出:
[
{
"id": 1,
"select_type": "SIMPLE",
"table": "orders_0",
"partitions": null,
"type": "range",
"possible_keys": "query_index_0,query_index_1",
"key": "query_index_0",
"key_len": "26",
"ref": null,
"rows": 1,
"filtered": 100,
"Extra": "Using where; Using index"
}
]
每项的说明:
id: 每个查询步骤的唯一标识符。
select_type: 查询的类型,例如 SIMPLE(简单查询)或 PRIMARY(主查询,嵌套子查询的最外层查询)。
table: 查询涉及的表。
partitions: 使用的分区(如果有分区表的话)。
type: 查询使用的连接类型,例如 ALL(全表扫描)或 index(索引扫描)。
possible_keys: 可能用于此查询的键列表。
key: 实际用于此查询的键。
key_len: 使用的索引长度。
ref: 显示索引的哪一列被用于查询。
rows: MySQL 估计将需要读取的行数。
filtered: 在表中的行数的百分比,表示查询的条件有多少行满足。
Extra: 其他的额外信息,例如使用了哪些索引、是否使用了文件排序等。
如果key、key_len为null说明没有用到索引。
上边的输出说明:使用了query_index_0索引。
另外重点关注 Extra:
-
Using where: 表示MySQL正在对检索出来的行进行额外的WHERE条件过滤,最终返回满足WHERE条件的行。
-
Using index: 这部分表示MySQL使用了索引来加速查询,但不必回表读取实际的数据行。这种情况通常发生在覆盖索引的情况下,即索引包含了查询所需的所有列,因此MySQL不需要回到数据表中去获取数据,从而提高了查询的性能。
"Using where; Using index"表示使用了覆盖索引,使用了WHERE子句来过滤数据,能够高效地找到并返回满足查询条件的行,而不必浪费资源读取不必要的数据。
- 根据执行计划调试SQL
如果我们把SQL改为如下内容,不仅查询ID还查询user_id:
explain
SELECT id,user_id
FROM orders_0
WHERE (orders_status = 0 AND user_id = 1716346406098296832 AND display = 1 and sort_by<1698924600022)
ORDER BY sort_by DESC
LIMIT 10
我们发现输出"Extra"仍然是 "Using where; Using index"
这是因为id、user_id都是索引中的字段,仍然满足覆盖索引。
如果改为查询列中是索引中不存在字段呢?下边的SQL添加了serve_item_name查询列。
explain
SELECT id,user_id,serve_item_name
FROM orders_0
WHERE (orders_status = 0 AND user_id = 1716346406098296832 AND display = 1 and sort_by<1698924600022)
ORDER BY sort_by DESC
LIMIT 10
输出:
[
{
"id": 1,
"select_type": "SIMPLE",
"table": "orders_0",
"partitions": null,
"type": "range",
"possible_keys": "query_index_0,query_index_1",
"key": "query_index_0",
"key_len": "26",
"ref": null,
"rows": 1,
"filtered": 100,
"Extra": "Using index condition"
}
]
从key、key_len可以看出使用了索引query_index_0,虽然serve_item_name在索引中不存在但是也使用了query_index_0索引,先从索引中查找满足条件的记录,索引中没有的数据再回表查询。
Using index condition表示:是MySQL一种优化手段,先从索引中查找满足条件的记录,索引中没有的数据再回表查询,可以有效减少回表的次数,大大提升了查询的效率。
如果我们想实现Extra为Using index的效果即覆盖索引该怎么实现呢?
可以在索引中添加serve_item_name字段,
create index query_index_0
on `jzo2o-orders-0`.orders_0 (orders_status asc, user_id asc, display asc, sort_by desc, serve_item_name asc);
- 全表扫描
如果我们把SQL语句改为如下方式:
explain
SELECT id,user_id,serve_item_name
FROM orders_0
WHERE (display = 1 and sort_by<1698924600022)
ORDER BY sort_by DESC
LIMIT 10
输出:
[
{
"id": 1,
"select_type": "SIMPLE",
"table": "orders_0",
"partitions": null,
"type": "ALL",
"possible_keys": null,
"key": null,
"key_len": null,
"ref": null,
"rows": 10,
"filtered": 10,
"Extra": "Using where; Using filesort"
}
]
type: ALL
key和key_len为null
说明整个查询是全表查询没有用到索引。虽然我们创建的两个索引中包括了display 和sort_by,根据最左前缀法则 查询条件中没有包含索引中最左边的列,索引无效。
全表扫描通常会比较耗费情况,但也不是绝对的,有时候虽然有索引MySQL也会选择全表扫描的方式,这是因为优化器分析使用全表扫描比使用索引更耗费时间。
4)创建索引
在三个数据库中对三个订单表创建query_index_0索引,执行下边的脚本:
create index query_index_0
on `jzo2o-orders-0`.orders (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-0`.orders_0 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-0`.orders_1 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-0`.orders_2 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-1`.orders (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-1`.orders_0 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-1`.orders_1 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-1`.orders_2 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-2`.orders (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-2`.orders_0 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-2`.orders_1 (orders_status asc, user_id asc, display asc, sort_by desc);
create index query_index_0
on `jzo2o-orders-2`.orders_2 (orders_status asc, user_id asc, display asc, sort_by desc);
在三个数据库中对三个订单表创建query_index_1索引,执行下边的脚本:
create index query_index_1
on `jzo2o-orders-0`.orders (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-0`.orders_0 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-0`.orders_1 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-0`.orders_2 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-1`.orders (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-1`.orders_0 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-1`.orders_1 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-1`.orders_2 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-2`.orders (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-2`.orders_0 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-2`.orders_1 (user_id asc, display asc, sort_by desc);
create index query_index_1
on `jzo2o-orders-2`.orders_2 (user_id asc, display asc, sort_by desc);
注意在执行上边脚本时由于创建的索引较多,可能部分索引已存在报错,点击忽略错误让其执行完
也可以登录mysql容器执行:
docker exec -it mysql /bin/bash
进入容器输入以命令:
mysql -uroot -pmysql
mysql>在mysql命令提示符下粘贴上边的创建索引命令。
5) 小结
什么是聚集索引和非聚集索引?
什么是回表查询?
什么是覆盖索引?
3.2.2 查询订单ID列表
1)优化Service方法
下边根据优化方案优化原来的订单查询方法。
下边是原来的代码:
/**
* 滚动分页查询
*
* @param currentUserId 当前用户id
* @param ordersStatus 订单状态,0:待支付,100:派单中,200:待服务,300:服务中,400:待评价,500:订单完成,600:已取消,700:已关闭
* @param sortBy 排序字段
* @return 订单列表
*/
@Override
public List<OrderSimpleResDTO> consumerQueryList(Long currentUserId, Integer ordersStatus, Long sortBy) {
//1.构件查询条件
LambdaQueryWrapper<Orders> queryWrapper = Wrappers.<Orders>lambdaQuery()
.eq(ObjectUtils.isNotNull(ordersStatus), Orders::getOrdersStatus, ordersStatus)
.lt(ObjectUtils.isNotNull(sortBy), Orders::getSortBy, sortBy)
.eq(Orders::getUserId, currentUserId)
.eq(Orders::getDisplay, EnableStatusEnum.ENABLE.getStatus());
Page<Orders> queryPage = new Page<>();
queryPage.addOrder(OrderItem.desc(SORT_BY));
queryPage.setSearchCount(false);
//2.查询订单列表
Page<Orders> ordersPage = baseMapper.selectPage(queryPage, queryWrapper);
List<Orders> records = ordersPage.getRecords();
List<OrderSimpleResDTO> orderSimpleResDTOS = BeanUtil.copyToList(records, OrderSimpleResDTO.class);
return orderSimpleResDTOS;
}
优化后的代码:
/**
* 滚动分页查询
*
* @param currentUserId 当前用户id
* @param ordersStatus 订单状态,0:待支付,100:派单中,200:待服务,300:服务中,400:待评价,500:订单完成,600:已取消,700:已关闭
* @param sortBy 排序字段
* @return 订单列表
*/
@Override
public List<OrderSimpleResDTO> consumerQueryList(Long currentUserId, Integer ordersStatus, Long sortBy) {
//构件查询条件
LambdaQueryWrapper<Orders> queryWrapper = Wrappers.<Orders>lambdaQuery()
.eq(ObjectUtils.isNotNull(ordersStatus), Orders::getOrdersStatus, ordersStatus)
.lt(ObjectUtils.isNotNull(sortBy), Orders::getSortBy, sortBy)
.eq(Orders::getUserId, currentUserId)
.eq(Orders::getDisplay, EnableStatusEnum.ENABLE.getStatus())
.select(Orders::getId);//只查询id列
Page<Orders> queryPage = new Page<>();
queryPage.addOrder(OrderItem.desc(SORT_BY));
queryPage.setSearchCount(false);
//查询订单id列表
Page<Orders> ordersPage = baseMapper.selectPage(queryPage, queryWrapper);
if (ObjectUtil.isEmpty(ordersPage.getRecords())) {
return new ArrayList<>();
}
//提取订单id列表
List<Long> orderIds= CollUtils.getFieldValues(ordersPage.getRecords(), Orders::getId);
//根据订单id查询订单列表
List<Orders> ordersList = batchQuery(orderIds);
List<OrderSimpleResDTO> orderSimpleResDTOS = BeanUtil.copyToList(ordersList, OrderSimpleResDTO.class);
return orderSimpleResDTOS;
}
2)测试
- 测试查询全部订单
打开小程序进入我的订单列表,查询全部订单,从IDEA控制台日志中获取执行的SQL语句并查看执行计划。
示例:
查询订单ID
根据订单ID查询订单信息
复制日志中的SQL,使用explain获取执行计划
从输出可以看出使用了覆盖索引。
第二个SQL是根据id主键查询详细信息,如下:
explain
SELECT id,
user_id,
serve_type_id,
serve_type_name,
serve_item_id,
serve_item_name,
serve_item_img,
unit,
serve_id,
orders_status,
pay_status,
refund_status,
price,
pur_num,
total_amount,
real_pay_amount,
discount_amount,
city_code,
serve_address,
contacts_phone,
contacts_name,
serve_start_time,
lon,
lat,
pay_time,
evaluation_time,
display,
sort_by,
create_time,
update_time,
trading_order_no,
transaction_id,
refund_no,
refund_id,
trading_channel
FROM orders_0
WHERE (id IN (2311030000000000029, 2311020000000000028, 2311020000000000027, 2311020000000000026, 2311020000000000023, 2311020000000000025, 2311020000000000024, 2311020000000000022, 2311020000000000021, 2310280000000000005))
输出:
[
{
"id": 1,
"select_type": "SIMPLE",
"table": "orders_0",
"partitions": null,
"type": "ALL",
"possible_keys": "PRIMARY",
"key": null,
"key_len": null,
"ref": null,
"rows": 10,
"filtered": 100,
"Extra": "Using where"
}
]
从key、key_len可以看出并没有使用索引,possible_keys显示可能会使用主键索引(聚集索引),但最终没有使用索引。
这是因为MySQL器判断上边的SQL进行全表扫描要比使用索引效率高。
如果我们去掉一些id IN ()中的参数,比如:我们只留三个ID,如下:
explain
SELECT id,
user_id,
serve_type_id,
serve_type_name,
serve_item_id,
serve_item_name,
serve_item_img,
unit,
serve_id,
orders_status,
pay_status,
refund_status,
price,
pur_num,
total_amount,
real_pay_amount,
discount_amount,
city_code,
serve_address,
contacts_phone,
contacts_name,
serve_start_time,
lon,
lat,
pay_time,
evaluation_time,
display,
sort_by,
create_time,
update_time,
trading_order_no,
transaction_id,
refund_no,
refund_id,
trading_channel
FROM orders_0
WHERE (id IN (2311030000000000029, 2311020000000000028, 2311020000000000027))
输出:
[
{
"id": 1,
"select_type": "SIMPLE",
"table": "orders_0",
"partitions": null,
"type": "range",
"possible_keys": "PRIMARY",
"key": "PRIMARY",
"key_len": "8",
"ref": null,
"rows": 3,
"filtered": 100,
"Extra": "Using where"
}
]
从输出可以看出此时使用了聚集索引。
- 查询待支付订单
参考上边的方法自行测试。
- 向下滚动屏幕查询出现重复记录
作为今天的作业进行bug修复。
3)小结
如何知道一个SQL有没有使用索引?
4 冷热分离
4.1 冷热分离方案
目标:理解冷热分离的方案。
1)冷热分离需求
随着时间的推移,订单数据会逐渐增加,虽然对订单数据库进行分库分表,但是考虑用户对已完成的历史订单的操作热度远远低于未完成订单,为了提高系统的性能我们对订单数据进行冷热分离。
冷数据是指那些很少被访问或者对访问延迟要求较低的数据,而热数据则是经常被访问、对访问延迟要求高的数据。
订单的冷热分离是指根据订单的特性和需求,将订单数据划分为冷数据和热数据,以便更有效地管理和优化数据存储、检索和处理的方式。这种分离有助于提高系统的性能和降低存储成本
根据需求,订单完成15日后将不允许对订单进行取消,我们可以将完成超过15日的订单归档到历史订单库,其它订单数据在订单数据库。订单数据库存储的是热数据,历史订单数据库存储冷数据。
暂时无法在飞书文档外展示此内容
2)历史数据库选型
历史订单(冷数据)存储到哪里呢?
通常通过以下方式存储冷数据:
-
将冷数据从主要的数据库中归档到较为廉价的存储介质,例如使用固态硬盘(SSD)存储热数据,而将冷数据存储在传统磁盘或云存储中,也可以使用低成本的云存储服务。
-
使用数据库分区或分片技术,将冷数据和热数据存储在不同的物理或逻辑分区,
-
使用云服务进行存储,比如:阿里云的对象存储OSS,腾讯云对象存储COS,这些都是比较廉价的。
-
如果对冷数据有分析需求就需要将冷数据存储到HDFS文件系统中,方便大数据系统进行分析。
-
将冷数据存储到分布式数据库中。
现在市面上也有很多的分布式数据库,分布式数据库是指将数据库系统中的数据存储和处理分布在多个计算机节点上,这些节点可以位于不同的物理位置或在同一物理位置上。分布式数据库旨在通过分布数据和查询负载,提高系统的可伸缩性、性能和容错性。
常见的分布式数据库:
NoSQL数据库:Apache Cassandra、HBase、MongoDB。
还有一些国内常用的分布式数据库:
阿里云 PolarDB:
阿里云的PolarDB是一种支持MySQL和PostgreSQL的分布式关系型数据库服务。它具有高性能、可伸缩性和自动容灾的特性。
腾讯云 TDSQL-C(TiDB):
TDSQL-C是腾讯云推出的一种云原生分布式数据库服务,基于TiDB开源项目,支持水平扩展、强一致性和分布式事务。
TiDB是一个开源的分布式数据库,它支持SQL语法,并且兼容MySQL协议,同时支持事务处理(OLTP)和分析处理(OLAP)的工作负载。
这么多数据库该如何选择呢?
冷数据是不常用的数据,首先基于满足需求为前提考虑用廉价的成本去实现。
我们的订单数据,按50万用户规模估算订单数据量有多少?
AI: 我们的电商项目有50万用户规模,请估算出订单数据量的规模
估算一个电商项目的订单数据量,我们需要考虑几个因素,包括但不限于用户的活跃度、平均每个用户的订单频率、订单的平均大小(包含多少商品项)、以及数据存储的需求(比如保留多久的历史记录)。这里给出一个大致的估算方法:
假设条件
用户活跃度:假设50万用户中有20%是活跃用户(这个比例可以根据实际情况调整)。
订单频率:假设每个活跃用户每月至少下一次订单。
订单大小:假设每笔订单平均包含3个商品项。
存储需求:假设需要保留一年的历史记录。
计算
活跃用户数:50万 * 20% = 10万
每月订单数:10万 * 1次/月 = 10万
每年订单数:10万 * 12个月 = 120万
订单总大小:120万 * 3商品项/订单 = 360万商品项
数据量估算
为了估算订单数据量,我们需要知道每个订单数据条目大约会占用多少空间。一个订单可能包含以下信息:
订单ID
用户ID
商品列表(每个商品的ID、数量、价格等)
收货地址
订单状态
创建时间和更新时间
假设每个订单数据条目大约需要占用1KB的空间(实际大小可能会根据具体字段和数据类型有所不同),那么:
- 每年产生的新数据量约为:360万 * 1KB ≈ 3.5GB
如果每年有20%的用户增量,五年后的订单量将是第一年订单量的大约两倍多,订单表的数据量在300万以内。
基于年百万量的数据没有必要使用基于大数据平台的分布式数据库,只有当年数据量上千万甚至上亿时才会考虑使用大数据平台。
对于年百万级别的数据量,传统的关系型数据库如MySQL可以通过分库分表等手段来有效应对,使用成熟的、经过验证的技术栈如MySQL,可以简化系统架构,降低运维复杂度和成本。
所以,根据本项目的实际需求我们选用对MySQL分库分表存储历史订单数据。
3)分库分表方案
要确定历史订单数据库的分库分表方案需要先明确统计的需求。
需求1:根据时间区间统计
对历史数据要进行统计分析,最终查询统计报表最多是以年为单位进行查询。
下图中用户只能查询一个年度的统计数据,这个需求在很多银行也存在,查询历史账单不能跨年查询。
统计指标说明:
-
有效订单数:在统计时间内,订单状态为已完成的订单数。(订单被取消、退款、关闭状态均不属于有效订单)
-
取消订单数:在统计时间内,提交订单后被取消订总量
-
关闭订单数:在统计时间内,提交订单后被关闭订总量
-
有效订单总额:在统计时间内,有效订单的总订单交易总额
-
实付单均价:在统计时间内,平均每单实际支付额(不包含失效订单)
实付单均价=有效订单总额/有效订单数
需求2:订单趋势图
如果统计时间区间 大于1天,订单趋势显示时间区间内每天的下单总数,查询按天统计表的数据即可。
如果统计时间区间小于等于1天,订单趋势显示当天每小时的下单总数,查询按小时统计表的数据即可。
分库分表方案
对于统计分析的需求清楚后当前需要做的是分析存储统计原始数据的问题,暂时不考虑统计实现问题,先把要统计的原始数据存储到历史订单库。
5年内的年订单规模在300万内,我们可以每年创建一个历史订单表,也就是按年进行分表。
如何使用ShardingSphere-JDBC实现按年分表?
大家思考这个问题并稍后尝试实现。
3)冷热分离方案
冷热分离流程
如何进行冷热分离?
本项目将完成15日的订单迁移到历史订单数据库,通过Canal+MQ技术将订单(完成、取消、关闭状态)迁移到历史订单数据库,在历史订单服务对订单数据进行统计分析。
方案如下:
暂时无法在飞书文档外展示此内容
流程如下:
订单管理服务:
-
当订单完成,取消、关闭时将订单信息写入同步表。
-
定时任务删除同步表已迁移的数据。
Canal+MQ:
-
通过Canal+MQ将同步表的订单数据同步到历史订单数据库的同步表中,具体过程如下:
Canal读取binlog将写入同步表的数据写入MQ。
历史订单服务监听MQ,获得同步的订单数据,写入历史订单数据库的同步表中。
历史订单服务:
-
启动定时任务,每天凌晨将昨天从0点到昨天24点之间完成15日后的订单信息从同步表迁移到历史订单表。
-
每次迁移完成将已经迁移完成的历史订单从同步表删除。
数据流
暂时无法在飞书文档外展示此内容
4)小结
订单的冷热分离是怎么做的?
4.2 订单数据冷热分离
4.2.1 订单同步
根据冷热分离方案,首先将完成、取消、关闭的订单同步到历史订单库。
本节目标:将完成、取消、关闭的订单同步到历史订单数据库。
1)创建历史订单数据库
创建jzo2o-orders-history数据库,导入jzo2o-orders-history.sql脚本:
history_orders:用于存储历史订单数据。
history_orders_serve: 用于存储历史服务单数据。
history_orders_serve_sync:用于存储待迁移的已完成的服务单数据。
history_orders_sync: 用于存储待迁移的订单数据(包括已完成,取消、关闭的订单)。
stat_day:存储按天统计数据,统计分析模块使用
stat_hour:存储按小时统计数据,统计分析模块使用
2)订单同步
根据冷热分离方案,当订单完成,取消、关闭时将订单信息写入订单数据库的同步表,再通过Canal+MQ同步到历史订单数据库的同步表,Canal+MQ同步的代码我们之前做过,这里我们阅读代码理解订单同步的过程。
在订单数据库jzo2o-orders-0、jzo2o-orders-1、jzo2o-orders-2 存在订单同步表和服务单的同步表:
- 当订单完成、取消、关闭时向同步表写入记录。
此部分的代码在订单状态机OrderStateMachine 类中实现,阅读下边的代码:
package com.jzo2o.orders.base.config;
import cn.hutool.core.util.*;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.common.utils.ObjectUtils;
import com.jzo2o.orders.base.enums.OrderStatusEnum;
import com.jzo2o.orders.base.model.dto.OrderSnapshotDTO;
import com.jzo2o.orders.base.service.IHistoryOrdersSyncCommonService;
import com.jzo2o.redis.helper.CacheHelper;
import com.jzo2o.statemachine.AbstractStateMachine;
import com.jzo2o.statemachine.persist.StateMachinePersister;
import com.jzo2o.statemachine.snapshot.BizSnapshotService;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import static com.jzo2o.orders.base.constants.RedisConstants.RedisKey.ORDERS;
/**
* 订单状态机
*
* @author itcast
* @create 2023/8/4 11:20
**/
@Component
public class OrderStateMachine extends AbstractStateMachine<OrderSnapshotDTO> {
...
@Resource
private IHistoryOrdersSyncCommonService historyOrdersSyncService;
@Override
protected void postProcessor(OrderSnapshotDTO orderSnapshotDTO) {
...
/***************************完成、关闭、取消订单写历史订单同步表*******************************/
//取出订单的新状态
Integer ordersStatus = orderSnapshotDTO.getOrdersStatus();
if(OrderStatusEnum.FINISHED.getStatus().equals(ordersStatus) ||
OrderStatusEnum.CLOSED.getStatus().equals(ordersStatus) ||
OrderStatusEnum.CANCELED.getStatus().equals(ordersStatus) ){
historyOrdersSyncService.writeHistorySync(orderSnapshotDTO.getId());
}
}
通过historyOrdersSyncService.writeHistorySync(orderSnapshotDTO.getId());方法将订单数据同步到上边两张同步表当中。
阅读此方法的源代码,在历史订单同步表的sort_time字段中记录订单完成后15天的时间点,如下:
// 排序时间(15天后数据无法再次修改,并迁移到历史订单中)
historyOrdersSync.setSortTime(orders.getUpdateTime().plusDays(15));
- Canal+MQ将同步表数据同步到历史订单数据库的同步表
首先进入RabbitMQ,配置exchange.canal-jzo2o交换机绑定下边红框的队列。
找到历史订单服务中的数据同步类:
代码如下:
package com.jzo2o.orders.history.handler;
import com.jzo2o.canal.listeners.AbstractCanalRabbitMqMsgListener;
import com.jzo2o.orders.history.model.domain.HistoryOrdersSync;
import com.jzo2o.orders.history.service.IHistoryOrdersSyncService;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
@Component
public class HistoryOrdersSyncHandler extends AbstractCanalRabbitMqMsgListener<HistoryOrdersSync> {
@Resource
private IHistoryOrdersSyncService historyOrdersSyncService;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-orders-history", durable = "true"),
exchange = @Exchange(name = "exchange.canal-jzo2o", type = ExchangeTypes.TOPIC),
key = "canal-mq-jzo2o-orders-history"),
concurrency = "1"
)
public void onMessage(Message message) throws Exception {
parseMsg(message);
}
@Override
public void batchSave(List<HistoryOrdersSync> historyOrdersSyncs) {
historyOrdersSyncService.saveOrUpdateBatch(historyOrdersSyncs);
}
@Override
public void batchDelete(List<Long> ids) {
}
}
通过historyOrdersSyncService.saveOrUpdateBatch(historyOrdersSyncs);将订单数据写入历史订单数据库的同步表history_orders_sync中。
服务单同步类HistoryOrdersServeSyncHandler请自行查询源码。
3)测试订单同步
目前我们还没有开发派单功能所以无法完成一个订单,我们只能通过取消订单功能将订单取消后添加到同步表中。
启动canal:
首先修改配置文件:vi canal.properties ,内容如下:
##################################################
######### RabbitMQ #############
##################################################
rabbitmq.host = 192.168.101.68
rabbitmq.virtual.host = /xzb
rabbitmq.exchange = exchange.canal-jzo2o
rabbitmq.username = hmall
rabbitmq.password = 123
rabbitmq.deliveryMode = 2
修改配置文件:
canal.instance.filter.regex=jzo2o-orders-1\\.orders_dispatch,jzo2o-orders-1\\.orders_seize,jzo2o-foundations\\.serve_sync,jzo2o-customer-all\\.serve_provider_sync,jzo2o-orders-1\\.serve_provider_sync,jzo2o-orders-1\\.history_orders_sync,jzo2o-orders-1\\.history_orders_serve_sync,jzo2o-market\\.activity
canal.mq.dynamicTopic=canal-mq-jzo2o-orders-dispatch:jzo2o-orders-1\\.orders_dispatch,canal-mq-jzo2o-orders-seize:jzo2o-orders-1\\.orders_seize,canal-mq-jzo2o-foundations:jzo2o-foundations\\.serve_sync,canal-mq-jzo2o-customer-provider:jzo2o-customer-all\\.serve_provider_sync,canal-mq-jzo2o-orders-provider:jzo2o-orders-1\\.serve_provider_sync,canal-mq-jzo2o-orders-serve-history:jzo2o-orders-1\\.history_orders_serve_sync,canal-mq-jzo2o-orders-history:jzo2o-orders-1\\.history_orders_sync,canal-mq-jzo2o-market-resource:jzo2o-market\\.activity
屏蔽nacos下jzo2o-orders-history.yaml中的内容,如下图:
测试流程如下:
启动网关
启动jzo2o-customer
启动jzo2o-fundations
启动jzo2o-publics
启动jzo2o-orders-manager
启动jzo2o-orders-history
启动服务端(前端工程)
用户下单
用户取消订单
取消订单后向同步表添加成功:
通过canal将完成订单同步到历史库
测试时注意:保证Canal工作正常。
数据不同步的问题参考:“配置ES索引同步环境v1.0” 进行处理。
4) 小结
本节将完成、取消、关闭的订单使用Canal+MQ同步到历史订单数据库,流程如下:
-
订单完成、取消、关闭后在写入订单同步表。
-
Canal读取同步表的binlog,解析数据发送至MQ
-
历史订单服务监听MQ,获取到订单信息后写入同步表(history_orders_sync和history_orders_serve_sync表)。
4.2.2 订单冷热分离
根据需求,订单完成15日后迁移到历史订单表和历史服务单表,在历史订单同步表的sort_time字段中记录订单完成后15天的时间点 ,所以根据sort_time字段查询待迁移的数据,迁移任务是每天凌晨将昨天达到订单完成15天的数据迁移到历史订单表。
1)定义mapper
- 首先编写SQL查询出未迁移的记录,即查询昨天到达订单完成15天的且未迁移的数据。
select hos.*
from history_orders_sync hos
LEFT JOIN history_orders ho on hos.id = ho.id
where hos.sort_time >= '2024-12-05 0:0:0'
and hos.sort_time <= '2024-12-05 23:59:59'
and ho.id is null
limit 0,1000
- 定义mapper
编写mapper映射文件:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jzo2o.orders.history.mapper.HistoryOrdersMapper">
<select id="migrate" parameterType="java.util.Map" resultType="com.jzo2o.orders.history.model.domain.HistoryOrders">
select distinct hos.*
from history_orders_sync hos
LEFT JOIN history_orders ho on hos.id=ho.id
where <![CDATA[ hos.sort_time >= #{yesterDayStartTime}]]> and <![CDATA[ hos.sort_time <= #{yesterDayEndTime} ]]>
and ho.id is null
limit #{offset},#{perNum}
</select>
</mapper>
定义mapper接口:
package com.jzo2o.orders.history.mapper;
import com.jzo2o.orders.history.model.domain.HistoryOrders;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
/**
* <p>
* 订单表 Mapper 接口
* </p>
*
* @author itcast
* @since 2023-09-11
*/
public interface HistoryOrdersMapper extends BaseMapper<HistoryOrders> {
List<HistoryOrders> migrate(@Param("yesterDayStartTime") LocalDateTime yesterDayStartTime,
@Param("yesterDayEndTime") LocalDateTime yesterDayEndTime,
@Param("offset") Integer offset,
@Param("perNum") Integer perNum);
}
2)定义service
package com.jzo2o.orders.history.service;
import com.jzo2o.common.model.PageResult;
import com.jzo2o.orders.history.model.domain.HistoryOrders;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jzo2o.orders.history.model.dto.request.HistoryOrdersListQueryReqDTO;
import com.jzo2o.orders.history.model.dto.request.HistoryOrdersPageQueryReqDTO;
import com.jzo2o.orders.history.model.dto.response.HistoryOrdersDetailResDTO;
import com.jzo2o.orders.history.model.dto.response.HistoryOrdersListResDTO;
import com.jzo2o.orders.history.model.dto.response.HistoryOrdersPageResDTO;
import java.util.List;
/**
* <p>
* 订单表 服务类
* </p>
*
* @author itcast
* @since 2023-09-11
*/
public interface IHistoryOrdersService extends IService<HistoryOrders> {
/**
* 迁移历史订单
*/
void migrate();
/**
* 删除已经迁移完成的订单
*/
void deleteMigrated();
...
实现类:
@Override
public void migrate() {
log.debug("历史订单迁移开始...");
// 查询时间开始坐标
int offset = 0;
int perNum = 1000;
// 昨天开始时间
LocalDateTime yesterDayStartTime = DateUtils.getDayStartTime(DateUtils.now().minusDays(1));
// 昨天结束时间
LocalDateTime yesterDayEndTime = DateUtils.getDayEndTime(DateUtils.now().minusDays(1));
// 统计迁移数据数量
Long total = historyOrdersSyncService.countBySortTime(yesterDayStartTime, yesterDayEndTime);
if(total <= 0){
return;
}
// 分批次迁移
while (offset < total) {
List<HistoryOrders> migrate = baseMapper.migrate(yesterDayStartTime, yesterDayEndTime, offset, perNum);
saveBatch(migrate);
offset += perNum;
}
log.debug("历史订单迁移结束。");
}
@Override
public void deleteMigrated() {
// 昨天开始时间
LocalDateTime yesterDayStartTime = DateUtils.getDayStartTime(DateUtils.now().minusDays(1));
// 昨天结束时间
LocalDateTime yesterDayEndTime = DateUtils.getDayEndTime(DateUtils.now().minusDays(1));
// 1.校验是否可以删除已迁移订单
// 查询即将删除的数据数量
Long totalOfDelete = historyOrdersSyncService.countBySortTime(yesterDayStartTime, yesterDayEndTime);
if(totalOfDelete <= 0) {
log.debug("无迁移的订单数据需要删除");
return;
}
// 查询已经迁移的数量
Long totalMigrated = lambdaQuery().between(HistoryOrders::getSortTime, yesterDayStartTime, yesterDayEndTime)
.count();
if (NumberUtils.null2Zero(totalMigrated) <= 0 || totalOfDelete > totalMigrated) {
log.error("订单未完全迁移,同步数据删除失败");
return;
}
// 2.删除已经迁移数据
historyOrdersSyncService.deleteBySortTime(yesterDayStartTime, yesterDayEndTime);
}
3)定时任务
阅读下边的代码:
package com.jzo2o.orders.history.handler;
import com.jzo2o.orders.history.service.IHistoryOrdersServeService;
import com.jzo2o.orders.history.service.IHistoryOrdersService;
import com.jzo2o.orders.history.service.IStatDayService;
import com.jzo2o.orders.history.service.IStatHourService;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
public class XxlJobHandler {
/**
* 迁移HistoryOrdersSync同步表的数据到HistoryOrders历史订单表
* 迁移HistoryOrdersServeSync同步表的数据到HistoryOrdersServe历史订单表
* 规则:
* 每天凌晨执行,迁移截止到昨日已完成15天的订单到历史订单
*
*/
@XxlJob("migrateHistoryOrders")
public void migrateHistoryOrders(){
//迁移HistoryOrdersSync同步表的数据到HistoryOrders历史订单表
historyOrdersService.migrate();
//删除迁移完成的数据
historyOrdersService.deleteMigrated();
}
...
4)测试订单冷热分离
添加执行器:
添加任务并启动任务:
订单完成15个工作日后迁移到历史订单。
在测试时修改history_orders_sync和history_orders_serve_sync中sort_time字段值小于等于昨天日期。
预期结果:
数据由 history_orders_sync和history_orders_serve_sync迁移到history_orders和history_orders_serve表。
5 统计模块
5.1 订单统计方案
1) 经营看板
本项目在运营端工作台页面展示系统经营看板,内容包括订单分析、用户分析等,如下图:
经营看板是一种用于实时监控关键业务指标的工具,它不仅帮助团队保持敏捷、透明和高效,还促进了团队的协作和创新。
软件中经营看板的应用场景非常多,下图显示了公司销售分析周报:
双11销售统计:
通过学习本节的内容掌握看板功能 的开发方法。
2)需求分析
下边梳理本项目运营端经营看板的功能。
首先选择一个时间区间(不能大于365天),统计在此时间区间内的订单数据,只统计已取消、已关闭、已完成的订单数据。
订单分析内容如下:
-
有效订单数:在统计时间内,订单状态为已完成的订单数。(订单被取消、退款、关闭状态均不属于有效订单)
-
取消订单数:在统计时间内,提交订单后被取消订总量
-
关闭订单数:在统计时间内,提交订单后被关闭订总量
-
有效订单总额:在统计时间内,有效订单的总订单交易总额
-
实付单均价:在统计时间内,平均每单实际支付额(不包含失效订单)
实付单均价=有效订单总额/有效订单数
订单趋势:
订单趋势的显示分两种情况:
-
如果统计时间区间 大于1天,订单趋势显示时间区间内每天的下单总数。
-
如果统计时间区间小于等于1天,订单趋势显示当天每小时的下单总数。
3)技术方案
根据需求,我们要统计一个时间区间的订单总数、订单均价等指标。统计出结果后通过接口将数据返回给前端,前端在界面展示即可。
基于什么平台进行统计分析?
通常统计分析要借助大数据平台进行,流程如下:
暂时无法在飞书文档外展示此内容
说明:
大数据统计系统对数据进行统计,并统计结果存入MySQL。
Java程序根据看板的需求提供查询接口,从统计结果表查询数据。这里使用缓存,将看板需要的数据存入Redis,提高查询性能。
如果数据量不大于千万级别可以基于数据库进行统计。
本项目按年统计,一年的订单数据量是300到400万,并且有单独的历史订单数据,所以基于数据库进行统计。
如何基于数据库进行统计呢?
当用户进入看板页面面向全部数据 进行实时统计其统计速度较慢。
为了提高统计效率可以分层次聚合统计,再基于分层聚合的统计结果进行二次统计。
举例:
我们要统计2023年10月1日 到2023年11月30日的订单总数等指标,我们可以提前按天把每天的订单总数等指标统计出来,当用户去统计2023年10月1日 到2023年11月30日的订单总数时基于按天统计的结果进行二次统计。
按天统计结果:
| 日期 | 订单总数 | 有效订单数 | 取消订单数 | .... | 订单均价 |
| 20231001 | 100 | 100 | 0 | ... | |
| 20231002 | 200 | 199 | 1 | ... | |
| ... |
统计数据的分层次聚合需要根据需求确定统计的维度,例如除了按照时间,还可能按地区、产品类别等进行聚合。
根据需求在订单趋势图上除了显示每天的订单总数以外还会按小时进行显示,所以还需要按小时进行统计。
本项目采用滚动式统计,每次统计近15天的数据(如果数据量大可减少统计时段长度),采用滚动式统计的好处是防止统计任务执行失败漏掉统计数据,如下图:
15日统计1到15日的订单。
16日统计2到16日的订单。
依此类推。
暂时无法在飞书文档外展示此内容
分层聚合的粒度有两种:
按天统计,将统计结果存储至按天统计表。
按小时,将统计结果存储至按小时统计表。
有了分层聚合的统计结果,根据用户需求基于分层聚合的统计结果进行二次统计,其统计效率会大大提高,并且有些需求无需进行二次统计直接查询分层聚合结果表即可。
数据流如下:
暂时无法在飞书文档外展示此内容
4)小结
订单统计分析是怎么实现的?
5.2 开发订单统计
下边实现按天统计订单。
1)定义mapper
首先编写SQL:
select day as id,
day as statTime,
sum(if(orders_status=500,1,0)) effective_order_num,
sum(if(orders_status=600,1,0)) cancel_order_num,
sum(if(orders_status=700,1,0)) close_order_num,
sum(if(orders_status=500,total_amount,0)) effective_order_total_amount
from history_orders_sync where day >= 20231120
GROUP BY day
在HistoryOrdersSyncMapper.xml.xml添加mapper映射,如下内容:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.jzo2o.orders.history.mapper.HistoryOrdersSyncMapper">
<select id="statForDay" parameterType="java.util.Map" resultType="com.jzo2o.orders.history.model.domain.StatDay">
select day as id,
day as statTime,
sum(if(orders_status=500,1,0)) effective_order_num,
sum(if(orders_status=600,1,0)) cancel_order_num,
sum(if(orders_status=700,1,0)) close_order_num,
sum(if(orders_status=500,total_amount,0)) effective_order_total_amount
from history_orders_sync where day >= #{queryDay}
GROUP BY day
</select>
...
定义mapper接口
package com.jzo2o.orders.history.mapper;
import com.jzo2o.orders.history.model.domain.HistoryOrdersSync;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jzo2o.orders.history.model.domain.StatDay;
import com.jzo2o.orders.history.model.domain.StatHour;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* <p>
* 订单统计
* </p>
*
* @author itcast
* @since 2023-09-21
*/
public interface HistoryOrdersSyncMapper extends BaseMapper<HistoryOrdersSync> {
List<StatDay> statForDay(@Param("queryDay") Integer queryDay);
...
2) 定义service
package com.jzo2o.orders.history.service;
public interface IHistoryOrdersSyncService extends IService<HistoryOrdersSync> {
List<StatDay> statForDay(Integer statDay);
...
实现类如下:
package com.jzo2o.orders.history.service.impl;
@Service
@Slf4j
public class HistoryOrdersSyncServiceImpl extends ServiceImpl<HistoryOrdersSyncMapper, HistoryOrdersSync> implements IHistoryOrdersSyncService {
@Override
public List<StatDay> statForDay(Integer statDay) {
//统计15天以内的订单
List<StatDay> statForDay = baseMapper.statForDay(statDay);
if(CollUtils.isEmpty(statForDay)) {
return Collections.emptyList();
}
// 按天统计订单,计算订单总数、均价等信息
List<StatDay> collect = statForDay.stream().peek(sd -> {
// 订单总数
sd.setTotalOrderNum(NumberUtils.add(sd.getEffectiveOrderNum(), sd.getCloseOrderNum(), sd.getCancelOrderNum()).intValue());
// 实付订单均价
if (sd.getEffectiveOrderNum().compareTo(0) == 0) {
sd.setRealPayAveragePrice(BigDecimal.ZERO);
} else {
//RoundingMode.HALF_DOWN 表示四舍五入 向下舍弃,如2.345,保留两位小数为2.34
BigDecimal realPayAveragePrice = sd.getEffectiveOrderTotalAmount().divide(new BigDecimal(sd.getEffectiveOrderNum()), 2, RoundingMode.HALF_DOWN);
sd.setRealPayAveragePrice(realPayAveragePrice);
}
}).collect(Collectors.toList());
return collect;
}
...
3) 定时任务
定义service:
package com.jzo2o.orders.history.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.jzo2o.common.utils.DateUtils;
import com.jzo2o.orders.history.model.domain.StatDay;
import com.jzo2o.orders.history.mapper.StatDayMapper;
import com.jzo2o.orders.history.service.IHistoryOrdersService;
import com.jzo2o.orders.history.service.IHistoryOrdersSyncService;
import com.jzo2o.orders.history.service.IStatDayService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jzo2o.orders.history.mapper.StatDayMapper;
import com.jzo2o.orders.history.model.domain.StatDay;
import com.jzo2o.orders.history.model.domain.StatHour;
import com.jzo2o.orders.history.service.IStatDayService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
/**
* <p>
* 日统计表 服务实现类
* </p>
*
* @author itcast
* @since 2023-09-21
*/
@Service
public class StatDayServiceImpl extends ServiceImpl<StatDayMapper, StatDay> implements IStatDayService {
@Override
public void statAndSaveData() {
// 1.数据统计
// 15天前时间
LocalDateTime statDayLocalDateTime = DateUtils.now().minusDays(15);
long statDayTime = DateUtils.getFormatDate(statDayLocalDateTime, "yyyMMdd");
// 统计数据
List<StatDay> statDays = historyOrdersSyncService.statForDay((int) statDayTime);
if(ObjectUtils.isEmpty(statDays)){
return ;
}
// 2.数据保存至按天统计表
saveOrUpdateBatch(statDays);
}
定义xxl-job定时任务,在历史订单服务通过定时任务完成数据统计。
package com.jzo2o.orders.history.handler;
import com.jzo2o.orders.history.service.IHistoryOrdersServeService;
import com.jzo2o.orders.history.service.IHistoryOrdersService;
import com.jzo2o.orders.history.service.IStatDayService;
import com.jzo2o.orders.history.service.IStatHourService;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
public class XxlJobHandler {
/**
* 按天统计保存15天内的订单数据
* 按小时统计保存15天内的订单数据
*/
@XxlJob("statAndSaveData")
public void statAndSaveDataForDay() {
//按天统计保存15天内的订单数据
statDayService.statAndSaveData();
//按小时统计保存15天内的订单数据
statHourService.statAndSaveData();
}
...
4)测试
添加订单统计任务
保证history_orders_sync和history_orders_serve_sync有要统计的数据,按订单完成时间统计15天以内的订单
统计完成写入stat_day和stat_hour两张表,stat_day存储按天统计数据,stat_hour存储按小时统计数据。
5) 按小时统计
自行阅读按小时统计代码。
6)小结
订单统计分析是怎么实现的?
5.3 开发经营看板
1)需求分析
目前我们完成对订单数据按天、按小时分层聚合,下边根据经营看板的需求进行二次统计,提供查询接口给前端,前端获取数据后在看板界面展示。
需求1:根据时间区间统计
根据时间区间统计以下内容:
-
有效订单数:在统计时间内,订单状态为已完成的订单数。(订单被取消、退款、关闭状态均不属于有效订单)
-
取消订单数:在统计时间内,提交订单后被取消订总量
-
关闭订单数:在统计时间内,提交订单后被关闭订总量
-
有效订单总额:在统计时间内,有效订单的总订单交易总额
-
实付单均价:在统计时间内,平均每单实际支付额(不包含失效订单)
实付单均价=有效订单总额/有效订单数
需求2: 订单趋势图
如果统计时间区间 大于1天,订单趋势显示时间区间内每天的下单总数,查询按天统计表的数据即可。
如果统计时间区间小于等于1天,订单趋势显示当天每小时的下单总数,查询按小时统计表的数据即可。
需求3: 缓存
看板中显示的数据有些需要进行二次统计,为了提高查询性能通常对二次统计的数据结果进行缓存,设置缓存过期时间,通常30分钟以内,根据监控数据变化的实时性去设置,本项目缓存数据为30分钟,当缓存过期重新统计最新的数据在看板展示。
2)阅读代码
- 接口
package com.jzo2o.orders.history.controller.operation;
import com.jzo2o.orders.history.model.dto.response.OperationHomePageResDTO;
import com.jzo2o.orders.history.service.OrdersStatisticsService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.IOException;
import java.time.LocalDateTime;
/**
* 订单统计
*
* @author itcast
* @create 2023/9/21 15:15
**/
@Api(tags = "运营端 - 订单统计相关接口")
@RestController("operationOrdersStatisticsController")
@RequestMapping("/operation/orders-statistics")
public class OrdersStatisticsController {
@Resource
private OrdersStatisticsService ordersStatisticsService;
@GetMapping("/homePage")
@ApiOperation("运营端首页数据")
@ApiImplicitParams({
@ApiImplicitParam(name = "minTime", value = "开始时间", required = true, dataTypeClass = LocalDateTime.class),
@ApiImplicitParam(name = "maxTime", value = "结束时间", required = true, dataTypeClass = LocalDateTime.class)
})
public OperationHomePageResDTO homePage(@RequestParam("minTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime minTime,
@RequestParam("maxTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime maxTime) {
return ordersStatisticsService.homePage(minTime, maxTime);
}
- service方法
service方法上使用spring cache注解对看板上展示的数据进行缓存。
package com.jzo2o.orders.history.service.impl;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.date.DatePattern;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.NumberUtil;
import cn.hutool.core.util.ObjectUtil;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.util.MapUtils;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.fastjson.JSON;
import com.jzo2o.common.expcetions.ForbiddenOperationException;
import com.jzo2o.common.utils.BeanUtils;
import com.jzo2o.common.utils.ObjectUtils;
import com.jzo2o.mvc.utils.ResponseUtils;
import com.jzo2o.orders.history.model.domain.StatDay;
import com.jzo2o.orders.history.model.domain.StatHour;
import com.jzo2o.orders.history.model.dto.excel.AggregationStatisticsData;
import com.jzo2o.orders.history.model.dto.excel.ExcelMonthData;
import com.jzo2o.orders.history.model.dto.excel.MonthElement;
import com.jzo2o.orders.history.model.dto.excel.StatisticsData;
import com.jzo2o.orders.history.model.dto.response.OperationHomePageResDTO;
import com.jzo2o.orders.history.service.IStatDayService;
import com.jzo2o.orders.history.service.IStatHourService;
import com.jzo2o.orders.history.service.OrdersStatisticsService;
import com.jzo2o.orders.history.utils.EasyExcelUtil;
import org.apache.poi.ss.formula.functions.T;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static com.jzo2o.mvc.constants.HeaderConstants.BODY_PROCESSED;
/**
* 订单统计服务层
*
* @author itcast
* @create 2023/9/21 15:19
**/
@Service
public class OrdersStatisticsServiceImpl implements OrdersStatisticsService {
/**
* 运营端首页数据
*
* @param minTime 开始时间
* @param maxTime 结束时间
* @return 首页数据
*/
@Override
@Cacheable(value = "JZ_CACHE", cacheManager = "cacheManager30Minutes")
public OperationHomePageResDTO homePage(LocalDateTime minTime, LocalDateTime maxTime) {
//校验查询时间
if (LocalDateTimeUtil.between(minTime, maxTime, ChronoUnit.DAYS) > 365) {
throw new ForbiddenOperationException("查询时间区间不能超过一年");
}
//如果查询日期是同一天,则按小时查询折线图数据
if (LocalDateTimeUtil.beginOfDay(maxTime).equals(minTime)) {
return getHourOrdersStatistics(minTime);
} else {
//如果查询日期不是同一天,则按日查询折线图数据
return getDayOrdersStatistics(minTime, maxTime);
}
}
/**
* 按日统计数据
*
* @param minTime 最小时间
* @param maxTime 最大时间
* @return 统计数据
*/
private OperationHomePageResDTO getDayOrdersStatistics(LocalDateTime minTime, LocalDateTime maxTime) {
//定义要返回的对象
OperationHomePageResDTO operationHomePageResDTO = OperationHomePageResDTO.defaultInstance();
//日期格式化,格式:yyyyMMdd
String minTimeDayStr = LocalDateTimeUtil.format(minTime, DatePattern.PURE_DATE_PATTERN);
String maxTimeDayStr = LocalDateTimeUtil.format(maxTime, DatePattern.PURE_DATE_PATTERN);
//根据日期区间聚合统计数据
StatDay statDay = statDayService.aggregationByIdRange(Long.valueOf(minTimeDayStr), Long.valueOf(maxTimeDayStr));
//将statDay拷贝到operationHomePageResDTO
operationHomePageResDTO = BeanUtils.copyIgnoreNull(BeanUtil.toBean(statDay, OperationHomePageResDTO.class), operationHomePageResDTO, OperationHomePageResDTO.class);
//根据日期区间查询按日统计数据
List<StatDay> statDayList = statDayService.queryListByIdRange(Long.valueOf(minTimeDayStr), Long.valueOf(maxTimeDayStr));
//将statDayList转为map<趋势图横坐标,订单总数>
Map<String, Integer> ordersCountMap = statDayList.stream().collect(Collectors.toMap(s -> dateFormatter(s.getId()), StatDay::getTotalOrderNum));
//趋势图上全部点
List<OperationHomePageResDTO.OrdersCount> ordersCountsDef = OperationHomePageResDTO.defaultDayOrdersTrend(minTime, maxTime);
//遍历ordersCountsDef,将统计出来的ordersCountMap覆盖ordersCountsDef中的数据
ordersCountsDef.stream().forEach(v->{
if (ObjectUtil.isNotEmpty(ordersCountMap.get(v.getDateTime()))) {
v.setCount(ordersCountMap.get(v.getDateTime()));
}
});
//将ordersCountsDef放入operationHomePageResDTO
operationHomePageResDTO.setOrdersTrend(ordersCountsDef);
return operationHomePageResDTO;
}
/**
* 按小时统计数据
*
* @param minTime 开始时间
* @return 统计数据
*/
private OperationHomePageResDTO getHourOrdersStatistics(LocalDateTime minTime) {
//定义要返回的对象
OperationHomePageResDTO operationHomePageResDTO = OperationHomePageResDTO.defaultInstance();
//获取当前日期,格式:yyyyMMdd
String minTimeDayStr = LocalDateTimeUtil.format(minTime, DatePattern.PURE_DATE_PATTERN);
//查询该日期的统计数据
StatDay statDay = statDayService.getById(Long.valueOf(minTimeDayStr));
//趋势图上全部点
List<OperationHomePageResDTO.OrdersCount> ordersCountsDef = OperationHomePageResDTO.defaultHourOrdersTrend();
if (null == statDay) {
operationHomePageResDTO.setOrdersTrend(ordersCountsDef);
return operationHomePageResDTO;
}
//如果统计数据不为空,拷贝数据
operationHomePageResDTO = BeanUtil.toBean(statDay, OperationHomePageResDTO.class);
//根据时间区间查询小时统计数据,并转换为map结构,key为小时,value为订单数量
List<StatHour> statHourList = statHourService.queryListByIdRange(Long.valueOf(minTimeDayStr + MIN_HOUR), Long.valueOf(minTimeDayStr + MAX_HOUR));
//将statHourList转map
Map<String, Integer> ordersCountMap = statHourList.stream().collect(Collectors.toMap(s -> String.format("%02d",s.getId() % 100), StatHour::getTotalOrderNum));
//遍历ordersCountsDef,将统计出来的ordersCountMap覆盖ordersCountsDef中的数据
ordersCountsDef.stream().forEach(v->{
if (ObjectUtil.isNotEmpty(ordersCountMap.get(v.getDateTime()))) {
v.setCount(ordersCountMap.get(v.getDateTime()));
}
});
//组装订单数趋势,返回结果
operationHomePageResDTO.setOrdersTrend(ordersCountsDef);
return operationHomePageResDTO;
}
...
3)测试
首先完成订单按天、按小时统计测试。
然后进入运营端
通过工作台查看统计数据
4)小结
经营看板功能怎么实现?
5.4 订单统计结果导出
1) 需求分析
在经营看板界面可将订单趋势图的数据导出Excel
点击“导出明细”,导出Excel。
示例:
2023-10-20~2023-11-20 全国经营分析统计.xlsx
示例2:
2) 接口设计
package com.jzo2o.orders.history.controller.operation;
import com.jzo2o.orders.history.model.dto.response.OperationHomePageResDTO;
import com.jzo2o.orders.history.service.OrdersStatisticsService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.io.IOException;
import java.time.LocalDateTime;
/**
* 订单统计
*
* @author itcast
* @create 2023/9/21 15:15
**/
@Api(tags = "运营端 - 订单统计相关接口")
@RestController("operationOrdersStatisticsController")
@RequestMapping("/operation/orders-statistics")
public class OrdersStatisticsController {
@Resource
private OrdersStatisticsService ordersStatisticsService;
/**
* 文件下载并且失败的时候返回json(默认失败了会返回一个有部分数据的Excel)
*
* @since 2.1.1
*/
@GetMapping("downloadStatistics")
@ApiOperation("导出统计数据")
@ApiImplicitParams({
@ApiImplicitParam(name = "minTime", value = "开始时间", required = true, dataTypeClass = LocalDateTime.class),
@ApiImplicitParam(name = "maxTime", value = "结束时间", required = true, dataTypeClass = LocalDateTime.class)
})
public void downloadStatistics(@RequestParam("minTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime minTime,
@RequestParam("maxTime") @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime maxTime) throws IOException {
ordersStatisticsService.downloadStatistics(minTime, maxTime);
}
}
3)统计结果导出
理解需求并完成订单统计结果导出功能。
完成开发后进行测试:
首先完成订单按天、按小时统计测试。
然后进入运营端
通过工作台查看统计数据,点击“导出明细”
4)小结
数据导出功能如何实现?
数据导入功能如何实现?
作业
订单查询优化bug修复
用户端订单列表采用滚动查询,向下滚动屏幕查询出现重复记录,可通过前端跟踪。
请大家修复这个问题,先重现这个问题,再排查问题,并进行修复。
历史订单实现按年分表。
提示
首先在历史订单微服务添加依赖:
<dependency>
<groupId>com.jzo2o</groupId>
<artifactId>jzo2o-shardingsphere-jdbc</artifactId>
</dependency>
不分库所以不用设置分库策略,定义一个数据源即可
按年分表要确定分表键是什么,分表策略的表达式是什么
提前创建好2024、2025两个年份的历史订单表和历史服务单表,其它表都为全局表。
测试时注意修改数据库的连接配置,如下:
测试时通过手动修改订单数据库中历史订单同步表的数据,使其同步到历史订单数据库的同步表中。
然后通过定时任务进行数据迁移,观察是否迁移到history_orders_2024表或history_orders_2025表中。
学习目标:
能够说出秒杀抢购业务常用的技术
能够说出活动查询的缓存是怎么做的
能够实现活动定时预热程序
能够实现活动查询接口
能够说出解决超卖问题的常用技术方案
能够说出本项目保证Redis原子操作的方案
能够说出抢券业务的整体方案
能够完成抢券接口开发
能够说出抢券结果异步同步的方案
能够开发抢券结果异步同步功能
能够说出家政项目抢单模块的设计方案
能够完成抢单接口开发
1 秒杀抢购业务介绍
1)秒杀抢购业务特点
秒杀抢购是电商平台的一种业务模式,它可以聚焦流量和吸引注意力,通过秒杀活动长时间吸引人群,最终吸引用户下单。
秒杀抢购业务有哪些特点呢?
时间限制: 秒杀抢购活动通常在特定的时间段内进行,例如1小时或更短的时间。在这个时间段内,消费者可以购买特定商品或服务,通常是限量销售。
限量销售: 秒杀抢购商品通常数量有限,销售数量是提前确定的。一旦库存售罄,抢购活动就结束,未能购买的消费者需要等待下一次活动。
价格优惠: 秒杀抢购商品通常以折扣价格销售,价格较平时低廉。这种价格优势是吸引消费者参与抢购的重要因素。
高并发和服务器压力: 抢购开始时大量用户会同时访问在线商城,导致网站服务器承受巨大压力。因此,网站的服务器和网络基础设施需要具备高并发处理能力,以应对瞬时大量的用户请求。
技术要求高: 秒杀抢购业务对技术要求非常高,包括网站性能优化、数据库优化、缓存技术、负载均衡等方面的技术应用。
如今秒杀抢购不仅应用在电商平台,随着互联网用户的增加在餐饮、旅游、娱乐等很多领域都有应用,比如:12306抢票、抢优惠券、骑手抢单、酒店预定等。
2)常用技术方案
实现秒杀抢购业务会用到哪些技术呢?可以参考行业上一些成熟的解决方案。
- 缓存方案
使用缓存技术(如Redis)来存储热点数据,例如商品信息和库存数量。这样可以减轻数据库的压力,提高读取数据的速度。
- 异步处理方案
当用户成功秒杀后,将抢购信息发送到队列,然后由消费者多线程异步处理订单,减轻系统的实时压力,使用Redis、RabbitMQ等技术都可以实现队列。
- 防止超卖方案
超卖是最终下单购买数量大于库存数量,比如:库存100个用户最终购买了101个,多出这一个就是超卖了,在秒杀抢购业务中这也是需要解决的问题,可以使用分布式锁、Redis等技术都可以防止超卖。
- 限流与防刷方案
使用限流算法(如令牌桶、漏桶算法)来控制请求的并发数,防止服务器被过多请求压垮。可以在服务端使用限流技术,比如:sentinel、nginx、验证码等技术。
- 数据库优化方案
对数据库进行优化,包括索引的设计、SQL语句的优化、数据库连接池的使用等,以提高数据库的查询和更新速度。
- 数据库分库分表方案
在数据库层面进行分库分表,将数据分散存储在不同的数据库实例或表中,提高数据库的读写性能。
- 负载均衡
使用负载均衡技术,例如Nginx、Spring Cloud Gateway等,将请求分发到多个服务器上,增加系统的处理能力。
- CDN加速
CDN(Content DeliveryNetwork)即内容分发网络,CDN用于加速静态资源的访问,将内容分发到CDN节点就近为客户提供服务。
如下图所示,通过CDN用户访问就近节点,提高访问速度。
- 安全性处理
确保系统的安全性,防止SQL注入、XSS攻击(跨站脚本攻击)等,同时在后端实现防刷、验证码等安全措施,保护系统免受恶意攻击。
3)本章目标
通过抢券、抢单两个模块的学习掌握秒杀抢购业务的技术方案。
4)小结
秒杀抢购常用的技术方案有哪些?
2 抢券
2.1 需求分析
用户进行抢券经过三个过程:
1)抢券界面
用户进入抢券界面如下图。
“疯抢中”界面展示进行中还未到结束的优惠券活动,按照开始时间升序排列,不进行分页。
“即将开始”界面是展示待发放的优惠券活动,按照开始时间升序排列,不进行分页。
在抢券界面显示如下信息:
1、优惠券活动名称
2、优惠券满减或折扣信息
3、活动的起止时间
4、是否抢光。
5、活动的状态。
用户点击立即领取进行抢券,以下情况无法抢券:
1、已抢光
“已抢光”表示活动发放的总数量已领取完,该优惠券的领取数量等于发放数量。
2、未到开始时间或已到结束时间
3、用户已领(每个用户限领一张)
4、活动已撤销
2)进行抢券
优惠券到达发放时间后,用户点击“立即领取”进行抢券。
抢券成功提示“领取成功”,如果领取失败会提示失败信息。
3)我的优惠券列表
用户抢券成功在我的优惠券列表查看。
用户进入【我的】-【优惠券】查看已抢到的优惠券,按抢券时间降序显示当前用户抢到的优惠券。
本查询为滚动查询,向上拖屏幕查询下一屏,一屏显示10条。
如下图:
对优惠券的三个状态说明如下:
未使用:未过有效期的优惠券。
优惠券的有效期:从领取优惠券的时间加上优惠券的使用期限(“使用期限”在优惠券活动管理界面进行设置)。
已使用:已经在订单中使用的优惠券。
已过期:未使用且已过有效期的优惠券,已过期的优惠券将无法使用。
4)小结
根据需求分析抢券模块包括三部分的内容:
活动查询
进入抢券页面查询优惠券活动信息。
抢券
点击抢券按钮进行抢券。
我的优惠券
抢券成功进入我的优惠券页面查询抢到的优惠券。
下边按顺序开发每一部分。
2.2 活动查询
2.2.1 系统设计
1)活动查询分析
下边根据需求分析对抢券界面的活动查询功能进行分析与设计,如下图:
- 活动查询界面显示了哪些数据?
活动信息:
包括两个部分数据:
-
进行中还未到结束的优惠券活动。
-
待开始的优惠券活动。
信息内容包括:
优惠券活动名称
优惠券满减或折扣信息
活动的起止时间
是否抢光(根据库存剩余量判断)
活动的状态。
- 面向高并发如何提高活动查询性能?
此部分信息来源于优惠券活动表,由于抢券页面面向C端用户且请求并发量大,如何在高并发下提高活动查询的性能呢?
如果直接查询数据库无法满足需求并且对数据库造成巨大的压力从而影响其它功能使用数据库,我们可以使用缓存,将优惠券活动信息存入缓存,比如Redis,从Redis查询避免查询数据库。
暂时无法在飞书文档外展示此内容
- 如何保证缓存一致性?
通过定时预热程序保证缓存一致性,抢券页面列出的活动信息属于热点信息,对于热点信息通过定时预热防止缓存击穿,定时预热程序通过定时任务定时执行,定时将活动信息存入Redis。
暂时无法在飞书文档外展示此内容
2)数据流
根据以上分析设计数据流如下:
暂时无法在飞书文档外展示此内容
活动管理:运营人员进行优惠券活动管理,对活动表进行增删改查操作。
活动状态更新任务:根据活动的开始和结束时间更新活动状态。
活动预热任务:定时查询活动表信息存入Redis
抢券查询:从Redis查询活动预热信息。
3)Redis数据结构设计
活动预热任务定时查询活动表信息存入Redis,下边设计活动信息的缓存结构:
根据需求可知在抢券查询界面需要的数据全部来源于活动表,因为要预热的活动信息内容有限,我们可以将要预热的活动信息转为json串存入redis,活动查询程序读取json串也方便进行解析。
活动信息缓存结构设计如下:
redis结构:String类型
key: "ACTIVITY:LIST"
value: 符合条件的优惠券活动列表JSON数据。
过期时间:永不过期
缓存一致性方案:通过预热程序保证缓存一致性
4)如何保证活动状态自动改变
在活动查询界面如何保证活动状态实时改变,当到达活动开始时间活动状态变为“进行中”,在“疯抢中”界面显示,当活动结束在“疯抢中”界面将无法查询到。
关于活动状态的改变,在“优惠券活动管理实战”中完成了通过定时任务自动更新活动的状态,实现了如下需求:
1)对待生效的活动更新为进行中
到达发放开始时间状态改为“进行中”。
2)对待生效及进行中的活动更新为已失效
到达发放结束时间状态改为“已失效”
具体代码如下:
Service接口实现
@Override
public void updateStatus() {
LocalDateTime now = DateUtils.now();
// 1.更新已经进行中的状态
lambdaUpdate()
.set(Activity::getStatus, ActivityStatusEnum.DISTRIBUTING.getStatus())//更新活动状态为进行中
.eq(Activity::getStatus, NO_DISTRIBUTE.getStatus())//检索待生效的活动
.le(Activity::getDistributeStartTime, now)//活动开始时间小于等于当前时间
.gt(Activity::getDistributeEndTime,now)//活动结束时间大于当前时间
.update();
// 2.更新已经结束的
lambdaUpdate()
.set(Activity::getStatus, LOSE_EFFICACY.getStatus())//更新活动状态为已失效
.in(Activity::getStatus, Arrays.asList(DISTRIBUTING.getStatus(), NO_DISTRIBUTE.getStatus()))//检索待生效及进行中的活动
.lt(Activity::getDistributeEndTime, now)//活动结束时间小于当前时间
.update();
}
定时任务方法:
@Component
public class XxlJobHandler {
/**
* 活动状态修改,
* 1.活动进行中状态修改
* 2.活动已失效状态修改
* 1分钟一次
*/
@XxlJob("updateActivityStatus")
public void updateActivitySatus(){
log.info("定时修改活动状态...");
try {
activityService.updateStatus();
} catch (Exception e) {
e.printStackTrace();
}
}
...
通过上边的定时任务无法实现状态的实时改变,每分钟执行一次状态变更理论上存在最多1分钟的状态变更延迟。
如何实现在页面到达活动开始时间立即变更活动状态?
-
在前端进行控制,根据活动开始时间进行倒计时,达到开始时间将活动移到进行中界面。
-
请求后端查询数据,根据当前时间和活动开始、活动结束时间判断活动的状态。
当活动开始时间小于等于当前时间并且结束时间大于当前时间说明活动已经开始并且还没有结束,活动状态为进行中。
当活动结束时间小于当前时间说明活动结束,活动状态为失效。
5)小结
抢券查询的缓存是怎么做的?
如何实现活动状态的自动改变?
- 通过定时任务每几分钟更新活动状态
对于待生效的活动,当活动开始时间小于等于当前时间并且结束时间大于当前时间,将活动状态更新为进行中。
对于待生效和进行中的活动,当活动结束时间小于当前时间,将活动状态更新为失效。
如何实现状态变化的实时性,当到达活动开始时间状态立即变化:
两种方法结合:
-
前端请求后端接口查询活动信息,后端根据活动时间(开始、结束时间)及当前时间得到活动当前准确的状态,并将状态返回给前端。
-
前端进行控制,根据活动开始时间进行倒计时,达到开始时间将活动移到进行中界面。
2.2.2 定时预热程序
1)搭建环境
进入jzo2o-market工程,创建dev_03分支,使用下发的jzo2o-market-dev03.zip代码作为dev_03分钟的基础代码。
2)明确需求
根据需求,预热的活动信息包括两个部分:
进行中还未到结束的优惠券活动。
待开始的优惠券活动。
3) 编写Service方法
下边定义Service接口从数据库查询要预热的活动信息,存入redis。
代码如下:
package com.jzo2o.market.service;
import com.jzo2o.common.model.PageResult;
import com.jzo2o.market.model.domain.Activity;
import com.baomidou.mybatisplus.extension.service.IService;
import com.jzo2o.market.model.dto.request.ActivityQueryForPageReqDTO;
import com.jzo2o.market.model.dto.request.ActivitySaveReqDTO;
import com.jzo2o.market.model.dto.response.ActivityInfoResDTO;
import com.jzo2o.market.model.dto.response.SeizeCouponInfoResDTO;
import java.util.List;
/**
* <p>
* 服务类
* </p>
*
* @author itcast
* @since 2023-09-16
*/
public interface IActivityService extends IService<Activity> {
/**
* 活动预热
*/
void preHeat();
...
Service接口实现:
@Override
public void preHeat() {
//当前时间
LocalDateTime now = DateUtils.now();
//查询即将开始、进行中还未到结束的优惠券活动
/**
select *
from activity t
where t.status in(1,2)
order by t.distribute_start_time
*/
List<Activity> list = lambdaQuery()
.in(Activity::getStatus, Arrays.asList(NO_DISTRIBUTE.getStatus(), DISTRIBUTING.getStatus()))//查询待开始和进行中的
.orderByAsc(Activity::getDistributeStartTime)
.list();
if (CollUtils.isEmpty(list)) {
//防止缓存穿透
list = new ArrayList<>();
}
// 2.数据转换
List<SeizeCouponInfoResDTO> seizeCouponInfoResDTOS = BeanUtils.copyToList(list, SeizeCouponInfoResDTO.class);
String seizeCouponInfoStr = JsonUtils.toJsonStr(seizeCouponInfoResDTOS);
// 3.活动列表写入缓存
redisTemplate.opsForValue().set(ACTIVITY_CACHE_LIST, seizeCouponInfoStr);
}
4) 定义XXL-Job调度方法
下边定义定时任务方法,执行定时预热程序。每小时执行一次。
package com.jzo2o.market.handler;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponService;
import com.jzo2o.redis.annotations.Lock;
import com.jzo2o.redis.constants.RedisSyncQueueConstants;
import com.jzo2o.redis.sync.SyncManager;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import static com.jzo2o.market.constants.RedisConstants.Formatter.*;
import static com.jzo2o.market.constants.RedisConstants.RedisKey.COUPON_SEIZE_SYNC_QUEUE_NAME;
@Component
public class XxlJobHandler {
@Resource
private IActivityService activityService;
/**
* 活动预热,整点预热
*
*/
@XxlJob("activityPreheat")
public void activityPreHeat() {
log.info("优惠券活动定时预热...");
try {
activityService.preHeat();
} catch (Exception e) {
e.printStackTrace();
}
}
...
5) 配置定时任务
下边在XXL-JOB中配置调度方法进行测试:
通常生产中会提前一周去策划活动并在系统中创建活动,所以在生产中每小时执行一次预热程序,开发中为了测试方便5秒执行一次。
6) 预热程序测试
测试流程如下:
首先在运营端创建优惠券活动,创建即将开始的活动
创建成功,活动为待生效
再创建一个活动:
创建成功,进入jzo2o-market数据库,找到活动表activity中该记录,修改活动开始时间为昨天,如下图:
通过活动状态自动更新程序,自动将“平台二周年庆”活动状态改为进行中。
测试通过如下图:
通过定时预热程序即将开始的活动及进行中的活动写入redis:
根据以上数据范围判断存入到redis的活动信息是否正确。
预热成功在redis成功存储上边添加的两个活动:
7)小结
活动定时预热程序是怎么做的?
-
首先说明缓存方案 (参考问题:抢券查询的缓存是怎么做的?)
-
使用xxl-job定时执行活动预热程序,从数据库查询出符合条件的活动信息存储到redis中。
2.2.3 活动查询接口
1)接口分析
根据界面原型分析接口:
界面有两个tab,疯抢中和即将开始,前端传入后端一个参数标记是查询进行中的活动还是即将开始的活动。
后端需要给前端返回以下数据:
-
活动id
-
活动名称
-
优惠券类型,1:满减,2:折扣
-
满减限额,0:表示无门槛,其他值:最低消费金额
-
折扣率,折扣类型的折扣率,例如:8,打8折
-
优惠金额,满减或无门槛的优惠金额
-
发放开始时间
-
发放结束时间
-
活动状态,1:待生效,2:进行中,3:已失效
-
优惠券剩余数量
这些信息在预热的活动信息缓存中都存在,但是有两个字段不够实时:活动状态,优惠券剩余数量。
活动状态:通过定时任务更新活动状态,写入活动表,定时预热程序读出活动信息存储到Redis,由于是通过定时任务更新活动状态、定时预热程序更新活动信息缓存,最终从redis取出的活动状态于实际的活动状态存在延迟,导致实际活动的状态与页面显示的状态不一致。
举例:活动开始时间为8点钟,当前时间已过了8点钟,查看抢券页面活动状态仍然显示“即将开始”。
优惠券剩余数量:在抢券模块会操作此字段,这里暂不考虑此字段。
如何解决活动状态延迟问题?
请求后端查询数据,根据当前时间和活动开始、活动结束时间判断活动的状态。
当活动开始时间小于等于当前时间并且结束时间大于当前时间说明活动已经开始并且还没有结束,活动状态为进行中。
当活动结束时间小于当前时间说明活动结束,活动状态为失效。
2)接口定义
接口名称:用户端抢券列表分页接口
接口路径:GET/market/consumer/activity/list
请求数据类型 application/x-www-form-urlencoded
定义controller方法:
package com.jzo2o.market.controller.consumer;
import com.jzo2o.common.model.PageResult;
import com.jzo2o.market.model.dto.request.ActivityQueryForPageReqDTO;
import com.jzo2o.market.model.dto.request.ActivitySaveReqDTO;
import com.jzo2o.market.model.dto.response.ActivityInfoResDTO;
import com.jzo2o.market.model.dto.response.SeizeCouponInfoResDTO;
import com.jzo2o.market.service.IActivityService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
@RestController("consumerActivityController")
@RequestMapping("/consumer/activity")
@Api(tags = "用户端-活动相关接口")
public class ActivityController {
@Resource
private IActivityService activityService;
@GetMapping("/list")
@ApiOperation("用户端抢券列表分页接口")
@ApiImplicitParams({
@ApiImplicitParam(name = "tabType", value = "页面tab类型,1:疯抢中,2:即将开始", required = true, dataTypeClass = Integer.class)})
public List<SeizeCouponInfoResDTO> queryForPage(@RequestParam(value = "tabType",required = true) Integer tabType) {
return null;
}
}
3)接口实现
- service接口
public interface IActivityService extends IService<Activity> {
/**
* 用户端抢券列表分页查询活动信息
*
* @param tabType 页面类型
* @return
*/
List<SeizeCouponInfoResDTO> queryForListFromCache(Integer tabType);
实现类:
@Override
public List<SeizeCouponInfoResDTO> queryForListFromCache(Integer tabType) {
//从redis查询活动信息
Object seizeCouponInfoStr = redisTemplate.opsForValue().get(ACTIVITY_CACHE_LIST);
if (ObjectUtils.isNull(seizeCouponInfoStr)) {
return CollUtils.emptyList();
}
//将json转为List
List<SeizeCouponInfoResDTO> seizeCouponInfoResDTOS = JsonUtils.toList(seizeCouponInfoStr.toString(), SeizeCouponInfoResDTO.class);
//根据tabType确定要查询的状态
int queryStatus = tabType == TabTypeConstants.SEIZING ? DISTRIBUTING.getStatus() : NO_DISTRIBUTE.getStatus();
//过滤数据,并设置剩余数量、实际状态
List<SeizeCouponInfoResDTO> collect = seizeCouponInfoResDTOS.stream().filter(item -> queryStatus == getStatus(item.getDistributeStartTime(), item.getDistributeEndTime(), item.getStatus()))
.peek(item -> {
//剩余数量
item.setRemainNum(item.getStockNum());
//状态
item.setStatus(queryStatus);
}).collect(Collectors.toList());
return collect;
}
/**
* 获取状态,
* 用于xxl或其他定时任务在高性能要求下无法做到实时状态
*
* @return
*/
private int getStatus(LocalDateTime distributeStartTime, LocalDateTime distributeEndTime, Integer status) {
if (NO_DISTRIBUTE.equals(status) &&
distributeStartTime.isBefore(DateUtils.now()) &&
distributeEndTime.isAfter(DateUtils.now())) {//待生效状态,实际活动已开始
return DISTRIBUTING.getStatus();
}else if(NO_DISTRIBUTE.equals(status) &&
distributeEndTime.isBefore(DateUtils.now())){//待生效状态,实际活动已结束
return LOSE_EFFICACY.getStatus();
}else if (DISTRIBUTING.equals(status) &&
distributeEndTime.isBefore(DateUtils.now())) {//进行中状态,实际活动已结束
return LOSE_EFFICACY.getStatus();
}
return status;
}
- controller
@GetMapping("/list")
@ApiOperation("用户端抢券列表分页接口")
@ApiImplicitParams({
@ApiImplicitParam(name = "tabType", value = "页面tab类型,1:疯抢中,2:即将开始", required = true, dataTypeClass = Integer.class)})
public List<SeizeCouponInfoResDTO> queryForPage(@RequestParam(value = "tabType",required = true) Integer tabType) {
return activityService.queryForListFromCache(tabType);
}
4)活动查询测试
测试流程如下:
启动网关
启动优惠券工程
参考预热程序测试的方法,创建活动信息,启动xxl-job,保证定时预热程序正常执行。
打开小程序,点击优惠券图标。
进入抢券查询界面,通过选择不同的类型查询活动信息,如下图:
下边测试状态实时性:
-
首先停止活动的状态更新任务。
-
手动修改上述记录在活动表的开始或结束时间,使用实际的活动状态与数据库中记录的不一样
预期结果:
通过预热程序将活动提前加入缓存,前端请求查询活动信息,后端程序根据活动时间设定活动准确的活动状态。
示例:
修改上图中“ 双12大促” 活动的开始时间使该活动已开始。
测试效果如下图:
“ 双12大促” 活动在界面上显示“疯抢中”
下边再修改数据库中的库存,设置为0,预期测试效果为“已抢光”,如下图:
5) 小结
通过定时任务更新活动状态,如何解决活动状态实时更新的问题?
-
前端请求后端接口查询活动信息
-
后端接口从redis查询活动信息,并根据活动开始和结束时间判断活动的最新状态
-
最后将活动信息及最新状态返回给前端。
2.3 抢券
2.3.1 解决超卖问题
1)系统需求
在抢券模块中需要实现下边两个需求:
1、提升高并发吞吐量
抢券类似抢购、秒杀等业务场景具有时效性的特点,提前规定好用户在什么时间可以抢购,用户访问集中,这样就会给系统造成高并发,抢券模块在设计时要以提升系统在高并发下的吞吐量为目标,吞吐量表示单位时间内系统处理的总请求数量,吞吐量高意味着系统处理能力强。
衡量系统吞吐量的常用指标有哪些?
QPS(Queries Per Second):
每秒查询数(Queries Per Second),它表示系统在每秒内能够处理的查询或请求的数量,是衡量一个系统处理请求的性能和吞吐量的指标。
计算公式:总请求数/时间窗口大小
示例:
在10秒内处理1万个请求,QPS为10000。每个请求处理的时间越短,QPS越大。
假设:一个网站有10万用户,有2万日活跃用户,并发量是4000,每个用户每秒平均发起2个请求,那么总请求数就是 2*4000,那么QPS就是 8000,如果单机支持2000的qps理论上需要4台服务器。
qps指标是需要根据服务器硬件性能、及具体的业务场景去测试,比如:门户查询数据如果直接走Nginx静态服务器则QPS可以达到上万,如果请求查询Tomcat,并且通过数据库去查询数据库返回,此时QPS会远低于查询Nginx静态服务器的QPS值,如果不走数据库,而是从Redis查询数其QPS也会大大提升。
TPS(Transactions Per Second):
表示系统每秒完成的事务数,与QPS不同,TPS更关注系统的事务处理能力,而不仅仅是单纯的查询或请求,一次事务通常会包括多个请求。在高度事务性的系统中,如在线交易系统、支付系统等,TPS是一个关键指标,用于衡量系统的处理能力。
TPS指标通常会涉及业务处理及数据库存储,在测试时也需要根据服务器硬件性能、及具体的业务场景去测试,拿下单举例:单机支持几十到几百的TPS指标属于正常。
在开发中,对以上性能指标的优化,可通过CDN、缓存、异步处理、数据库优化、多线程、集群、负载均衡等技术去提高系统的吞吐量。当然,再优化也不要忘记系统保护,通过限流技术根据系统的性能指标进行限流保护。
2、解决超卖问题
抢购、秒杀等业务场景还需要解决超卖问题,超卖是最终下单购买数量大于库存数量,比如:库存有100个,用户最终购买了101个,多出这一个就是超卖了,结合抢券业务即用户最终抢到的优惠券总数大于优惠券库存数。
下边先分析超卖问题的解决方案。
2)什么是超卖问题
超卖是最终下单购买数量大于库存数量,比如:库存100个用户最终购买成功了101个,多出这一个就是超卖了,结合抢券业务,用户最终抢到的优惠券总数大于优惠券库存数就出现了超卖问题。
导致超卖问题的原因是什么呢?
下边举例说明超卖问题并分析导致超卖问题的原因。
下图是两个线程更新数据库的库存字段。
线程1:先查询库存为1,判断是否大于0,如果大于则库存减1,最后更新数据库库存字段。
线程2:先查询库存为1,判断是否大于0,如果大于则库存减1,最后更新数据库库存字段。
线程1和线程2查询到的库存都是1,两个线程分别减1得到剩余库存数0,由于线程2并不是基于线程1扣减库存后的值进行扣减,线程2更新库存覆盖了线程1更新的库存值。
上边的例子就出现了超卖的问题。
造成超卖问题的原因是在高并发场景下对库存这个共享资源进行操作存在线程不安全所导致。
暂时无法在飞书文档外展示此内容
3)悲观锁与乐观锁
提到解决线程安全问题大家想到了锁,下边复习下关于锁的基本概念。
jvm提供了很多锁,比如:synchronized、reentrantLock、CAS等,它们都可以解决线程安全问题,synchronized、reentrantLock可以实现悲观锁,CAS可以实现乐观锁,关于这些锁的知识掌握不牢固的一定要自行复习。下边理解悲观锁与乐观锁的概念。
- 悲观锁
悲观锁是一种悲观思想,总认为会有其它线程修改数据,为了保证线程安全所以在操作前总是先加锁,操作完成后释放锁,其它线程只有当锁释放后才可以获取锁继续操作数据。synchronized和ReentrantLock都可以实现悲观锁。
暂时无法在飞书文档外展示此内容
使用悲观锁后原来的多线程并发执行改为了顺序(同步)执行,当线程2去执行时查询到库存发现为0,不满足条件更新库存失败。
- 乐观锁
乐观锁则是一种乐观思想,认为不会有太多线程去并发修改数据,所以谁都可以去执行代码。
Java提供的CAS机制可以实现乐观锁,CAS即Compare And Swap 比较并交换,在修改数据前先比较版本号,如果数据的版本号没有变化说明数据没有修改,此时再去更改数据。
示例如下:
库存数据对应一个版本,库存每次变化则版本号跟着变化,如下:
| 库存 | 版本号 |
| 100 | 1 |
| 99 | 2 |
| ... | ... |
| 1 | 100 |
| 0 | 101 |
线程1修改库存前拿到库存及对应的版本号:1和100。
线程1判断库存如果大于0则将库存减1,准备更新库存。
更新库存时要校验当前库存的版本是否和自己之前拿到的一致,如果版本号为1说明自己在执行的这过程没有其它线程去修改过库存,此时将库存更新为99并将版本号加1为2。
线程2执行和线程1一样的逻辑,线程2去更新库存时发现库存的版本号为2与自己之前拿到的不一致,更新库存失败。
- 结论
悲观锁和乐观锁都是一种解决共享资源的线程安全问题的方法,悲观锁是在读数据时就加锁,如果读比较多则加锁频繁影响性能,相比而言乐观锁性能比悲观锁要好。
4)数据库行锁控制方案
数据库的行级锁可以实现悲观锁也可以实现乐观锁。
- 实现悲观锁(排他锁)
执行select … for update 实现加锁,select … for update 会锁住符合条件的行的数据,如下语句会锁一行的数据
select * from 库存表 where id=? for update
通常此语句放在事务中,开启事务开始时执行此语句获取锁,事务提交或回滚自动锁释放,保证在事务处理过程中没有其它线程去修改数据。
测试:
使用一个线程执行下边的命令:
start transaction;
select ... for update;
COMMIT;
另一个线程修改数据,如果上边不释放锁将无法修改。
高并发场景不推荐使用select … for update方法,同时也可能存在死锁的潜在风险。
- 实现乐观锁
数据库的行级锁也可以实现乐观锁,通用的做法是在表中添加一个version版本字段,在更新时对比版本号,更新成功将版本号加1,SQL为:
update 表名 set 字段=值,version=version+1 where id =? and version =?
针对扣减库存业务扣减库存SQL:
update 库存表 set 库存=库存-2 where 库存>=2 and id =?
多线程执行上边的SQL,假如线程1先执行会添加排他锁,当事务没有结束前其它线程去更新同一条记录会被阻塞,等到线程1更新结束其它线程才可以更新库存。
暂时无法在飞书文档外展示此内容
当执行update后返回影响的记录行数为1表示更新成功即扣减库存成功,返回0表示没有更新记录行,即扣减库存失败。
- 结论
悲观锁在查询时就开始加锁,如果读比较多则加锁频繁影响性能,相比而言乐观锁性能比悲观锁要好。
对于并发不高的场景可以使用数据乐观锁去控制扣减库存,由于抢购业务并发较高且对性能要求也高,如果使用数据库行锁去控制,并发高就会对数据库造成压力,如果进行限流控制并发数又无法满足性能要求,所以对于抢购业务使用数据库行锁进行控制是不适合的。
5) Redis分布式锁方案
数据库乐观锁不适用高并发场景,我们能否将库存数据放在Redis,并且通过JVM锁去控制扣减库存呢?
上边介绍的synchronized、reentrantLock、CAS只控制了JVM本身的线程争抢同一个锁,无法控制多个JVM之间争抢同一个锁。
如下图,有两个JVM进程,每个JVM进程都有一个Lock01锁,这两个JVM进程中的线程1仍然会同时去修改库存:
线程1:先查询库存为1,判断是否大于0,如果大于则库存减1,最后更新Redis库存数据。
线程2:先查询库存为1,判断是否大于0,如果大于则库存减1,最后更新Redis库存数据。
此时就会出现修改库存数据的线程不安全问题。
暂时无法在飞书文档外展示此内容
所以,如果是单机环境下,使用JVM的锁在内存加锁可以解决资源并发访问的线程安全问题。
微服务架构的项目在部署时每个微服务会部署多个实例(JVM),每个实例就是一个JVM,如果要控制多个JVM之间争抢资源需要用到分布式锁,分布式锁是由一个统一的服务提供分布式锁服务,比如:使用redis、数据库都可以实现分布式锁,下边介绍分布式锁控制争抢资源的方法。
如下图:每个JVM中的线程去争抢同一个分布式锁,在扣减库存前先获取分布式锁,拿到锁再扣减库存,执行完释放锁之后其它JVM的线程才可以获取锁继续扣减库存,如下图:
暂时无法在飞书文档外展示此内容
上边的方案将库存放在Redis中避免与数据库交互,很大的提高的了执行效率,在分布式场景下使用分布式锁是一种常用的控制共享资源的方案。
分布式锁需要搭建独立的分布式锁服务(例如Redis、Zookeeper等),每次操作需要远程与分布式锁服务交互获取锁、释放锁,还有没有性能更高的方案呢?
6)Redis原子操作方案
上边使用分布式锁的方案每次操作需要远程与分布式锁服务交互获取锁、释放锁,有没有优化的方法避免申请锁与释放锁的交互呢?
在分布式锁方案中是在java程序中扣减库存最后更新redis库存的值,能否使用redis的decr命令去扣减库存呢?
Redis Decr 命令将 key 中储存的数字值减一,并且具有原子性,Redis中所有命令都具有原子性。
原子性表示该命令在执行过程中是不被中断的,也就实现了多线程去执行decr命令扣减库存是顺序执行的,假如库存原来是100,扣减到0结束,多线程并发执行decr命令不会出现扣减次数超过100次,如下图:
暂时无法在飞书文档外展示此内容
基于这个思想可以对分布式锁方案优化如下:
暂时无法在飞书文档外展示此内容
此方案中没有使用分布式锁,而是基于Redis命令具有原子性的特点实现。
本项目使用Redis原子操作控制超卖问题。
7)小结
解决超卖问题有哪些方案?
什么是悲观锁和乐观锁?
数据库的怎么实现悲观锁和乐观锁?
2.3.2 Redis原子操作方案
在Redis原子操作方案中扣减库存使用decr命令实现,decr命令具有原子性,如果在扣减库存操作中有多个操作 ,那么整体还是原子性吗?如下图:
暂时无法在飞书文档外展示此内容
扣减库存逻辑如下:
1、首先查询库存
2、判断库存大小,如果大于0则扣减库存,否则 直接返回
3、记录抢券成功的记录,用于判断用户不能重复抢券的依据。
4、记录抢券同步的记录,用于后续的异步处理,将抢券结果保存到数据库。
如果上述四步整体不具有原子性仍然没有办法控制超卖问题,所以必须保证1、2、3步逻辑放在一起整体具有原子性。
如何保证多个Redis命令具有原子性呢?本节介绍两个保证Redis多个命令具有原子性的方法。
1)通过 MULTI 事务命令实现
对于redis单个命令都是原子操作,现在要求扣减库存、写入抢券成功队列及写入同步队列保证原子性,多个redis命令如何保证原子性呢?
1、通过 MULTI 事务命令实现
下边的命令执行流程如下:
执行MULTI 标记首先标记一个事务块开始。
然后将要执行的命令加入队列。
将“HSET key1 field1 value2 field2 value2” 命令放入队列中,表示向key1中写入两个hashkey。
将“INCR key2”命令放入队列中,表示对key2自增1。
运行EXEC命令按顺序执行,整体保证原子性。
MULTI
HSET key1 field1 value2 field2 value2
INCR key2
EXEC
测试如下:
测试结果:
2)了解Pipeline与MULTI 的区别
学习过Redis的同学听说过pipline管道命令,pipline也可实现批量执行多个 redis命令,pipline与multi的区别是:
pipeline 是把多个redis指令一起发出去,redis并没有保证这些命令的执行是原子的;multi实现的是将多个命令作为事务块去执行,保证整个操作的原子性。
如果仅是执行多个命令不保证原子性那么使用pipeline 的性能要比multi要高,但是针对本项目要保证多个命令实现原子性的需求那么pipeline 不符合要求。
3)Redis+Lua实现
Lua 是一种强大、高效、轻量级、可嵌入的脚本语言,Lua体积小、启动速度快,从而适合嵌入在别的程序里,Lua可以用于web开发、游戏开发、嵌入式开发等领域。
参考:http://www.lua.org/docs.html,或者去百度搜索Lua中文教程。
对于Lua脚本语法非常容易理解,先不用系统的去学习,先把本项目使用的Lua脚本读懂即可,实际工作中用到时再参考本项目的脚本去写,不会的再查Lua 的语法。
先看一个例子,对上边的例子编写Lua脚本,如下:
local ret = redis.call('hset', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
redis.call('incr', KEYS[2]);
return ret..'';
说明:
KEYS:表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,KEYS[1]表示第一个key,KEYS[2]表示第2个key,依次类推。
ARGV:表示在脚本中所用到的参数,在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类),ARGV[1]表示第一个参数,ARGV[2]表示第二个参数,依次类推。
如何执行上边的Lua脚本呢?
使用EVAL 命令执行Lua脚本。
EVAL是redis的命令本身具有原子性,整个脚本的执行具有原子性。
EVAL script numkeys key [key ...] arg [arg ...]
参数说明:
-
script: 是一段 Lua 5.1 脚本程序。
-
numkeys: 用于指定键名参数的个数。
-
key [key ...]: 从 EVAL 的第三个参数开始算起,表示在脚本中所用到的那些 Redis 键(key),这些键名参数可以在 Lua 中通过全局变量 KEYS 数组,用 1 为基址的形式访问( KEYS[1] , KEYS[2] ,以此类推)。
-
arg [arg ...]: 附加参数,在 Lua 中通过全局变量 ARGV 数组访问,访问的形式和 KEYS 变量类似( ARGV[1] 、 ARGV[2] ,诸如此类)。
执行下边的命令:
eval "local ret = redis.call('hset', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4]);redis.call('incr', KEYS[2]);return ret..'';" 2 test_key01 test_key02 field1 aa field2 bb
说明:
eval后边的script参数即脚本程序,将上边的Lua脚本使用双引号括起来。
numkeys:为2表示2个key
之后传入key的名称(多key中间用空格分隔):test_key01 test_key02
key后边再传入ARGV 参数(多ARGV 中间用空格分隔):field1 aa field2 bb
测试结果如下所示:
返回2表示向hash中写入2个key
下边是使用RedisTemplate执行Lua脚本的方法:
<T> T execute(RedisScript<T> script, List<K> keys, Object... args)
通过第一个参数类型指定要执行的Lua脚本,RedisScript的实现类是DefaultRedisScript,下边查阅DefaultRedisScript的源代码。
第一种方法是将Lua脚本的内容作为字符串传入DefaultRedisScript对象并且指定返回值类型,代码如下:
public DefaultRedisScript(String script, @Nullable Class<T> resultType) {
this.shaModifiedMonitor = new Object();
this.setScriptText(script);
this.resultType = resultType;
}
如果脚本内容比较多使用第一种方法显得很麻烦。
第二种方法是指定Lua脚本的位置,通过DefaultRedisScript的setScriptSource方法完成,如下:
public void setScriptSource(ScriptSource scriptSource) {
this.scriptSource = scriptSource;
}
本项目使用第二种方法,在RedisLuaConfiguration中定义DefaultRedisScript bean
@Bean("Lua_test01")
public DefaultRedisScript<Integer> getLuaTest01() {
DefaultRedisScript<Integer> redisScript = new DefaultRedisScript<>();
//resource目录下的scripts文件下的Lua_test01.Lua文件
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/Lua_test01.Lua")));
redisScript.setResultType(Integer.class);
return redisScript;
}
在classpath的script下创建lua脚本Lua_test01.lua,内容如下:
local ret = redis.call('hset', KEYS[1], ARGV[1], ARGV[2], ARGV[3], ARGV[4]);
redis.call('incr', KEYS[2]);
return ret..'';
如下图:
创建RedisTest测试类:
注入上边定义的DefaultRedisScript,注意注入时指定名称“Lua_test01”。
package com.jzo2o.market.service;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description TODO
* @date 2023/10/13 16:28
*/
@SpringBootTest
@Slf4j
public class RedisLuaTest {
@Resource
RedisTemplate redisTemplate;
@Resource(name = "Lua_test01")
DefaultRedisScript script;
//测试Lua
@Test
public void test_Luafirst() {
//参数1:key ,key1:test_key01 key2:test_key02
List<String> keys = Arrays.asList("test_key01","test_key02");
//参数2:传入Lua脚本的参数,"field1","aa","field2", "bb"
Object result = redisTemplate.execute(script, keys, "field1","aa","field2", "bb");
log.info("执行结果:{}",result);
}
}
执行test_Luafirst() 测试方法。
4)选择方案
上边学习了Multi和Redis+Lua两种实现Redis原子操作的方案,在本项目你会选择哪一种方案?
你肯定会说是第一种方案,因为简单,使用Lua脚本还要写Lua脚本,去学习它的语法。
不过,在实际使用中要根据具体的需求去确定方案,比如下边的Lua脚本在执行过程中就会有一些业务逻辑判断,不满足条件提前返回结果,而MULTI 执行命令是执行完成最后一起拿到所有命令的执行结果,并且MULTI 不适合写带有业务逻辑的脚本内容。
下边的lua脚本实现了项目抢券功能,可以尝试阅读,稍后会详细讲解。
-- 抢券Lua实现
-- key: 抢券同步队列,资源库存,抢券成功列表
-- argv:活动id,用户id
--优惠券是否已经抢过
local couponNum = redis.call("HGET", KEYS[3], ARGV[2])
-- hget 获取不到数据返回false而不是nil
if couponNum ~= false and tonumber(couponNum) >= 1
then
return "-1";
end
-- --库存是否充足校验
local stockNum = redis.call("HGET",KEYS[2], ARGV[1])
if stockNum == false or tonumber(stockNum) < 1
then
return "-2";
end
--抢券列表
local listNum = redis.call("HSET",KEYS[3], ARGV[2], 1)
if listNum == false or tonumber(listNum) < 1
then
return "-3";
end
--减库存
stockNum = redis.call("HINCRBY",KEYS[2], ARGV[1], -1)
if tonumber(stockNum) < 0
then
return "-4"
end
-- 抢单结果写入同步队列
local result = redis.call("HSETNX", KEYS[1], ARGV[2],ARGV[1])
if result > 0
then
return ARGV[1] ..""
end
return "-5"
根据需求本项目使用Redis执行Lua脚本的方式保证多命令的原子性,完成抢券功能。
5)使用Lua脚本注意点
Lua脚本在redis集群上执行需要注意什么?
在redis集群下执行redis命令会根据key求哈希,确定具体的槽位(slot),然后将命令路由到负责该槽位的 Redis 节点上。
执行一次Lua脚本会涉及到多个key,在redis集群下执行lua脚本要求多个key必须最终落到同一个节点,否则调用Lua脚本会报错:ERR eval/evalsha command keys must be in same slot。
如何保证多个key落地到一个redis节点呢?
只要保证多个key的哈希值一致即可保证多个key落到一个redis节点上,这个如何实现呢?
解决方法:一次执行Lua脚本的所有key中使用大括号‘{}’且保证大括号中的内容相同,此时会根据大括号中的内容求哈希,因为内容相同所以求得的哈希数据相同所以就落在了同一个Redis节点。
测试如下:
在key名称后边添加{},大括号中写一个固定的值。
@Test
public void test_Luafirst2() {
//参数1:key ,key1:test_key01
List<String> keys = Arrays.asList("test_key01{111}","test_key02{111}");
//参数2:传入Lua脚本的参数,"field1","aa","field2", "bb"
Object result = redisTemplate.execute(script, keys, "field1","aa","field2", "bb");
log.info("执行结果:{}",result);
}
执行测试成功,观察redis多了两个key:"test_key01{111}"和"test_key02{111}"
6)小结
项目中保证Redis原子操作用什么方案?
2.3.3 抢券整体方案
1)抢券方案分析
抢券的架构设计思想同抢券查询,将库存保存在Redis,避免抢券操作请求数据库,通过异步任务将Redis中的抢券结果同步到数据库。
抢券的交互流程如下:
如下图:
暂时无法在飞书文档外展示此内容
说明如下:
1、由预热程序将待生效库存同步到redis(活动开始将不允许更改库存)
2、活动开始后,抢券程序请求Redis扣减库存,扣减库存成功向抢券成功队列和抢券同步队列写入记录
Redis中两个队列的作用如下:
抢券成功队列:为了校验用户是否抢过该优惠券。
抢券同步队列:将抢券结果同步到数据库
3、通过定时任务程序根据Redis中同步队列记录的用户抢券结果信息将数据同步到MySQL,具体操作如下:
向优惠券表插入用户抢券记录。
更新优惠券活动表的库存。
写入数据库完成后删除Redis中同步队列的相应记录,删除后表示同步完成,如果同步过程失败将保留Redis同步队列的相应记录。
2)数据流
根据交互流程分析数据流如下:
暂时无法在飞书文档外展示此内容
3)Redis数据结构
- 活动信息
缓存结构:String类型:
key: "ACTIVITY:LIST"
value: 符合条件的优惠券活动列表JSON数据。
过期时间:永不过期
缓存一致性方案:通过预热程序保证缓存一致性
- 优惠券活动库存
缓存结构:Hash
RedisKey:COUPON:RESOURCE:STOCK:{活动id%10}
{活动id%10}表示根据活动id除以10求余,通过这种方法将key分散到不同的redis服务器上,通过“活动id%10”表达式可知优惠券活动库存hash最多有10个。
HashKey:活动id
HashValue: 库存
过期时间:永不过期
缓存一致性方案:通过预热程序保证缓存一致性
暂时无法在飞书文档外展示此内容
举例:
如果n为10,1号活动的库存是100,将1号活动库存 存储到Redis的效果如下:
暂时无法在飞书文档外展示此内容
- 抢券成功队列
缓存结构:Hash
RedisKey:COUPON:SEIZE:LIST:活动id_{活动id%10}
HashKey:用户id
HashValue:1
过期时间:永不过期
暂时无法在飞书文档外展示此内容
- 抢券同步队列
缓存结构:Hash
RedisKey:QUEUE:COUPON:SEIZE:SYNC:{活动id%10}
HashKey:用户id
HashValue:活动id
过期时间:永不过期
暂时无法在飞书文档外展示此内容
4)小结
抢券是怎么做的?或方案是什么?
抢券业务的Redis数据结构用的什么?具体说说
秒杀系统中如何进行流量削峰?
在秒杀系统中进行流量削峰是非常重要的,因为瞬时的高流量可能会导致系统崩溃或性能下降。以下是一些常见的流量削峰策略:
-
限流措施:通过控制请求的发放速率,可以有效地平滑流量,避免瞬时的高并发。
-
队列缓冲: 使用消息队列来缓冲请求,将瞬时的高并发请求进行缓存和排队。秒杀系统可以异步地从队列中取出请求进行处理,以平滑处理流量。
-
分批处理:将瞬时的高并发请求分批处理。不需要一次性处理所有请求,可以将请求按照一定的规模分批处理,以减轻数据库和系统的压力。
-
负载均衡:采用多节点部署,通过负载均衡器将流量分发到不同的服务器上。
-
熔断机制:
- 熔断策略: 实现熔断机制,当系统达到一定的负载阈值时,暂时停止接受新的请求,防止系统崩溃。等到系统恢复后再重新开启。
-
缓存预热:在秒杀开始之前,提前将秒杀商品的信息加载到缓存中,减轻数据库的压力。
-
验证码和身份验证:引入验证码和身份验证机制,防止机器人或恶意请求,减少无效请求对系统的冲击。
-
数据库优化:对于秒杀系统,数据库通常是瓶颈之一。通过优化数据库结构、建立索引、使用缓存等手段来提高数据库的读写性能。
2.3.4 库存同步
1)系统设计
库存同步包括两部分:
- 用户抢券要在Redis扣减库存,所以需要提前将优惠券活动的库存同步到Redis。
可以通过定时预热程序中将优惠券活动的库存同步到Redis,同步规则如下:
-
对于待生效的活动更新库存。
-
对于已生效的活动如果库存已经同步则不再同步,只更新没有同步库存的活动。
做第二点的原因是为了避免时间差问题,活动状态更改为进行中了但是库存还没有同步到Redis。
- 当用户抢券成功,Redis中的库存有了变化,需要将redis中的库存同步到mysql
根据整体方案分析,在抢券结果同步程序中根据抢券结果修改数据库中的库存,此部分在抢券结果同步章节再确定具体的方法。
交互流程如下:
暂时无法在飞书文档外展示此内容
2)预热程序中同步库存
下边实现在预热程序中同步库存。
在预热程序中添加:
@Override
public void preHeat() {
....
// 将待生效的活动库存写入redis
list.stream().filter(v->getStatus(v.getDistributeStartTime(),v.getDistributeEndTime(),v.getStatus())==1).forEach(v->{
redisTemplate.opsForHash().put(String.format(COUPON_RESOURCE_STOCK, v.getId() % 10), v.getId(), v.getTotalNum());
});
// 对于已生效的活动库存没有同步时再进行同步
list.stream().filter(v->getStatus(v.getDistributeStartTime(),v.getDistributeEndTime(),v.getStatus())==2).forEach(v->{
redisTemplate.opsForHash().putIfAbsent(String.format(COUPON_RESOURCE_STOCK, v.getId() % 10), v.getId(), v.getTotalNum());
});
}
说明:
对于待生效的活动库存使用put方法,可以对已设置的记录进行更改。
对已生效的活动库存使用putIfAbsent实现,当key不存在时才执行设置操作。
String.format(COUPON_RESOURCE_STOCK, v.getId() % 10) 用来拼装 key,库存的redis key为:
COUPON:RESOURCE:STOCK:{活动id%10}。
3)测试
测试流程:
启动定时预热程序任务。
观察redis中是否成功存储库存信息。
示例:
4)小结
库存怎么同步到redis?
通过定时预热程序将活动库存信息同步到redis。
对于待生效的活动库存使用put方法,可以对已设置的记录进行更改。
对已生效的活动库存使用putIfAbsent实现,当key不存在时才执行设置操作。
2.3.5 抢券Lua脚本
1)抢券Lua脚本
本节对抢券Lua脚本进行阅读并测试,理解抢券的过程。
1. 阅读下边的Lua脚本
-- 抢券lua实现
-- key: 抢券同步队列,资源库存,抢券成功列表
-- argv:活动id,用户id
--优惠券是否已经抢过
local couponNum = redis.call("HGET", KEYS[3], ARGV[2])
-- hget 获取不到数据返回false而不是nil
if couponNum ~= false and tonumber(couponNum) >= 1
then
return "-1";
end
-- --库存是否充足校验
local stockNum = redis.call("HGET",KEYS[2], ARGV[1])
if stockNum == false or tonumber(stockNum) < 1
then
return "-2";
end
--抢券列表
local listNum = redis.call("HSET",KEYS[3], ARGV[2], 1)
if listNum == false or tonumber(listNum) < 1
then
return "-3";
end
--减库存
stockNum = redis.call("HINCRBY",KEYS[2], ARGV[1], -1)
if tonumber(stockNum) < 0
then
return "-4"
end
-- 抢券结果写入同步队列
local result = redis.call("HSETNX", KEYS[1], ARGV[2],ARGV[1])
if result > 0
then
return ARGV[1] ..""
end
return "-5"
错误代码:
-1: 限领一张
-2: 已抢光
-3: 写入抢券成功队列失败,返回给用户为:抢券失败
-4: 已抢光
-5: 写入抢券同步队列失败,返回给用户为:抢券失败
2)测试
编写测试方法,准备好调用抢券Lua脚本需要传入的key和参数。
代码如下:
package com.jzo2o.market.service;
import com.jzo2o.api.market.dto.request.CouponUseBackReqDTO;
import com.jzo2o.api.market.dto.request.CouponUseReqDTO;
import com.jzo2o.api.market.dto.response.AvailableCouponsResDTO;
import com.jzo2o.api.market.dto.response.CouponUseResDTO;
import com.jzo2o.common.constants.UserType;
import com.jzo2o.common.model.CurrentUserInfo;
import com.jzo2o.common.model.PageResult;
import com.jzo2o.common.utils.JsonUtils;
import com.jzo2o.market.model.dto.request.CouponOperationPageQueryReqDTO;
import com.jzo2o.market.model.dto.request.SeizeCouponReqDTO;
import com.jzo2o.market.model.dto.response.CouponInfoResDTO;
import com.jzo2o.mvc.utils.UserContext;
import com.jzo2o.redis.utils.RedisSyncQueueUtils;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import static com.jzo2o.market.constants.RedisConstants.RedisKey.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Slf4j
public class ICouponServiceTest {
@Test
void test_seizeCouponScriptLua() {
//argv:抢券活动id
long activityId = 1706183021040336896L;
// argv: 用户id
Long userId = 1694250327664218113L;
int index = (int) (activityId % 10);
//key: 抢券同步队列,资源库存,抢券成功列表
// 同步队列redisKey
String couponSeizeSyncRedisKey = RedisSyncQueueUtils.getQueueRedisKey(COUPON_SEIZE_SYNC_QUEUE_NAME, index);
// 资源库存redisKey
String resourceStockRedisKey = String.format(COUPON_RESOURCE_STOCK, index);
// 抢券成功列表
String couponSeizeListRedisKey = String.format(COUPON_SEIZE_LIST,activityId, index);
// 抢券
Object execute = redisTemplate.execute(seizeCouponScript, Arrays.asList(couponSeizeSyncRedisKey, resourceStockRedisKey, couponSeizeListRedisKey),
activityId, userId);
log.debug("seize coupon result : {}", execute);
}
执行成功,观察redis:
示例:
库存是否减少:
抢券成功队列是否存在相应记录:
抢券同步队列是否存在相应记录:
3) 小结
抢券的Lua脚本做的什么工作?
-
判断用户是否在该活动抢过券。
-
判断库存是否充足
-
写入抢券成功列表
-
扣减库存
-
写入抢券同步列表
2.3.6 抢券接口开发
1)接口定义
下边进行接口分析,定义抢券接口。
在下边的界面中点击“立即领取”即开始抢券。
请求哪些参数?
抢券需要明确两个元素: 哪个用户抢的是哪个活动的优惠券。
用户的身份信息在token中由前端传入服务端。
所以,本接口需要传入服务端的参数是活动ID。
传入参数:活动ID。
响应结果:无,通过状态码判断。
接口定义如下:
接口名称:抢券接口
接口路径:POST/market/consumer/coupon/seize
编写controller方法:
@RestController("consumerCouponController")
@RequestMapping("/consumer/coupon")
@Api(tags = "用户端-优惠券相关接口")
public class CouponController {
@PostMapping("/seize")
public void seizeCoupon(@RequestBody SeizeCouponReqDTO seizeCouponReqDTO) {
}
...
2)校验活动有效性
下边定义service方法,在service方法中需要做哪些事?
-
校验活动是否有效
-
调用Lua脚本执行抢券
本节实现第一步,校验活动的有效性。
定义 service接口如下:
public interface ICouponService extends IService<Coupon> {
/**
* 抢券
*
* @param seizeCouponReqDTO
*/
void seizeCoupon(SeizeCouponReqDTO seizeCouponReqDTO);
...
实现类:
@Override
public void seizeCoupon(SeizeCouponReqDTO seizeCouponReqDTO) {
// 1.校验活动开始时间或结束
// 首先从缓存查询活动
// 2.抢券准备
// key: 抢券同步队列,资源库存,抢券列表
// argv:抢券id,用户id
// 3.执行lua脚本进行抢券结果
// 4.处理lua脚本结果,失败的抛出异常,成功的正常返回
}
如何校验活动是否有效?
1、从缓存中查询指定活动的信息
抢券接口避免与数据库交互。
2、根据活动时间校验活动是否未开始或者已经结束,这两类活动不允许抢券
实现方法如下:
- 定义从缓存查询指定活动信息的方法
public interface IActivityService extends IService<Activity> {
/**
* 从缓存中获取活动信息
* @param id
* @return
*/
ActivityInfoResDTO getActivityInfoByIdFromCache(Long id);
阅读下边的方法:
@Override
public ActivityInfoResDTO getActivityInfoByIdFromCache(Long id) {
// 1.从缓存中获取活动信息
Object activityList = redisTemplate.opsForValue().get(ACTIVITY_CACHE_LIST);
if (ObjectUtils.isNull(activityList)) {
return null;
}
// 2.过滤指定活动信息
List<ActivityInfoResDTO> list = JsonUtils.toList(activityList.toString(), ActivityInfoResDTO.class);
if (CollUtils.isEmpty(list)) {
return null;
}
// 3.过滤指定活动
return list.stream()
.filter(activityInfoResDTO -> activityInfoResDTO.getId().equals(id))
.findFirst().orElse(null);
}
3)抢券service方法
下边编写抢券的service方法
@Override
public void seizeCoupon(SeizeCouponReqDTO seizeCouponReqDTO) {
// 1.校验活动开始时间或结束
// 抢券时间
ActivityInfoResDTO activity = activityService.getActivityInfoByIdFromCache(seizeCouponReqDTO.getId());
LocalDateTime now = DateUtils.now();
if (activity == null ||
activity.getDistributeStartTime().isAfter(now)) {
throw new CommonException(SEIZE_COUPON_FAILD, "活动未开始");
}
if (activity.getDistributeEndTime().isBefore(now)) {
throw new CommonException(SEIZE_COUPON_FAILD, "活动已结束");
}
// 2.抢券准备
// key: 抢券同步队列,资源库存,抢券列表
// argv:抢券id,用户id
int index = (int) (seizeCouponReqDTO.getId() % 10);
// 同步队列redisKey
String couponSeizeSyncRedisKey = RedisSyncQueueUtils.getQueueRedisKey(COUPON_SEIZE_SYNC_QUEUE_NAME, index);
// 资源库存redisKey
String resourceStockRedisKey = String.format(COUPON_RESOURCE_STOCK, index);
// 抢券列表
String couponSeizeListRedisKey = String.format(COUPON_SEIZE_LIST, activity.getId(), index);
log.debug("seize coupon keys -> couponSeizeListRedisKey->{},resourceStockRedisKey->{},couponSeizeListRedisKey->{},seizeCouponReqDTO.getId()->{},UserContext.currentUserId():{}",
couponSeizeListRedisKey, resourceStockRedisKey, couponSeizeListRedisKey, seizeCouponReqDTO.getId(), UserContext.currentUserId());
// 3.抢券结果
Object execute = redisTemplate.execute(seizeCouponScript, Arrays.asList(couponSeizeSyncRedisKey, resourceStockRedisKey, couponSeizeListRedisKey),
seizeCouponReqDTO.getId(), UserContext.currentUserId());
log.debug("seize coupon result : {}", execute);
// 4.处理lua脚本结果
if (execute == null) {
throw new CommonException(SEIZE_COUPON_FAILD, "抢券失败");
}
long result = NumberUtils.parseLong(execute.toString());
if (result > 0) {
return;
}
if (result == -1) {
throw new CommonException(SEIZE_COUPON_FAILD, "限领一张");
}
if (result == -2 || result == -4) {
throw new CommonException(SEIZE_COUPON_FAILD, "已抢光!");
}
throw new CommonException(SEIZE_COUPON_FAILD, "抢券失败");
}
4)抢券controller方法
@ApiOperation("抢券接口")
@PostMapping("/seize")
public void seizeCoupon(@RequestBody SeizeCouponReqDTO seizeCouponReqDTO) {
couponService.seizeCoupon(seizeCouponReqDTO);
}
5) 抢券测试
启动网关
启动优惠券活动管理工程
启动xxl-job
打开小程序,进入抢券页面
点击领取,观察写入redis的数据是否正确:
示例:
抢券成功存入redis抢券成功队列:
存入redis抢券同步队列:
抢券失败情况测试:
限领一张提示:
已抢光:
手动修改redis库存为0,进行测试,示例如下:
6)小结
项目是怎么实现抢券功能的?
1)将优惠券活动的库存同步到Redis
- 用户抢券请求redis,执行Lua脚本,具体如下:
先判断当前用户是否抢过该优惠券,如果抢过则返回-1
判断库存是否充足,如果不充足返回-2
向抢券成功列表写入记录
扣减库存
向抢券同步列表写入记录
3)由异步任务将redis抢券成功记录同步到数据库中
2.4 抢券结果同步
2.4.1 Redis到MySQL同步方案分析
如何将Redis中的抢券结果同步到MySQL的优惠券表(coupon)呢?
1) 整体思路
基本思路: 遍历Redis中的抢券结果同步队列,拿到一个元素就向数据库的优惠券表插入记录,插入完成后删除Redis中的这条记录。
下图是存储抢券结果同步数据的Hash表。
我们可以一次从Hash表中拿一批数据,每个元素包括了用户id和活动id,根据这两个参数插入coupon表。
从下图可以看出,只要拿到用户id和活动id即可向优惠券表插入一条记录。
基本思路清楚,现在需要考虑:系统有多个活动,如何提高同步程序的处理性能呢?
假如同步队列的key为:QUEUE:COUPON:SEIZE:SYNC:{活动id % 10},这说明最多有10个同步列表。
我们可以用多线程,每个线程处理一个同步队列。
暂时无法在飞书文档外展示此内容
由定时任务去调度,每隔1分钟由多线程对同步队列中的数据进行处理。
2)如何从Redis 批量取数据?
我们使用redisTemplate.opsForHash().scan(H key, ScanOptions options)方法,scan方法通过游标的方式实现从hash中批量获取数据。
测试代码如下:
@Test
void batchGet() {
int index = 6;
String hashKey = String.format("QUEUE:COUPON:SEIZE:SYNC:{%s}",index);
Cursor<Map.Entry<String, Object>> cursor = null;
// 通过scan从redis hash数据中批量获取数据,获取完数据需要手动关闭游标
ScanOptions scanOptions = ScanOptions.scanOptions()
.count(10)
.build();
try {
// sscan获取数据
cursor = redisTemplate.opsForHash().scan(hashKey, scanOptions);
// 遍历数据转换成SyncMessage列表
List<SyncMessage<Object>> collect = cursor.stream()
.map(entry -> SyncMessage
.builder()
.key(entry.getKey().toString())
.value(entry.getValue())
.build())
.collect(Collectors.toList());
log.info("获取{}数据{}条", hashKey,collect.size());
collect.stream().forEach(System.out::println);
}catch (Exception e){
log.error("同步处理异常,e:", e);
throw new RuntimeException(e);
} finally {
// 关闭游标
if (cursor != null) {
cursor.close();
}
}
}
下边进行测试:
保证存在“QUEUE:COUPON:SEIZE:SYNC:{%s}”的队列且其中有数据
观察控制台的日志"{}获取{}队列的数据{}条",并观察是否输出了从Hash表中拿到的数据。
3)小结
本节我们分析了从Redis到MySQL同步方案:
1、使用线程池从多个同步队列中查询数据,每个线程处理一个同步队列。
注意:同步队列的个数可以灵活配置,但不宜过多,因为同步队列个数为最大线程数,通常配置10到20即可。
2、使用redisTemplate.opsForHash().scan(H key, ScanOptions options)方法从hash表获取数据。
这里需要注意游标的使用,一定要在finally 中关闭游标。
2.4.2 抢券结果同步开发
1)编写扣减库存方法
项目使用数据同步组件完成数据从Redis的Hash结构同步到MySQL中。
关于数据同步组件的使用前边已经讲解,下边我们需要完善数据同步处理器即可将抢券同步队列的数据同步到MySQL。
数据同步处理器拿到抢券结果做两件事:
-
插入优惠券表
-
扣减库存
首先编写扣减库存的方法
此方法已存在
public interface IActivityService extends IService<Activity> {
/**
* 扣减库存
* @param id 活动id
* 如果扣减库存失败抛出异常
*/
public boolean updateStockNum(Long id,Integer num);
...
实现方法:
@Service
public class ActivityServiceImpl extends ServiceImpl<ActivityMapper, Activity> implements IActivityService {
/**
* 扣减库存
* @param id 活动id
* 如果扣减库存失败抛出异常
*/
@Override
public boolean updateStockNum(Long id,Integer num) {
//sql=update activity set stock_num=stock_num-#{num} where id=#{activityId} and stock_num>=#{num}
boolean update = lambdaUpdate()
.setSql("stock_num=stock_num-"+num)
.eq(Activity::getId, id)
.ge(Activity::getStockNum,num)
.update();
return update;
}
...
2) 编写数据同步方法
每从hash中获取一条数据则向coupon表插入记录。
public interface ICouponService extends IService<Coupon> {
...
/**
* 抢券结果同步
* @param activityId
* @param userId
*/
void seizeCouponSync(Long activityId, Long userId);
实现类:
@Transactional
public void seizeCouponSync(Long activityId, Long userId){
// 1.获取活动
Activity activity = activityService.getById(activityId);
if (activity == null) {
return;
}
CommonUserResDTO commonUserResDTO = commonUserApi.findById(userId);
if(commonUserResDTO == null){
return;
}
// 2.新增优惠券
Coupon coupon = new Coupon();
coupon.setId(IdUtils.getSnowflakeNextId());
coupon.setActivityId(activityId);
coupon.setUserId(userId);
coupon.setUserName(commonUserResDTO.getNickname());
coupon.setUserPhone(commonUserResDTO.getPhone());
coupon.setName(activity.getName());
coupon.setType(activity.getType());
coupon.setDiscountAmount(activity.getDiscountAmount());
coupon.setDiscountRate(activity.getDiscountRate());
coupon.setAmountCondition(activity.getAmountCondition());
coupon.setValidityTime(DateUtils.now().plusDays(activity.getValidityDays()));
coupon.setStatus(CouponStatusEnum.NO_USE.getStatus());
boolean save = save(coupon);
//扣减库存
boolean b = activityService.updateStockNum(activity.getId(), 1);
if(!b || !save){
throw new CommonException("优惠券同步失败");
}
}
3) 编写线程任务类
package com.jzo2o.market.handler;
import com.jzo2o.common.utils.NumberUtils;
import com.jzo2o.market.service.ICouponIssueService;
import com.jzo2o.market.service.ICouponService;
import com.jzo2o.redis.model.SyncMessage;
import com.jzo2o.redis.utils.RedisSyncQueueUtils;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* @author Mr.M
* @version 1.0
* @description 抢券结果处理器
* @date 2024/9/23 18:43
*/
@Slf4j
public class SeizeCouponHandler implements Runnable {
//hashkey
private String hashKey;
//分布式锁
private RedissonClient redissonClient;
private RedisTemplate redisTemplate;
private ICouponService couponService;
//构造方法
public SeizeCouponHandler(String hashKey, ICouponService couponService,RedissonClient redissonClient, RedisTemplate redisTemplate) {
this.hashKey = hashKey;
this.couponService = couponService;
this.redissonClient = redissonClient;
this.redisTemplate = redisTemplate;
}
@Override
public void run() {
String lockKey = "LOCK:" + hashKey;
log.info("获取锁:{}", lockKey);
RLock lock = redissonClient.getLock(lockKey);
//尝试获取锁
try {
boolean tryLock = lock.tryLock(1, -1, TimeUnit.SECONDS);
if (!tryLock) {
log.info("获取锁失败:{}", lockKey);
return;
}
//开始发放优惠券
log.info("开始处理抢券结果:{}", hashKey);
Cursor<Map.Entry<String, Object>> cursor = null;
try {
// 通过scan从redis hash数据中批量获取数据,获取完数据需要手动关闭游标
ScanOptions scanOptions = ScanOptions.scanOptions()
.count(100)
.build();
while (true){
// sscan获取数据
cursor = redisTemplate.opsForHash().scan(hashKey, scanOptions);
// 遍历数据转换成SyncMessage列表
List<SyncMessage<Object>> collect = cursor.stream()
.map(entry -> SyncMessage
.builder()
.key(entry.getKey().toString())
.value(entry.getValue())
.build())
.collect(Collectors.toList());
log.info("{}获取{}数据{}条", Thread.currentThread().getId(), hashKey, collect.size());
if(collect.size() <=0){
break;
}
//处理抢券结果
collect.stream().forEach((objectSyncMessage) -> {
try {
//活动id
Long activityId = NumberUtils.parseLong(objectSyncMessage.getValue().toString());
//用户id
Long userId = NumberUtils.parseLong(objectSyncMessage.getKey());
//同步抢券结果
couponService.seizeCouponSync(activityId, userId);
//删除同步记录
this.redisTemplate.opsForHash().delete(hashKey, new Object[]{objectSyncMessage.getKey()});
} catch (Exception var5) {
log.error("hash结构同步消息单独处理异常,e:", var5);
}
});
}
} catch (Exception e) {
log.error("同步处理异常,e:", e);
throw new RuntimeException(e);
} finally {
lock.unlock();
// 关闭游标
if (cursor != null) {
cursor.close();
}
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
4)编写多线程任务方法
package com.jzo2o.market.handler;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jzo2o.market.constants.RedisConstants;
import com.jzo2o.market.model.domain.Activity;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponIssueService;
import com.jzo2o.market.service.ICouponService;
import com.jzo2o.redis.properties.RedisSyncProperties;
import com.jzo2o.redis.utils.RedisSyncQueueUtils;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
/**
* @author Mr.M
* @version 1.0
* @description 抢券结果同步任务
* @date 2024/9/23 19:49
*/
@Slf4j
@Component
public class SeizeCouponHandlerJob {
//定义线程池
private static ThreadPoolExecutor threadPoolExecutor;
@Resource
private RedisTemplate redisTemplate;
@Resource
private ICouponService couponService;
@Resource
private RedissonClient redissonClient;
@Resource
private RedisSyncProperties redisSyncProperties;
static {
threadPoolExecutor = new ThreadPoolExecutor(10, 10, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(10));
}
public void start() {
int queueNum = redisSyncProperties.getQueueNum();
log.info("根据队列创建线程任务对象");
for (int i = 0; i < queueNum; i++) {
String hashKey = RedisSyncQueueUtils.getQueueRedisKey(RedisConstants.RedisKey.COUPON_SEIZE_SYNC_QUEUE_NAME, i);
//创建SeizeCouponHandler对象
SeizeCouponHandler seizeCouponHandler = new SeizeCouponHandler(hashKey, couponService,redissonClient, redisTemplate);
//将任务加入线程池
threadPoolExecutor.execute(seizeCouponHandler);
}
}
}
5)抢券结果同步测试
编写定时任务
package com.jzo2o.market.handler;
import com.jzo2o.market.service.IActivityService;
import com.jzo2o.market.service.ICouponService;
import com.jzo2o.redis.annotations.Lock;
import com.jzo2o.redis.constants.RedisSyncQueueConstants;
import com.jzo2o.redis.sync.SyncManager;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import static com.jzo2o.market.constants.RedisConstants.Formatter.*;
import static com.jzo2o.market.constants.RedisConstants.RedisKey.COUPON_SEIZE_SYNC_QUEUE_NAME;
@Component
public class XxlJobHandler {
...
/**
* 抢券同步队列
* 5s一次
*/
@XxlJob("seizeCouponSyncJob")
public void seizeCouponSyncJob() {
seizeCouponHandlerJob.start();
}
}
下边在xxl-job配置定时任务
启动任务:
测试流程:
启动客户管理工程(需要根据用户id查询抢券人的信息)
抢券测试,保证抢券成功后在Redis同步队列写入数据成功
示例:
启动xxl-job抢券结果同步任务
跟踪断点,观察数据是否正确:
数据同步成功删除同步 HashKey
最终写入优惠券表并扣减库存。
示例:
查询coupon表,发现用户抢券结果已成功写入优惠券表:
活动的库存数据正常扣除。
抢券结果同步完成,redis中记录的活动的库存与数据库活动表的库存一致:
6)小结
秒杀异步处理怎么实现的?
3 抢单
3.1 需求分析
完成了预约下单、支付业务的开发,接下来进入抢单模块。
首先我们对抢单模块进行需求分析。
1)核心业务流程
首先明确本模块在核心业务流程的位置,下图是项目的核心业务流程:
用户下单后服务人员通过app进行抢单,机构通过pc进行抢单,抢单成功后服务人员开始现场服务。
暂时无法在飞书文档外展示此内容
2)服务端抢单原型
服务人员在平台注册后需要进行实名认证、服务技能设置、服务范围设置、开启接单,这四项准备工作完成后方可在平台接单。
- 服务技能设置
服务技能即服务人员服务范围,比如:服务人员A提供日常保洁、日常维修服务。设置服务技能后服务人员可以抢与自己的技能匹配的订单,比如:服务人员A提供日常保洁、日常维修服务,所以它只能抢日常保洁、日常维修服务的订单。
- 服务范围设置
设置服务范围后能抢服务范围内的订单,下图中的服务城市是北京,接单范围是朱辛庄地铁站,在抢单查询时以接单范围为中心点查询方圆几公里的用户创建的订单,具体方圆公里数在查询条件中选择。
- 接单设置
开启接单后方可抢单。
- 开始抢单
进入抢单页面,选择服务距离和服务类型查询订单,点击“立即抢单”进行抢单。
服务距离:距离服务人员接单范围的距离。
服务类型:
- 抢单成功查询订单
服务人员抢单成功在我的订单中查询
3) 机构端抢单原型
机构端抢单与服务端抢单的区别是:
1、机构使用pc端,服务端使用app
2、机构端抢单成功后订单状态为待分配,表示待将订单分配给机构下的服务人员;服务端抢单成功后订单状态为待服务,因为服务人员抢到的订单服务人员就是自己。
3、抢单数量限制不同
抢单数量限制是当前拥有的进行中的服务单的最大数量。服务人员默认是10,机构默认是100。
抢单数量限制的目的是规避恶意抢单。
因为机构下的服务人员较多所以机构的签单数量限制大于服务人员。
下边说明机构端抢单的界面原型:
机构端也需要进行实名认证、服务技能与服务范围设置、开启接单后方可在平台接单。
1、服务技能与服务范围设置
2、开启接单
3、抢单页面
上图中除了服务费用以外其它字段均为订单表中的信息,服务费是订单金额的30%(可设置)。
4)服务单状态
服务人员抢单成功或系统派单成功将生成订单对应的服务单,服务单状态如下:
暂时无法在飞书文档外展示此内容
服务单初始状态:待分配或待服务
机构抢单成功:待分配。
服务人员抢单成功:待服务。
开始服务: 待服务---》服务中
服务完成:
服务中---》服务完成
用户取消订单:
待分配---》已取消
待服务---》已取消
运营人员取消订单:
服务中---》已取消
服务完成---》已取消
5) 小结
通过需求分析,抢单模块分如下子模块:
- 抢单设置子模块
设置服务技能、服务范围、开启抢单。
- 抢单查询子模块
查询抢单池中的订单
- 抢单子模块
点击“立即抢单”开始抢单。
抢单成功后生成服务单,服务单的状态包括:待分配、待服务、服务中、服务完成、已取消。
3.2 系统整体设计
1)整体分析
根据抢单需求,抢单的流程和抢券的流程很相似,核心内容也包括三部分:
抢单查询
抢单查询对应到抢券查询
在抢单界面查询抢单池中的订单,订单属于抢购的资源,在抢券中优惠券也属于争抢的资源,此处的查询存在高并发需要使用缓存技术进行优化。
查询抢单池中的订单可以根据下单用户的地理位置进行搜索订单,此功能类似“搜索附近”的业务,比如:搜索附近的酒店、搜索附近的房源信息等。
结合上边的要求,本项目使用Elasticsearch查询抢单资源,使用ES基于地理坐标搜索功能完成“搜索附近”功能的开发。
如果要使用Elasticsearch查询抢单池信息就需要将待派单的订单同步到Elasticsearch,这里我们使用Canal+MQ的方式实现数据同步,如下图:
暂时无法在飞书文档外展示此内容
说明:
下单支付成功通过订单分流程序将订单信息写入抢单池,满足系统自动派单需求的订单同时写入派单池,派单部分在后边章节讲解。
通过Canal将抢单池的信息同步到Elasticsearch。
抢单查询从Elasticsearch中查询订单信息,使用ES的基于地理坐标搜索功能完成搜索附近的订单。
抢单
抢单技术方案同抢券
我们在分析抢单技术方案时结合 抢券 的技术方案进行分析,多个人争抢同一个订单,这里仍然存在超卖问题,我们使用同抢券一样的技术方案去解决超卖问题。
下图中加入了抢单的交互流程:
暂时无法在飞书文档外展示此内容
说明:
参考抢券的方案,使用Redis+Lua的技术解决超卖问题,这里将抢单库存同步到Redis,每个订单的库存就是1。
抢单执行Lua脚本完成抢单,具体包括:扣减库存、抢单成功写入同步队列。
抢单同步队列的作用是通过异步任务将抢单结果信息同步到数据库。
抢单结果异步处理
技术方案同抢券
秒杀抢购业务的并发高,为了避免直接操作数据库这里使用异步任务的方式将抢单结果同步到数据库。
抢单成功创建服务单,异步任务的主要职责是根据抢单结果创建服务单,并且更新订单的状态。
抢单结果同步异步任务的具体的内容如下:
创建服务单:
服务单记录了服务人员进行家政服务的信息,关键字段有:订单ID、订单金额、服务人员ID、服务单状态、服务时间、服务照片等。
服务单初始状态:待分配或待服务
机构抢单成功:待分配。
服务人员抢单成功:待服务。
服务单的详细状态如下图:
暂时无法在飞书文档外展示此内容
更新订单的状态:
用户下单并支付完成后订单的状态为“派单中”,服务人员抢单成功订单状态为“待服务”,机构抢单成功订单状态为“待分配”。
订单状态图如下,详细说明请参见第四章内容。
暂时无法在飞书文档外展示此内容
抢单结果同步成功删除抢单池等相关信息:
抢单结果同步成功后删除抢单池中该订单的信息,这样服务人员在抢单界面无法查询到该订单。
删除数据库中抢单池的记录,将Elasticsearch中对应的抢单记录删除。
删除Redis中该订单的库存信息。
删除Redis中抢单同步队列中的记录。
交互流程如下:
暂时无法在飞书文档外展示此内容
2) 数据流
下边分析整体的数据流:
具体的表结构在各个模块中进行设计。
暂时无法在飞书文档外展示此内容
说明:
1、下单
向订单表添加记录。
2、支付成功
更新订单状态为派单中
向抢单池表新增记录
3、Canal同步
将数据库中抢单池的信息同步到Elasticsearch中。
4、抢单查询
请求Elasticsearch查询抢单池信息
5、抢单
请求Redis扣减库存、添加抢单同步记录
6、抢单结果异步处理
创建服务单
更新订单状态
删除抢单池记录
删除Redis同步队列记录
删除Redis库存记录
3)小结
能够说出本项目抢单的实现方案?
-
用户下单且支付成功,订单信息进入抢单池
-
通过Canal+MQ将抢单池的信息同步到Elasticsearch、同步到Redis中。
-
服务人员或机构进入抢单界面,请求Elasticsearch查询订单信息,这里可以根据地理坐标搜索附近的订单
-
服务人员或机构点击抢单、请求Redis执行Lua脚本完成抢单。
-
通过定时任务将Redis中抢单成功的结果同步到数据库。
3.3 抢单设置
根据需求,机构和服务人员都需要做抢单前的准备工作方可抢单。
准备工作包括:实名认证、服务技能设置、服务范围设置、开启接单。关于实名认证的业务流程请参考第二章,下边通过阅读代码理解服务技能设置、服务范围设置、开启接单的设置。
下边以服务端为例说明。
1)需求分析
- 服务技能设置
服务人员新注册账号后进入下边的界面:
通过此界面进行服务技能设置、服务范围设置、接单设置。
点击上图中的“去设置”进入服务技能设置界面:
点击编辑开始设置服务技能:
选择服务技能,点击保存。
- 服务范围设置
进入服务范围设置界面:
2)准备环境
抢单章节的代码环境:
jzo2o-customer
jzo2o-fundations
jzo2o-orders
订单工程创建一个新分支dev_02,从课程资料中找到并解压jzo2o-orders-seize-dev02.zip、jzo2o-orders-manager-dev02.zip,覆盖dev_02分支的代码。
3) 表设计
服务技能表:存储服务提供者的技能信息。
结构如下:
create table `jzo2o-customer`.serve_skill
(
id bigint not null comment '主键'
constraint `PRIMARY`
primary key,
serve_provider_id bigint null comment '服务人员/机构id',
serve_provider_type int null comment '类型,2:服务人员,3:服务机构',
serve_type_id bigint null comment '服务类型id',
serve_type_name varchar(50) null comment '服务类型名称',
serve_item_id bigint null comment '服务项id',
serve_item_name varchar(50) null comment '服务项名称',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
is_delete int default 0 not null comment '是否已删除,0:未删除,1:已删除'
)
comment '服务技能表' charset = utf8mb4;
服务提供者设置表:存储服务提供者的服务范围等信息。
create table `jzo2o-customer`.serve_provider_settings
(
id bigint not null comment '服务人员/机构id'
constraint `PRIMARY`
primary key,
city_code varchar(20) default '' null comment '城市码',
city_name varchar(64) null comment '城市名称',
lon double(10, 5) null comment '经度',
lat double(10, 5) null comment '纬度',
intention_scope varchar(100) null comment '意向单范围',
have_skill int default 0 null comment '是否有技能',
can_pick_up int default -1 null comment '是否可以接单,-0:关闭接单,1:开启接单',
create_time datetime default CURRENT_TIMESTAMP null,
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP,
is_deleted int default 0 null
)
comment '服务人员/机构附属信息' charset = utf8mb4;
服务人员与机构表:存储服务人员与机构的注册信息。
当服务技能、服务范围、接单设置全部设置完成会将settings_status 字段更新为1
create table `jzo2o-customer`.serve_provider
(
id bigint not null comment '主键'
constraint `PRIMARY`
primary key,
code varchar(255) null comment '编号',
type int not null comment '类型,2:服务人员,3:服务机构',
name varchar(255) null comment '姓名',
phone varchar(255) not null comment '电话',
avatar varchar(255) null comment '头像',
status int not null comment '状态,0:正常,1:冻结',
settings_status int default 0 null comment '首次设置状态,0:未完成设置,1:已完成设置',
password varchar(255) null comment '机构登录密码',
account_lock_reason varchar(255) null comment '账号冻结原因',
score double null comment '综合评分',
good_level_rate varchar(50) null comment '好评率',
create_time datetime default CURRENT_TIMESTAMP not null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间',
is_deleted int default 0 not null comment '是否已删除,0:未删除,1:已删除',
constraint serve_provider_phone_type_uindex
unique (phone, type)
)
comment '服务人员/机构表' charset = utf8mb4;
4)代码阅读
这些操作的接口在客户中心工程(jzo2o-customer)定义。
服务技能设置接口类:
package com.jzo2o.customer.controller.worker;
import com.jzo2o.api.foundations.dto.response.ServeItemSimpleResDTO;
import com.jzo2o.api.foundations.dto.response.ServeTypeSimpleResDTO;
import com.jzo2o.customer.model.dto.request.ServeSkillAddReqDTO;
import com.jzo2o.customer.model.dto.response.ServeSkillCategoryResDTO;
import com.jzo2o.customer.service.IServeSkillService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.List;
/**
* <p>
* 服务技能表 前端控制器
* </p>
*
* @author itcast
* @since 2023-07-18
*/
@RestController("workerServeSkillController")
@RequestMapping("/worker/serve-skill")
@Api(tags = "服务端 - 服务技能相关接口")
public class ServeSkillController {
@Resource
private IServeSkillService serveSkillService;
@PostMapping("/batchUpsert")
@ApiOperation("批量新增或修改服务技能")
public void listServeType(@RequestBody List<ServeSkillAddReqDTO> serveSkillAddReqDTOList) {
serveSkillService.batchUpsert(serveSkillAddReqDTOList);
}
@GetMapping("/category")
@ApiOperation("查询服务技能目录")
public List<ServeSkillCategoryResDTO> category() {
return serveSkillService.category();
}
@GetMapping("/queryCurrentUserServeSkillTypeList")
@ApiOperation("查询当前用户的服务技能类型")
public List<ServeTypeSimpleResDTO> queryCurrentUserServeSkillTypeList() {
return serveSkillService.queryCurrentUserServeSkillTypeList();
}
@GetMapping("/queryCurrentUserServeSkillItemList")
@ApiOperation("查询当前用户的服务技能")
public List<ServeItemSimpleResDTO> queryCurrentUserServeSkillItemList() {
return serveSkillService.queryCurrentUserServeSkillItemList();
}
}
服务范围及接单设置接口类:
package com.jzo2o.customer.controller.worker;
import com.jzo2o.customer.model.dto.request.ServePickUpReqDTO;
import com.jzo2o.customer.model.dto.request.ServeScopeSetReqDTO;
import com.jzo2o.customer.model.dto.response.ServeProviderSettingsGetResDTO;
import com.jzo2o.customer.model.dto.response.ServeSettingsStatusResDTO;
import com.jzo2o.customer.service.IServeProviderSettingsService;
import com.jzo2o.mvc.utils.UserContext;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
/**
* @author 86188
*/
@RestController("workerServeProviderSettingsController")
@RequestMapping("/worker/serve-settings")
@Api(tags = "服务端 - 服务设置相关接口")
public class ServeProviderSettingsController {
@Resource
private IServeProviderSettingsService serveProviderSettingsService;
@PutMapping("/serve-scope")
@ApiOperation("服务范围设置")
public void setServeScope(@RequestBody @Validated ServeScopeSetReqDTO serveScopeSetReqDTO) {
serveProviderSettingsService.setServeScope(serveScopeSetReqDTO);
}
@GetMapping
@ApiOperation("获取设置")
public ServeProviderSettingsGetResDTO getServeScope() {
return serveProviderSettingsService.getServeScope();
}
@PutMapping("/pick-up")
@ApiOperation("接单设置")
public void setPickUp(@RequestBody ServePickUpReqDTO servePickUpReqDTO) {
// serveProviderSettingsService.setPickUp(servePickUpReqDTO);
serveProviderSettingsService.setPickUp(UserContext.currentUserId(), servePickUpReqDTO.getCanPickUp());
}
@GetMapping("/status")
@ApiOperation("获取所有设置状态")
public ServeSettingsStatusResDTO getStatus() {
return serveProviderSettingsService.getSettingStatus();
}
}
5)测试
请大家自行从接口入口开始阅读代码,找到服务技能设置、服务范围设置、接单设置存储的表及存储方式。
存储方式:是添加还是更新、是批量还是单条记录操作等详细的操作方式。
在阅读代码时可以通过断点测试的方式进行。
启动jzo2o-customer、jzo2o-publics、jzo2o-fundations、jzo2o-gateway。
启动服务端前端工程。
(服务端前端工程运行方式参考第二章关于project-xzb-app-uniapp-java工程的部署说明)
在测试时可以使用一个新的手机号注册。
关于服务范围设置:
设置服务范围时需要通过手机进行定位,我们使用浏览器测试服务端前端程序无法通过手机定位,这里我们在配置文件中配置坐标模拟手机定位的效果。
在下边的配置文件上配置坐标及相关信息实现定位:
坐标及信息使用高德地图的坐标拾取工具(https://lbs.amap.com/tools/picker)获取。
citycode从数据库的city_directory获取。
进入服务范围设置界面:
如果要修改原来设置的接单范围需要进入数据库清理接单范围设置后再进入此界面。
进入jzo2o-customer.serve_provider_settings表,找到服务人员对应的接单范围信息,清理下边红框内的字段值
如何找到服务人员对应的接单范围信息?
先从jzo2o-customer-all.serve_provider表中根据手机号找到主键
再根据主键从jzo2o-customer-all.serve_provider_settings表找到具体的记录。
6)小结
抢单设置包括哪些表?
服务技能表:存储服务提供者的技能信息。
服务提供者设置表:存储服务提供者的服务范围等信息。
服务人员与机构表:存储服务人员与机构的注册信息。
3.4 订单分流
1)需求分析
订单分流的流程如下:
暂时无法在飞书文档外展示此内容
2)表设计
根据数据流分析需要设计抢单池表。
-- 抢单池(资源池)
-- auto-generated definition
create table orders_seize
(
id bigint not null comment '订单id'
primary key,
orders_code varchar(50) null comment '订单编号',
city_code varchar(50) default '' not null comment '城市编码',
serve_type_id bigint null comment '服务分类id',
serve_item_name varchar(50) not null comment '服务名称',
serve_type_name varchar(50) not null comment '服务分类名称',
serve_item_id bigint null comment '服务项id',
serve_address varchar(50) not null comment '服务地址',
serve_item_img varchar(255) not null comment '服务项目图片',
orders_amount decimal(10, 2) null comment '订单总金额',
serve_start_time datetime not null comment '服务开始时间',
pay_success_time datetime null comment '订单支付成功时间,用于计算是否进入派单',
lon double(10, 5) null comment '经度',
lat double(10, 5) null comment '纬度',
pur_num int not null comment '服务数量',
is_time_out int default 0 null comment '抢单是否超时',
sort_by bigint null comment '抢单列表排序字段',
create_time datetime default CURRENT_TIMESTAMP null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间'
)
comment '抢单池(资源池)' charset = utf8mb4;
create index sort_by_index
on orders_seize (sort_by);
3)阅读代码
下边阅读订单分流相关的代码。
package com.jzo2o.orders.base.service.impl;
import com.jzo2o.api.foundations.RegionApi;
import com.jzo2o.api.foundations.ServeApi;
import com.jzo2o.api.foundations.dto.response.ConfigRegionInnerResDTO;
import com.jzo2o.api.foundations.dto.response.ServeAggregationResDTO;
import com.jzo2o.common.utils.BooleanUtils;
import com.jzo2o.common.utils.DateUtils;
import com.jzo2o.common.utils.ObjectUtils;
import com.jzo2o.orders.base.mapper.OrdersDispatchMapper;
import com.jzo2o.orders.base.mapper.OrdersSeizeMapper;
import com.jzo2o.orders.base.model.domain.Orders;
import com.jzo2o.orders.base.model.domain.OrdersDispatch;
import com.jzo2o.orders.base.model.domain.OrdersSeize;
import com.jzo2o.orders.base.service.IOrdersDiversionCommonService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.time.Duration;
@Service
@Slf4j
public class OrdersDiversionCommonServiceImpl implements IOrdersDiversionCommonService {
@Resource
private RedisTemplate<String, Long> redisTemplate;
@Resource
private RegionApi regionApi;
@Resource
private ServeApi serveApi;
@Resource
private OrdersDiversionCommonServiceImpl owner;
@Resource
private OrdersSeizeMapper ordersSeizeMapper;
@Resource
private OrdersDispatchMapper ordersDispatchMapper;
@Override
public void diversion(Orders orders) {
log.debug("订单分流,id:{}",orders.getId());
// 1.当前时间已超过服务预约时间则不再分流
if (orders.getServeStartTime().compareTo(DateUtils.now()) < 0) {
log.debug("订单{}当前时间已超过服务预约时间则不再分流",orders.getId());
return;
}
ConfigRegionInnerResDTO configRegion = regionApi.findConfigRegionByCityCode(orders.getCityCode());
ServeAggregationResDTO serveAggregationResDTO = serveApi.findById(orders.getServeId());
//订单分流数据存储
owner.diversionCommit(orders,configRegion,serveAggregationResDTO);
}
@Transactional(rollbackFor = Exception.class)
public void diversionCommit(Orders orders, ConfigRegionInnerResDTO configRegion, ServeAggregationResDTO serveAggregationResDTO) {
//流间隔(单位分钟),即当前时间与服务预计开始时间的间隔
Integer diversionInterval = configRegion.getDiversionInterval();
//当前时间与服务预约时间的间隔
Duration between = DateUtils.between(DateUtils.now(), orders.getServeStartTime());
//服务类型名称
String serveTypeName = ObjectUtils.get(serveAggregationResDTO, ServeAggregationResDTO::getServeTypeName);
//服务类型id
Long serveTypeId = ObjectUtils.get(serveAggregationResDTO, ServeAggregationResDTO::getServeTypeId);
//服务项名称
String serveItemName = ObjectUtils.get(serveAggregationResDTO, ServeAggregationResDTO::getServeItemName);
//服务项图片
String serveItemImg = ObjectUtils.get(serveAggregationResDTO, ServeAggregationResDTO::getServeItemImg);
//用于排序,服务预约时间戳加订单号后5位
long sortBy = DateUtils.toEpochMilli(orders.getServeStartTime()) + orders.getId() % 100000;
OrdersSeize ordersSeize = OrdersSeize.builder()
.id(orders.getId())
.ordersAmount(orders.getRealPayAmount())
.cityCode(orders.getCityCode())
.serveTypeId(serveTypeId)
.serveTypeName(serveTypeName)
.serveItemId(orders.getServeItemId())
.serveItemName(serveItemName)
.serveItemImg(serveItemImg)
.ordersAmount(orders.getRealPayAmount())
.serveStartTime(orders.getServeStartTime())
.serveAddress(orders.getServeAddress())
.lon(orders.getLon())
.lat(orders.getLat())
.paySuccessTime(DateUtils.now())
.paySuccessTime(orders.getPayTime())
.sortBy(sortBy)
.isTimeOut(BooleanUtils.toInt(between.toMinutes() < diversionInterval))
.purNum(orders.getPurNum()).build();
ordersSeizeMapper.insert(ordersSeize);
//当前时间与服务预约时间的间隔 小于指定间隔则插入派单表
if (between.toMinutes() < diversionInterval) {
OrdersDispatch ordersDispatch = OrdersDispatch.builder()
.id(orders.getId())
.ordersAmount(orders.getRealPayAmount())
.cityCode(orders.getCityCode())
.serveTypeId(serveTypeId)
.serveTypeName(serveTypeName)
.serveItemId(orders.getServeItemId())
.serveItemName(serveItemName)
.serveItemImg(serveItemImg)
.ordersAmount(orders.getRealPayAmount())
.serveStartTime(orders.getServeStartTime())
.serveAddress(orders.getServeAddress())
.lon(orders.getLon())
.lat(orders.getLat())
.purNum(orders.getPurNum()).build();
ordersDispatchMapper.insert(ordersDispatch);
}
}
}
4)实现订单分流
根据需求,订单支付成功进行订单分流,在支付成功的方法添加订单分流的代码:
@Transactional(rollbackFor = Exception.class)
public void paySuccess(TradeStatusMsg tradeStatusMsg) {
//查询订单
Orders orders = baseMapper.selectById(tradeStatusMsg.getProductOrderNo());
//2:待支付,4:支付成功
if (ObjectUtil.notEqual(OrderPayStatusEnum.NO_PAY.getStatus(), orders.getPayStatus())) {
log.info("当前订单:{},不是待支付状态", orders.getId());
return;
}
//第三方支付单号校验
if (ObjectUtil.isEmpty(tradeStatusMsg.getTransactionId())) {
throw new CommonException("支付成功通知缺少第三方支付单号");
}
// 修改订单状态和支付状态
OrderSnapshotDTO orderSnapshotDTO = OrderSnapshotDTO.builder()
.payTime(LocalDateTime.now())
.tradingOrderNo(tradeStatusMsg.getTradingOrderNo())
.tradingChannel(tradeStatusMsg.getTradingChannel())
.thirdOrderId(tradeStatusMsg.getTransactionId())
.payStatus(OrderPayStatusEnum.PAY_SUCCESS.getStatus())
.build();
orderStateMachine.changeStatus(orders.getUserId(), String.valueOf(orders.getId()), OrderStatusChangeEventEnum.PAYED, orderSnapshotDTO);
// 订单分流
ordersDiversionService.diversion(orders);
}
5)调度配置
登录运营端,进入区域配置,针对运营区域进行调度配置
此操作向config_region表保存配置,config_region表的数据是在添加区域时同时写入的。
如果在区域管理界面可以查询到区域在此表不存在数据是因为脏数据导致,需要手动向config_region表添加区域,config_region表的city_code和id字段与region表保持 一致。
5)测试
测试流程:
启动网关
启动jzo2o-orders-manager
启动jzo2o-foundations
启动小程序
用户下单并支付成功。
观察订单是否写入抢单池表。
派单池暂不测试。
预期:
成功写入抢单池表
为了测试方便我们修改下单的代码,下单成功默认支付成功。
在nacos的jzo2o-orders-manager.yaml配置jzo2o.openPay参数:
jzo2o:
openPay: false
修改下单service方法如下:
@Value("${jzo2o.openPay}")
private Boolean openPay;
public PlaceOrderResDTO placeOrder(Long userId, PlaceOrderReqDTO placeOrderReqDTO) {
//....
//todo 测试需要默认支付成功
if (Boolean.FALSE.equals(openPay)) {
TradeStatusMsg msg = TradeStatusMsg.builder()
.productOrderNo(String.valueOf(orders.getId()))
.tradingChannel("WECHAT_PAY")
.statusCode(TradingStateEnum.YJS.getCode())
.tradingOrderNo(IdUtil.getSnowflakeNextId())
.transactionId(IdUtils.getSnowflakeNextIdStr())
.build();
owner.paySuccess(msg);
}
return new PlaceOrderResDTO(orders.getId());
}
测试示例:
新下单:
下单成功进入支付页面,
因为我们为了测试方便设置了自动支付成功,上边的支付界面 不用管,直接进入我的订单列表查看此订单状态为派单中,这说明订单支付成功。
检查抢单池表,成功向抢单池添加成功。
{
"id": 2311150000000000038,
"city_code": "010",
"serve_type_id": 1678649931106705409,
"serve_item_name": "日常保洁",
"serve_type_name": "保洁清",
"serve_item_id": 1685894105234755585,
"serve_address": "北京市北京市昌平区北京市昌平区回龙观街道弘文恒瑞文化传播公司正泽商务中心",
"serve_item_img": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png",
"orders_amount": 1.00,
"serve_start_time": "2023-11-15 20:30:00",
"pay_success_time": null,
"lon": 116.34351,
"lat": 40.06024,
"pur_num": 1,
"is_time_out": 0,
"sort_by": 1700051400038,
"create_time": "2023-11-15 15:06:11",
"update_time": "2023-11-15 15:06:11"
}
6)小结
订单分流的业务流程是什么?
-
用户下单且支付成功
-
根据服务预约时间与当前时间的间隔判断,如果间隔小于2小时(默认)将订单信息写入派单池表和抢单池表。
-
如果服务预约时间与当前时间的间隔大于2小时将订单信息只写入抢单池表。
3.5 抢单查询
3.5.1 抢单池同步
1)同步方案
根据系统设计方案,抢单池的信息需要同步到Elasticsearch,使用Canal加RabbitMQ完成同步,如下:
暂时无法在飞书文档外展示此内容
2) 索引结构设计
在ES中创建索引结构orders_seize,抢单池的信息将同步到orders_seize中。
启动ES和kibana
docker start elasticsearch7.17.7
docker start kibana7.17.7
通过下边的命令创建orders_seize索引结构:
PUT /orders_seize
{
"mappings" : {
"properties" : {
"city_code" : {
"type" : "keyword"
},
"id" : {
"type" : "long"
},
"key_words" : {
"type" : "text",
"analyzer" : "ik_max_word",
"search_analyzer" : "ik_smart"
},
"location" : {
"type" : "geo_point"
},
"orders_amount" : {
"type" : "float"
},
"pur_num" : {
"type" : "integer"
},
"serve_address" : {
"type" : "text",
"index" : false
},
"serve_item_id" : {
"type" : "long"
},
"serve_item_img" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"serve_item_name" : {
"type" : "text",
"index" : false
},
"serve_start_time" : {
"type" : "text",
"index" : false
},
"serve_time" : {
"type" : "integer"
},
"serve_type_id" : {
"type" : "long"
},
"serve_type_name" : {
"type" : "text",
"index" : false
},
"total_amount" : {
"type" : "double"
}
}
}
}
创建成功进行查询 索引列表,命令如下,找到orders_seize。
GET /_cat/indices?v
查询orders_seize的索引结构,命令如下,对照是否和上边创建的orders_seize的索引一致。
GET orders_seize
或
GET /orders_seize/_mapping
3) 抢单池库存结构
抢单池库存信息存储在Redis,在抢单时通过Redis扣减库存。
设计如下:
缓存结构:Hash
RedisKey:ORDERS:RESOURCE:STOCK:{citycode%10}
HashKey:订单id
HashValue: 库存,为1
过期时间:永不过期
缓存一致性方案:通过Canal进行同步
暂时无法在飞书文档外展示此内容
4)阅读代码
阅读下边的代码:
package com.jzo2o.orders.seize.handler;
import com.jzo2o.canal.listeners.AbstractCanalRabbitMqMsgListener;
import com.jzo2o.common.model.Location;
import com.jzo2o.common.utils.BeanUtils;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.es.core.ElasticSearchTemplate;
import com.jzo2o.orders.base.constants.RedisConstants;
import com.jzo2o.orders.base.model.domain.OrdersSeize;
import com.jzo2o.orders.base.utils.RedisUtils;
import com.jzo2o.orders.seize.model.domain.OrdersSeizeInfo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import static com.jzo2o.orders.base.constants.EsIndexConstants.ORDERS_SEIZE;
/**
* 抢单池同步类
*/
@Component
@Slf4j
public class OrdersSeizeSyncHandler extends AbstractCanalRabbitMqMsgListener<OrdersSeize> {
@Resource
private ElasticSearchTemplate elasticSearchTemplate;
@Resource
private RedisTemplate redisTemplate;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-orders-seize"),
exchange = @Exchange(name = "exchange.canal-jzo2o", type = ExchangeTypes.TOPIC),
key = "canal-mq-jzo2o-orders-seize"),
concurrency = "1"
)
public void onMessage(Message message) throws Exception {
parseMsg(message);
}
@Override
public void batchSave(List<OrdersSeize> ordersSeizes) {
// 1.es中添加抢单信息
List<OrdersSeizeInfo> ordersSeizeInfos = ordersSeizes.stream().map(ordersSeize -> {
OrdersSeizeInfo ordersSeizeInfo = BeanUtils.toBean(ordersSeize, OrdersSeizeInfo.class);
//得到服务开始时间(yyMMddHH)
String serveTimeString = DateTimeFormatter.ofPattern("yyMMddHH").format(ordersSeize.getServeStartTime());
ordersSeizeInfo.setServeTime(Integer.parseInt(serveTimeString));
ordersSeizeInfo.setLocation(new Location(ordersSeize.getLon(), ordersSeize.getLat()));
ordersSeizeInfo.setKeyWords(ordersSeize.getServeTypeName() + ordersSeize.getServeItemName() + ordersSeize.getServeAddress());
return ordersSeizeInfo;
}).collect(Collectors.toList());
Boolean result = elasticSearchTemplate.opsForDoc().batchInsert(ORDERS_SEIZE, ordersSeizeInfos);
if (!result){
throw new RuntimeException("同步抢单池加入es失败");
}
// 2.写入库存
ordersSeizeInfos.stream().forEach(ordersSeizeInfo -> {
String redisKey = String.format(RedisConstants.RedisKey.ORDERS_RESOURCE_STOCK, RedisUtils.getCityIndex(ordersSeizeInfo.getCityCode()));
// 库存默认1
redisTemplate.opsForHash().putIfAbsent(redisKey, ordersSeizeInfo.getId(), 1);
});
}
@Override
public void batchDelete(List<Long> ids) {
log.info("抢单删除开始,删除数量:{},开始id:{},结束id:{}", CollUtils.size(ids), CollUtils.getFirst(ids), CollUtils.getLast(ids));
Boolean result = elasticSearchTemplate.opsForDoc().batchDelete(ORDERS_SEIZE, ids);
if (!result){
throw new RuntimeException("同步抢单池加入es失败");
}
log.info("抢单删除结束,删除数量:{},开始id:{},结束id:{}", CollUtils.size(ids), CollUtils.getFirst(ids), CollUtils.getLast(ids));
}
}
5)测试
-
首先在rabbitmq中创建canal-mq-jzo2o-orders-seize队列并绑定到exchange.canal-jzo2o交换机
-
然后配置Canal
在Canal中配置order_seize为同步表。
上边的配置中为什么只配置了jzo2o-orders-0下的order_seize为同步表?jzo2o-orders-1和jzo2o-orders-2需要配置吗?
order_seize为广播表,jzo2o-orders-0、jzo2o-orders-1、jzo2o-orders-2三个数据库中的order_seize表数据是一致的,所以只配置其中一个数据库的order_seize为同步表即可。
- 下边进行测试
测试流程:
启动Canal
启动RabbitMQ
启动Elasticsearch
启动Kibana
启动Redis
启动jzo2o-orders-seize
启动jzo2o-orders-manager
启动网关
启动小程序
用户下单并支付成功。
观察订单是否写入抢单池表。
在OrdersSeizeSyncHandler 中的batchSave(List
注意:关于canal数据不同步问题参考“配置搜索及数据同步环境v1.0” 中常见问题进行解决。
示例:
在同步类中打断点
下单成功,执行到断点位置
程序进入断点说明收到抢单池同步数据
继续运行程序,数据将写入ES
登录kibana,执行:GET /orders_seize/_search,查询抢单数据是否同步到ES。
#! Elasticsearch built-in security features are not enabled. Without authentication, your cluster could be accessible to anyone. See https://www.elastic.co/guide/en/elasticsearch/reference/7.17/security-minimal-setup.html to enable security.
{
"took" : 261,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 1,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
{
"_index" : "orders_seize",
"_type" : "_doc",
"_id" : "2311150000000000038",
"_score" : 1.0,
"_source" : {
"id" : 2311150000000000038,
"city_code" : "010",
"serve_type_id" : 1678649931106705409,
"serve_item_id" : 1685894105234755585,
"serve_type_name" : "保洁清",
"serve_item_name" : "日常保洁",
"serve_address" : "北京市北京市昌平区北京市昌平区回龙观街道弘文恒瑞文化传播公司正泽商务中心",
"serve_time" : 23111520,
"location" : {
"lon" : 116.34351,
"lat" : 40.06024
},
"serve_start_time" : "2023-11-15 20:30:00",
"pur_num" : 1,
"key_words" : "保洁清日常保洁北京市北京市昌平区北京市昌平区回龙观街道弘文恒瑞文化传播公司正泽商务中心",
"orders_amount" : 1.0,
"serve_item_img" : "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png"
}
}
]
}
}
下边进入redis查看抢单库存是否同步成功:
hashkey: 订单id
hashvalue:库存数量
5)小结
抢单池是怎么设计的?
-
在数据库中创建抢单池表,存储待抢单的订单信息。
-
用户下单并支付成功将订单信息写入订单表和抢单池表。
-
通过Canal+MQ将抢单池的信息同步到Elasticsearch和Redis中。
同步到Elasticsearch是为了通过ES的地理坐标搜索功能查询订单信息。
同步到Redis是为了将抢单池库存信息同步到Redis,服务人员抢单时请求Redis完成。
-
服务人员抢单完成扣减Redis中抢单池的库存,并记录抢单结果。
-
最后通过异步任务将抢单结果同步到数据库。
3.5.2 抢单查询
1)接口定义
接口名称:抢单查询列表
接口功能:查询抢单池的订单信息
接口路径:GET/orders-seize/worker
请求参数:
响应参数:
定义controller
@RestController
@Api(tags = "服务端 - 抢单相关接口")
@RequestMapping("/worker")
@Slf4j
public class WorkerOrdersSeizeController {
@GetMapping("")
@ApiOperation("服务端抢单列表")
public OrdersSeizeListResDTO queryForList(OrdersSerizeListReqDTO ordersSerizeListReqDTO) {
return null;
}
...
2)接口开发
- 首先在Kibana中测试查询抢单语句
下边表示根据服务人员的服务范围、服务技能搜索抢单池:
GET /orders_seize/_search
{
"query": {
"bool": {
"must": [
{
"term": {
"city_code": {
"value": "010"
}
}
},
{
"terms": {
"serve_item_id": [
1685894105234755585,
1683432288440897537,
1678727478181957634,
1692475107114487809
]
}
},
{
"geo_distance": {
"location": {
"lat": 40.008,
"lon": 116.4343
},
"distance": "3.0km"
}
}
]
}
},
"sort": [
{
"_geo_distance": {
"location": [
{
"lat": 40.008,
"lon": 116.4343
}
],
"distance_type": "arc",// 距离计算方式
"order": "asc", // "asc" 表示升序,"desc" 表示降序
"unit": "km"//// 单位,可以是 "km"、"mi"(英里)等
}
}
]
}
服务人员可以选择服务类型和距离进行筛选
GET /orders_seize/_search
{
"query": {
"bool": {
"must": [
{
"term": {
"city_code": {
"value": "010"
}
}
},
{
"term": {
"serve_type_id": {
"value": 1678649931106705409
}
}
},
{
"terms": {
"serve_item_id": [
1685894105234755585,
1683432288440897537,
1678727478181957634,
1692475107114487809
]
}
},
{
"geo_distance": {
"location": {
"lat": 40.008,
"lon": 116.4343
},
"distance": "20.0km"
}
}
]
}
},
"sort": [
{
"_geo_distance": {
"location": [
{
"lat": 40.008,
"lon": 116.4343
}
],
"distance_type": "arc",// 距离计算方式
"order": "asc", // "asc" 表示升序,"desc" 表示降序
"unit": "km"//// 单位,可以是 "km"、"mi"(英里)等
}
}
]
}
根据上边编写语句通过Java代码查询ES:
service方法如下:
@Service
@Slf4j
public class OrdersSeizeServiceImpl extends ServiceImpl<OrdersSeizeMapper, OrdersSeize> implements IOrdersSeizeService {
@Override
public OrdersSeizeListResDTO queryForList(OrdersSerizeListReqDTO ordersSerizeListReqDTO) {
// 1.校验是否可以查询(认证通过,开启抢单)
ServeProviderResDTO detail = serveProviderApi.getDetail(UserContext.currentUserId());
// 验证设置状态
if (detail.getSettingsStatus() != 1 || !detail.getCanPickUp()) {
return OrdersSeizeListResDTO.empty();
}
// 2.查询准备 (距离、技能,时间冲突)
// 距离
Double serveDistance = ordersSerizeListReqDTO.getServeDistance();
if(ObjectUtils.isNull(ordersSerizeListReqDTO.getServeDistance())) {
// 区域默认配置配置
ConfigRegionInnerResDTO configRegionInnerResDTO = regionApi.findConfigRegionByCityCode(detail.getCityCode());
serveDistance = (detail.getType() == UserType.INSTITUTION)
? configRegionInnerResDTO.getInstitutionServeRadius().doubleValue() : configRegionInnerResDTO.getStaffServeRadius().doubleValue();
}
// 技能
List<Long> serveItemIds = serveSkillApi.queryServeSkillListByServeProvider(UserContext.currentUserId(), UserContext.currentUser().getUserType(), detail.getCityCode());
if(CollUtils.isEmpty(serveItemIds)) {
log.info("当前机构或服务人员没有对应技能");
return OrdersSeizeListResDTO.empty();
}
// 3.查询符合条件的抢单列表id
List<OrdersSeizeListResDTO.OrdersSeize> ordersSeizes = getOrdersSeizeId(
serveItemIds, detail.getLon(), detail.getLat(), serveDistance, detail.getCityCode(), ordersSerizeListReqDTO);
return new OrdersSeizeListResDTO(CollUtils.defaultIfEmpty(ordersSeizes, new ArrayList<>()));
}
/**
* 获取抢单id,抢单类型,抢单预约服务时间
* @param serveItemIds 服务项id
* @param lon 当前服务人员或机构所在位置经度
* @param lat 当前服务人员或机构所在纬度
* @param distanceLimit 抢单距离限制
* @param cityCode 城市编码
* @param ordersSerizeListReqDTO 抢单查询参数
* @return
*/
private List<OrdersSeizeListResDTO.OrdersSeize> getOrdersSeizeId(List<Long> serveItemIds, Double lon, Double lat, double distanceLimit, String cityCode, OrdersSerizeListReqDTO ordersSerizeListReqDTO) {
// 服务项查询条件
List<FieldValue> serveItemIdFieldValue = serveItemIds.stream().map(serveItemId -> FieldValue.of(serveItemId)).collect(Collectors.toList());
SearchRequest.Builder builder = new SearchRequest.Builder();
builder.query(query ->
query.bool(bool -> {
// 所在城市
bool.must(must -> must.term(term -> term.field(CITY_CODE).value(cityCode)));
// 服务类型
if(ordersSerizeListReqDTO.getServeTypeId() != null) {
bool.must(must -> must.term(term -> term.field(SERVE_TYPE_ID).value(ordersSerizeListReqDTO.getServeTypeId())));
}
// 服务项
bool.must(must -> must.terms(terms -> terms.field(SERVE_ITEM_ID).terms(t -> t.value(serveItemIdFieldValue))));
// 距离条件
bool.must(must -> {
must.geoDistance(geoDistance -> {
geoDistance.field(LOCATION);
geoDistance.location(location -> location.latlon(latlon -> latlon.lon(lon).lat(lat)));
geoDistance.distance(distanceLimit + "km");
return geoDistance;});
return must;
});
// 关键字匹配 满足一个字段即可 服务项名称,服务类型名称,服务地址
if (StringUtils.isNotEmpty(ordersSerizeListReqDTO.getKeyWord())) {
bool.must(must -> must.match(match -> match.field(FieldConstants.KEY_WORDS).query(ordersSerizeListReqDTO.getKeyWord())));
}
return bool;
}));
// 排序 根据距离排序
List<SortOptions> sortOptions = new ArrayList<>();
sortOptions.add(SortOptions.of(sortOption -> sortOption.geoDistance(
geoDistance -> {
geoDistance.field(LOCATION);
geoDistance.distanceType(GeoDistanceType.Arc);
geoDistance.order(SortOrder.Asc);
geoDistance.unit(DistanceUnit.Kilometers);
geoDistance.location(location -> location.latlon(latlon -> latlon.lat(lat).lon(lon)));
return geoDistance;
}
)));
builder.sort(sortOptions);
// 索引
builder.index(EsIndexConstants.ORDERS_SEIZE);
// 滚动分页,根据距离滚动分页
if (ordersSerizeListReqDTO.getLastRealDistance() != null) {
builder.searchAfter(ordersSerizeListReqDTO.getLastRealDistance().toString());
}
// 检索数据
SearchResponse<OrdersSeizeInfo> searchResponse = elasticSearchTemplate.opsForDoc().search(builder.build(), OrdersSeizeInfo.class);
if (SearchResponseUtils.isSuccess(searchResponse)) {
return searchResponse.hits().hits()
.stream().map(hit -> {
// 从sort字段中获取实际距离
double realDistance = NumberUtils.parseDouble(CollUtils.getFirst(hit.sort()));
OrdersSeizeListResDTO.OrdersSeize ordersSeize = BeanUtils.toBean(hit.source(), OrdersSeizeListResDTO.OrdersSeize.class);
ordersSeize.setRealDistance(realDistance);
return ordersSeize;
})
.collect(Collectors.toList());
}
return null;
}
2、controller
@GetMapping("")
@ApiOperation("服务端抢单列表")
public OrdersSeizeListResDTO queryForList(OrdersSerizeListReqDTO ordersSerizeListReqDTO) {
return ordersSeizeService.queryForList(ordersSerizeListReqDTO);
}
3) 测试
首先修改抢单服务的配置文件,使用shardingsphere的驱动
下边进行测试:
启动Canal
启动RabbitMQ
启动Elasticsearch
启动Kibana
启动Redis
启动jzo2o-orders-manager
启动jzo2o-orders-seize
启动jzo2o-customer
启动jzo2o-publics
启动jzo2o-foundations
启动网关
首先启动小程序下单
然后启动服务端前端程序(通过浏览器运行)
服务人员登录服务端程序,进入抢单界面,查询抢单信息。
预期结果:
北京的家政服务人员成功查询到符合条件抢单信息。
具体查询语句在service方法中打断点跟踪
复制query语句,在kibana中调试。
4)小结
搜索附近怎么实现?
对于地理坐标不变的场景,比如:搜索附近的酒店、搜索附近银行等,可以提前将搜索目标信息同步到Elasticsearch中,再通过Elasticsearch的geo去根据地理坐标去搜索附近几公里内的酒店、银行等。
使用geo时在索引中设置geo_point类型的字段,查询时需要传入经纬度坐标及距离(公里),将查询以此经纬度坐标为中心方圆几公里的信息。
对于地理坐标变的场景,比如:搜索附近的骑手、搜索附近的出租车,这里就需要在手机定时上报坐标到系统中,系统收到上传的坐标更新至Elasticsearch中,再通过geo去搜索附近的骑手、搜索附近的出租车等。
3.6 抢单
1) 抢单流程
暂时无法在飞书文档外展示此内容
说明:
参考抢券的方案,使用Redis+Lua的技术解决超卖问题,这里将抢单库存同步到Redis,每个订单的库存就是1。
抢单执行Lua脚本完成抢单,具体包括:扣减库存、抢单成功写入同步队列。
抢单同步队列的作用是通过异步任务将抢单结果信息同步到数据库。
2) Redis数据结构设计
- 抢单同步队列
缓存结构:Hash
RedisKey:QUEUE:ORDERS:SEIZE:SYNC:{citycode%10}
HashKey:订单id
HashValue:多值拼接中间用逗号分隔,分别为:被派单服务人员id/机构id、服务人员类型(2,服务人员,3:机构端),是否是机器抢单(1:机器抢单,0:人工抢单)
过期时间:永不过期
暂时无法在飞书文档外展示此内容
- 抢单库存结构
缓存结构:Hash
RedisKey:ORDERS:RESOURCE:STOCK:{citycode%10}
HashKey:订单id
HashValue: 库存,为1
过期时间:永不过期
缓存一致性方案:通过Canal进行同步
暂时无法在飞书文档外展示此内容
3)阅读代码
- 接口定义
接口名称:抢单接口
接口功能:抢单
接口路径:POST/orders-seize/worker
请求参数:
响应参数:
抢单结果 大于0抢单成功,-1/-2:库存数量不足,-3:抢单失败
controller方法如下:
package com.jzo2o.orders.seize.controller.worker;
import com.jzo2o.mvc.utils.UserContext;
import com.jzo2o.orders.seize.model.dto.request.OrdersSeizeReqDTO;
import com.jzo2o.orders.seize.model.dto.request.OrdersSerizeListReqDTO;
import com.jzo2o.orders.seize.model.dto.response.OrdersSeizeListResDTO;
import com.jzo2o.orders.seize.service.IOrdersSeizeService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiImplicitParam;
import io.swagger.annotations.ApiImplicitParams;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
@RestController
@Api(tags = "服务端 - 抢单相关接口")
@RequestMapping("/worker")
@Slf4j
public class WorkerOrdersSeizeController {
@Resource
private IOrdersSeizeService ordersSeizeService;
@GetMapping("")
@ApiOperation("服务端抢单列表")
public OrdersSeizeListResDTO queryForList(OrdersSerizeListReqDTO ordersSerizeListReqDTO) {
return ordersSeizeService.queryForList(ordersSerizeListReqDTO);
}
@PostMapping("")
@ApiOperation("服务端抢单")
public void seize(@RequestBody OrdersSeizeReqDTO ordersSeizeReqDTO) {
ordersSeizeService.seize(ordersSeizeReqDTO.getId(), UserContext.currentUserId(), UserContext.currentUser().getUserType(), false);
}
}
- service方法
@Service
@Slf4j
public class OrdersSeizeServiceImpl extends ServiceImpl<OrdersSeizeMapper, OrdersSeize> implements IOrdersSeizeService {
public void seize(Long id, Long serveProviderId, Integer serveProviderType, Boolean isMatchine) {
// 1.抢单校验
// 1.1.校验是否可以查询(认证通过,开启抢单)
ServeProviderResDTO detail = serveProviderApi.getDetail(serveProviderId);
if (!detail.getCanPickUp() || detail.getSettingsStatus() != 1) {
throw new CommonException(ErrorInfo.Code.SEIZE_ORDERS_FAILD, SEIZE_ORDERS_RECEIVE_CLOSED);
}
// 1.2.校验抢单资源是否存在
OrdersSeize ordersSeize = ordersSeizeService.getById(id);
// 校验订单是否还存在,如果订单为空或id不存在,则认为订单已经不在
if (ordersSeize == null || ObjectUtils.isNull(ordersSeize.getId())) {
throw new CommonException(ErrorInfo.Code.SEIZE_ORDERS_FAILD, SEIZE_ORDERS_FAILD);
}
ConfigRegionInnerResDTO configRegionInnerResDTO = regionApi.findConfigRegionByCityCode(detail.getCityCode());
// 城市编码最后1位序号
int index = RedisUtils.getCityIndex(detail.getCityCode());
// 1.3.校验时间冲突
// 服务时间状态redisKey
String serveProviderStateRedisKey = String.format(SERVE_PROVIDER_STATE, index);
// 1.4.订单数量已达上限
// 接单数量上限
int receiveOrderMax = (serveProviderType == UserType.INSTITUTION) ? configRegionInnerResDTO.getInstitutionReceiveOrderMax() : configRegionInnerResDTO.getStaffReceiveOrderMax();
Object ordersNum = redisTemplate.opsForHash().get(serveProviderStateRedisKey, serveProviderId + "_num");
if(ObjectUtils.isNotNull(ordersNum) && NumberUtils.parseInt(ordersNum.toString()) >= receiveOrderMax){
throw new CommonException(ErrorInfo.Code.SEIZE_ORDERS_FAILD, SEIZE_ORDERS_RECEIVE_ORDERS_NUM_OVER);
}
// 2.执行redis脚本
// 2.1.redisKey
// 抢单结果同步队列 redis key
String ordersSeizeSyncRedisKey = RedisSyncQueueUtils.getQueueRedisKey(RedisConstants.RedisKey.ORERS_SEIZE_SYNC_QUEUE_NAME, index);
// 库存redisKey
String resourceStockRedisKey = String.format(ORDERS_RESOURCE_STOCK, index);
log.debug("抢单key:{},values:{}", Arrays.asList(ordersSeizeSyncRedisKey, resourceStockRedisKey),
Arrays.asList(id, serveProviderId,serveProviderType));
// 2.2.执行lua脚本
Object execute = redisTemplate.execute(seizeOrdersScript,
Arrays.asList(ordersSeizeSyncRedisKey, resourceStockRedisKey),
id, serveProviderId,serveProviderType,isMatchine ? 1 : 0);
log.debug("抢单结果 : {}", execute);
// 3.处理lua脚本结果
if (execute == null) {
throw new CommonException(ErrorInfo.Code.SEIZE_ORDERS_FAILD, SEIZE_ORDERS_FAILD);
}
// 4.抢单结果判断 大于0抢单成功,-1/-2:库存数量不足,-3:抢单失败
long result = NumberUtils.parseLong(execute.toString());
if(result < 0) {
throw new CommonException(ErrorInfo.Code.SEIZE_ORDERS_FAILD, SEIZE_ORDERS_FAILD);
}
}
4)测试
测试流程:
启动Canal:docker start canal
启动RabbitMQ:docker start rabbitmq
启动Elasticsearch: docker start elasticsearch7.17.7
启动Kibana: docker start kibana7.17.7
启动Redis:docker start redis
启动jzo2o-orders-manager
启动jzo2o-orders-seize
启动网关
启动服务端前端程序(通过浏览器运行)
服务人员登录服务端程序,进入抢单界面,查询抢单信息,点击“立即抢单”
预期结果:
抢单界面提示:抢单成功
在redis成功写入抢单同步记录
在redis成功扣除库存
抢单失败:
如果抢单失败报下边的提示:
或
首先排查 jzo2o-orders-seize是否启动
在抢单service方法中断点跟踪程序运行。
4)小结
抢单是如何防止超卖的?
3.7 抢单结果异步处理
1)异步处理方案
抢单成功根据抢单结果进行异步处理:
交互流程如下:
暂时无法在飞书文档外展示此内容
创建服务单:
服务单记录了服务人员进行家政服务的信息,关键字段有:订单ID、订单金额、服务人员ID、服务单状态、服务时间、服务照片等。
服务单初始状态:待分配或待服务
机构抢单成功:待分配。
服务人员抢单成功:待服务。
更新订单的状态:
用户下单并支付完成后订单的状态为“派单中”,服务人员抢单成功订单状态为“待服务”,机构抢单成功订单状态为“待分配”。
抢单结果同步成功删除抢单池等相关信息:
抢单结果同步成功后删除抢单池中该订单的信息,这样服务人员在抢单界面无法查询到该订单。
删除数据库中抢单池的记录,通过Canal将Elasticsearch中对应的抢单记录删除。
删除Redis中该订单的库存信息。
删除Redis中抢单同步队列中的记录。
2) 表设计
服务单表设计
-- 服务任务
-- auto-generated definition
create table orders_serve_0
(
id bigint not null comment '任务id'
primary key,
user_id bigint null comment '属于哪个用户',
serve_provider_id bigint not null comment '服务人员或服务机构id',
serve_provider_type int null comment '服务者类型,2:服务端服务,3:机构端服务',
institution_staff_id bigint null comment '机构服务人员id',
orders_id bigint null comment '订单id',
orders_origin_type int not null comment '订单来源类型,1:抢单,2:派单',
city_code varchar(50) not null comment '城市编码',
serve_type_id bigint not null comment '服务分类id',
serve_start_time datetime null comment '预约时间',
serve_item_id bigint not null comment '服务项id',
serve_status int not null comment '任务状态',
settlement_status int default 0 not null comment '结算状态,0:不可结算,1:待结算,2:结算完成',
real_serve_start_time datetime null comment '实际服务开始时间',
real_serve_end_time datetime null comment '实际服务完结时间',
serve_before_imgs json null comment '服务前照片',
serve_after_imgs json null comment '服务后照片',
serve_before_illustrate varchar(255) null comment '服务前说明',
serve_after_illustrate varchar(255) null comment '服务后说明',
cancel_time datetime null comment '取消时间,可以是退单,可以是取消时间',
orders_amount decimal(10, 2) null comment '订单金额',
pur_num int null comment '购买数量',
create_time datetime default CURRENT_TIMESTAMP null comment '创建时间',
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '更新时间',
sort_by bigint null comment '排序字段(serve_start_time(秒级时间戳)+订单id(后6位))',
display int default 1 null comment '服务端/机构端是否展示,1:展示,0:隐藏',
is_deleted int default 0 null comment '是否是逻辑删除',
update_by bigint null comment '更新人'
)
comment '服务任务' charset = utf8mb4;
分库分表策略:
分库策略:按服务人员id分库,表达式:jzo2o-orders-${serve_provider_id % 3}
分表策略:orders_serve_${(int)Math.floor(id % 10000000000 / 15000000)}
3)阅读代码
package com.jzo2o.orders.seize.handler;
import cn.hutool.json.JSONArray;
import com.jzo2o.api.customer.ServeProviderApi;
import com.jzo2o.api.customer.dto.response.ServeProviderResDTO;
import com.jzo2o.common.utils.JsonUtils;
import com.jzo2o.common.utils.NumberUtils;
import com.jzo2o.orders.base.model.domain.OrdersSeize;
import com.jzo2o.orders.seize.service.IOrdersSeizeService;
import com.jzo2o.redis.handler.SyncProcessHandler;
import com.jzo2o.redis.model.SyncMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
/**
* 抢单成功同步任务
*/
@Component("ORDERS:SEIZE:SYNC")
@Slf4j
public class SeizeSyncProcessHandler implements SyncProcessHandler<Object> {
@Resource
private IOrdersSeizeService ordersSeizeService;
@Resource
private ServeProviderApi serveProviderApi;
@Override
public void batchProcess(List<SyncMessage<Object>> multiData) {
throw new RuntimeException("不支持批量处理");
}
@Override
public void singleProcess(SyncMessage<Object> singleData) {
log.info("抢单结果同步开始 id : {}",singleData.getKey());
// 抢单信息放在value中,内容格式:[serveProviderId,serveProviderType,isMatchine(0,表示人工抢单,1:表示机器抢单)]
JSONArray seizeResult = JsonUtils.parseArray(singleData.getValue());
// 服务人员或机构id
Long serveProviderId = seizeResult.getLong(0);
// 用户类型
Integer serveProviderType = seizeResult.getInt(1);
// 是否是机器抢单
boolean isMatchine = seizeResult.getBool(2);
// 抢单id
Long seizeId = NumberUtils.parseLong(singleData.getKey());
// 抢单不在无需继续处理
OrdersSeize ordersSeize = ordersSeizeService.getById(seizeId);
if (ordersSeize == null) {
return;
}
// 处理抢单结果
ordersSeizeService.seizeOrdersSuccess(ordersSeize, serveProviderId, serveProviderType, isMatchine);
log.info("抢单结果同步结束 id : {}",singleData.getKey());
}
}
ordersSeizeService.seizeOrdersSuccess()方法如下:
@Transactional(rollbackFor = Exception.class)
public void seizeOrdersSuccess(OrdersSeize ordersSeize, Long serveProviderId, Integer serveProviderType, Boolean isMatchine) {
// 1.校验服务单是否已经生成
OrdersServe ordersServeInDb = ordersServeService.findById(ordersSeize.getId());
if(ordersServeInDb != null){
return;
}
// 2.生成服务单,
OrdersServe ordersServe = BeanUtils.toBean(ordersSeize, OrdersServe.class);
ordersServe.setCreateTime(null);
ordersServe.setUpdateTime(null);
// 服务单状态 机构抢单状态:待分配;服务人员抢单状态:待服务
int serveStatus = UserType.WORKER == serveProviderType ? ServeStatusEnum.NO_SERVED.getStatus() : ServeStatusEnum.NO_ALLOCATION.getStatus();
// 服务单来源类型,人工抢单来源抢单,值为1;机器抢单来源派单,值为2
int ordersOriginType = isMatchine ? OrdersOriginType.DISPATCH : OrdersOriginType.SEIZE;
ordersServe.setOrdersOriginType(ordersOriginType);
ordersServe.setServeStatus(serveStatus);
ordersServe.setServeProviderId(serveProviderId);
ordersServe.setServeProviderType(serveProviderType);
if(!ordersServeService.save(ordersServe)){
return;
}
// 3.当前订单数量
serveProviderSyncService.countServeTimesAndAcceptanceNum(serveProviderId, serveProviderType);
String resourceStockRedisKey = String.format(ORDERS_RESOURCE_STOCK, RedisUtils.getCityIndex(ordersSeize.getCityCode()));
Object stock = redisTemplate.opsForHash().get(resourceStockRedisKey, ordersSeize.getId());
if (ObjectUtils.isNull(stock) || NumberUtils.parseInt(stock.toString()) <= 0) {
ordersDispatchMapper.deleteById(ordersSeize.getId());
ordersSeizeService.removeById(ordersSeize.getId());
redisTemplate.opsForHash().delete(resourceStockRedisKey, ordersSeize.getId());
}
//状态机修改订单状态
// OrderSnapshotDTO orderSnapshotDTO = OrderSnapshotDTO.builder()
// .ordersStatus(OrderStatusEnum.NO_SERVE.getStatus()).build();
Orders orders = ordersMapper.selectById(ordersSeize.getId());
orderStateMachine.changeStatus(orders.getUserId(),String.valueOf(ordersSeize.getId()), OrderStatusChangeEventEnum.DISPATCH);
}
xxl-job任务方法:
package com.jzo2o.orders.seize.handler;
/**
* 抢单xxl-job任务
*/
@Component
@Slf4j
public class SeizeJobHandler {
/**
* 抢单成功同步任务
*/
@XxlJob("seizeSyncJob")
public void seizeSyncJob() {
syncManager.start(ORERS_SEIZE_SYNC_QUEUE_NAME, RedisSyncQueueConstants.STORAGE_TYPE_HASH, RedisSyncQueueConstants.MODE_SINGLE);
}
...
4) 测试
- 在xxl-job中添加 seizeSyncJob任务
启动xxl-job: docker start xxl-job-admin
-
在seizeOrdersSuccess方法的断点
-
启动xxl-job任务
-
跟踪seizeOrdersSuccess方法的断点‘
预期结果:
orders_serve服务单数据添加成功,状态为1(待服务)
订单更新为200(待服务)
该订单在抢单池的记录删除
示例:
服务单表中添加服务单成功:
{
"id": 2311150000000000038,
"user_id": null,
"serve_provider_id": 1716431678555406338,
"serve_provider_type": 2,
"institution_staff_id": null,
"orders_id": null,
"orders_origin_type": 1,
"city_code": "010",
"serve_type_id": 1678649931106705409,
"serve_start_time": "2023-11-15 20:30:00",
"serve_item_id": 1685894105234755585,
"serve_status": 1,
"settlement_status": 0,
"real_serve_start_time": null,
"real_serve_end_time": null,
"serve_before_imgs": null,
"serve_after_imgs": null,
"serve_item_img": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png",
"serve_before_illustrate": null,
"serve_after_illustrate": null,
"cancel_time": null,
"orders_amount": 1.00,
"pur_num": 1,
"create_time": "2023-11-15 17:01:20",
"update_time": "2023-11-15 17:01:20",
"sort_by": 1700051400038,
"display": 1,
"update_by": null
}
订单更新更改为服务中:
{
"id": 2311150000000000038,
"user_id": 1716346406098296832,
"serve_type_id": 1678649931106705409,
"serve_type_name": "保洁清",
"serve_item_id": 1685894105234755585,
"serve_item_name": "日常保洁",
"serve_item_img": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png",
"unit": 1,
"serve_id": 1693815624114970626,
"orders_status": 200,
"pay_status": 4,
"refund_status": null,
"price": 1.00,
"pur_num": 1,
"total_amount": 1.00,
"real_pay_amount": 1.00,
"discount_amount": 0.00,
"city_code": "010",
"serve_address": "北京市北京市昌平区北京市昌平区回龙观街道弘文恒瑞文化传播公司正泽商务中心",
"contacts_phone": "13333333333",
"contacts_name": "苗先生",
"serve_start_time": "2023-11-15 20:30:00",
"lon": 116.34351,
"lat": 40.06024,
"pay_time": "2023-11-15 15:06:11",
"evaluation_time": null,
"trading_order_no": 1724685206700535808,
"transaction_id": "1724685206700535809",
"refund_no": null,
"refund_id": null,
"trading_channel": "WECHAT_PAY",
"display": 1,
"sort_by": 1700051400038,
"create_time": "2023-11-15 15:06:10",
"update_time": "2023-11-15 17:01:20"
}
5)小结
抢单结果如何从redis同步到MySQL?
我们项目开发了一个同步组件,用于将Redis Hash结构中的数据同步到MySQL,具体是这样做的:
1、使用线程池从多个同步队列中查询数据,每个线程处理一个同步队列。
同步队列的个数通常配置10到20即可。
在同步时为了保证一个线程只处理一个队列,这里使用的分布式锁进行控制。
2、使用redisTemplate.opsForHash().scan(H key, ScanOptions options)方法从hash表获取数据。
3、读取到数据后将数据库写入MySQL,最后将写入成功的的数据表示已经同步成功,将从Redis的Hash表中删除。
秒杀异步处理怎么实现的?
在秒杀抢购场景中,为了流量削峰可以在Redis存储秒杀结果,再通过定时任务将秒杀结果同步到数据库中。
本项目使用了一个数据同步组件,将Redis中的数据异步同步到MySQL,具体的做法是:
将Redis中的数据异步同步到MySQL参考“如何将Redis中的数据异步同步到MySQL?”
3.8 整体测试
抢单功能开发完成对抢单流程进行测试。
-
用户下单
-
服务人员抢单
-
服务人员抢单成功,查询自己的服务单
-
开始服务
-
完成服务
4 电商限时抢购
4.1 业务流程
1)运营端创建秒杀活动
设置一周的秒杀活动
设置每天的秒杀时间段
2)店铺端添加秒杀商品
点击“管理”进入添加商品界面
添加成功下一步设置活动价格
点击提交申请秒杀商品,系统设置为自动审核,提交后自动审核通过,如下图
3)用户端进行秒杀
进入小程序首页,
点击“秒杀活动”进入秒杀界面
点击具体的商品进入商品详情页面。
点击“立即购买”
点击“提交订单”,进入支付页面,支付成功商品购买成功。
4.2 系统设计
1)表结构
商品秒杀活动表:记录商品秒杀活动的起止时间、店铺信息等。
商品秒杀活动申请表:记录店铺申请的秒杀商品信息。
秒杀商品信息:记录了审批通过的秒杀商品。
2)Redis
- 秒杀商品活动信息
按天为单位进行缓存
结构:String
key: 年月日
Value: 当天的秒杀商品信息
过期时间:永不过期,由定时任务维护
示例:
key:SECKILL_CACHE::2024-11-16
Value:
[
"java.util.ArrayList",
[
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 0,
"startTime": "1731686400",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 1,
"startTime": "1731690000",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 2,
"startTime": "1731693600",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 3,
"startTime": "1731697200",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 4,
"startTime": "1731700800",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 5,
"startTime": "1731704400",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 6,
"startTime": "1731708000",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 7,
"startTime": "1731711600",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 8,
"startTime": "1731715200",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 9,
"startTime": "1731718800",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 10,
"startTime": "1731722400",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 11,
"startTime": "1731726000",
"distanceStartTime": "0",
"seckillGoodsList": [
"java.util.ArrayList",
[
[
"com.jzo2o.mall.promotion.model.dto.SeckillGoodsDTO",
{
"seckillId": "1857604893620105217",
"timeLine": 11,
"goodsId": "1854464715971907585",
"point": null,
"skuId": "1854464716361977857",
"goodsName": "荣耀80 白色 256G",
"goodsImage": "https://jzo2o-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/undefined/287487e68c804dc2bda95ad2f2f791c1.png?x-oss-process=image/resize,h_100,m_lfit",
"storeId": "1376433565247471616",
"price": 900.0,
"quantity": 11,
"salesNum": 0,
"originalPrice": 900.0,
"promotionGoods": [
"com.jzo2o.mall.promotion.model.domain.PromotionGoods",
{
"id": "1857625826636382209",
"createBy": "13011111111",
"createTime": "2024-11-16 11:24:44",
"updateBy": "1376417684140326912",
"updateTime": "2024-11-16 11:24:44",
"deleteFlag": false,
"storeId": "1376433565247471616",
"storeName": "家家乐",
"goodsId": "1854464715971907585",
"skuId": "1854464716361977857",
"goodsName": "荣耀80 白色 256G",
"thumbnail": "https://jzo2o-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/undefined/287487e68c804dc2bda95ad2f2f791c1.png?x-oss-process=image/resize,h_100,m_lfit",
"startTime": [
"java.util.Date",
"2024-11-17 11:00:00"
],
"endTime": [
"java.util.Date",
"2024-11-17 11:59:59"
],
"promotionId": "1857604893620105217",
"promotionType": "SECKILL",
"goodsType": "PHYSICAL_GOODS",
"title": "2024-11-17 秒杀活动",
"num": 0,
"originalPrice": 900.0,
"price": 900.0,
"points": null,
"limitNum": null,
"quantity": 11,
"categoryPath": "1348576427264204941,1348576427264204942,1348576427264204943",
"scopeType": "PORTION_GOODS",
"scopeId": null
}
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillGoodsDTO",
{
"seckillId": "1857604893620105217",
"timeLine": 11,
"goodsId": "1797528556385894402",
"point": null,
"skuId": "1797528556507529217",
"goodsName": "荣耀80 黑色 128G",
"goodsImage": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/6774bb3d4a714d34a75ffde79f290221.jpg?x-oss-process=image/resize,h_100,m_lfit",
"storeId": "1376433565247471616",
"price": 1.0,
"quantity": 87,
"salesNum": 0,
"originalPrice": 1.0,
"promotionGoods": [
"com.jzo2o.mall.promotion.model.domain.PromotionGoods",
{
"id": "1857625826644770817",
"createBy": "13011111111",
"createTime": "2024-11-16 11:24:44",
"updateBy": "1376417684140326912",
"updateTime": "2024-11-16 11:24:44",
"deleteFlag": false,
"storeId": "1376433565247471616",
"storeName": "家家乐",
"goodsId": "1797528556385894402",
"skuId": "1797528556507529217",
"goodsName": "荣耀80 黑色 128G",
"thumbnail": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/6774bb3d4a714d34a75ffde79f290221.jpg?x-oss-process=image/resize,h_100,m_lfit",
"startTime": [
"java.util.Date",
"2024-11-17 11:00:00"
],
"endTime": [
"java.util.Date",
"2024-11-17 11:59:59"
],
"promotionId": "1857604893620105217",
"promotionType": "SECKILL",
"goodsType": "PHYSICAL_GOODS",
"title": "2024-11-17 秒杀活动",
"num": 0,
"originalPrice": 1.0,
"price": 1.0,
"points": null,
"limitNum": null,
"quantity": 87,
"categoryPath": "1348576427264204941,1348576427264204942,1348576427264204943",
"scopeType": "PORTION_GOODS",
"scopeId": null
}
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillGoodsDTO",
{
"seckillId": "1857604893620105217",
"timeLine": 11,
"goodsId": "1797528556385894402",
"point": null,
"skuId": "1797528556503334913",
"goodsName": "荣耀80 红色 256G",
"goodsImage": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/db4c234f10b2475ead284ef037b1163f.png?x-oss-process=image/resize,h_100,m_lfit",
"storeId": "1376433565247471616",
"price": 1.0,
"quantity": 88,
"salesNum": 0,
"originalPrice": 1.0,
"promotionGoods": [
"com.jzo2o.mall.promotion.model.domain.PromotionGoods",
{
"id": "1857625826644770818",
"createBy": "13011111111",
"createTime": "2024-11-16 11:24:44",
"updateBy": "1376417684140326912",
"updateTime": "2024-11-16 11:24:44",
"deleteFlag": false,
"storeId": "1376433565247471616",
"storeName": "家家乐",
"goodsId": "1797528556385894402",
"skuId": "1797528556503334913",
"goodsName": "荣耀80 红色 256G",
"thumbnail": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/db4c234f10b2475ead284ef037b1163f.png?x-oss-process=image/resize,h_100,m_lfit",
"startTime": [
"java.util.Date",
"2024-11-17 11:00:00"
],
"endTime": [
"java.util.Date",
"2024-11-17 11:59:59"
],
"promotionId": "1857604893620105217",
"promotionType": "SECKILL",
"goodsType": "PHYSICAL_GOODS",
"title": "2024-11-17 秒杀活动",
"num": 0,
"originalPrice": 1.0,
"price": 1.0,
"points": null,
"limitNum": null,
"quantity": 88,
"categoryPath": "1348576427264204941,1348576427264204942,1348576427264204943",
"scopeType": "PORTION_GOODS",
"scopeId": null
}
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillGoodsDTO",
{
"seckillId": "1857604893620105217",
"timeLine": 11,
"goodsId": "1782597676706136065",
"point": null,
"skuId": "1795709064865415169",
"goodsName": "小米802 绿色 256G",
"goodsImage": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/4ca2677b1b7b4b3a9f6e88857dded3e3.png?x-oss-process=image/resize,h_100,m_lfit",
"storeId": "1376433565247471616",
"price": 0.1,
"quantity": 23,
"salesNum": 0,
"originalPrice": 0.1,
"promotionGoods": [
"com.jzo2o.mall.promotion.model.domain.PromotionGoods",
{
"id": "1857625826644770819",
"createBy": "13011111111",
"createTime": "2024-11-16 11:24:44",
"updateBy": "1376417684140326912",
"updateTime": "2024-11-16 11:24:44",
"deleteFlag": false,
"storeId": "1376433565247471616",
"storeName": "家家乐",
"goodsId": "1782597676706136065",
"skuId": "1795709064865415169",
"goodsName": "小米802 绿色 256G",
"thumbnail": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/4ca2677b1b7b4b3a9f6e88857dded3e3.png?x-oss-process=image/resize,h_100,m_lfit",
"startTime": [
"java.util.Date",
"2024-11-17 11:00:00"
],
"endTime": [
"java.util.Date",
"2024-11-17 11:59:59"
],
"promotionId": "1857604893620105217",
"promotionType": "SECKILL",
"goodsType": "PHYSICAL_GOODS",
"title": "2024-11-17 秒杀活动",
"num": 0,
"originalPrice": 0.1,
"price": 0.1,
"points": null,
"limitNum": null,
"quantity": 23,
"categoryPath": "1348576427264204941,1348576427264204942,1348576427264204943",
"scopeType": "PORTION_GOODS",
"scopeId": null
}
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillGoodsDTO",
{
"seckillId": "1857604893620105217",
"timeLine": 11,
"goodsId": "1782595491175956482",
"point": null,
"skuId": "1783321570509451265",
"goodsName": "荣耀70 白色 254G",
"goodsImage": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/eac762d7e4ff4ec8bec211c5d683b7b7.jpg?x-oss-process=image/resize,h_100,m_lfit",
"storeId": "1376433565247471616",
"price": 0.1,
"quantity": 5,
"salesNum": 0,
"originalPrice": 0.1,
"promotionGoods": [
"com.jzo2o.mall.promotion.model.domain.PromotionGoods",
{
"id": "1857625826644770820",
"createBy": "13011111111",
"createTime": "2024-11-16 11:24:44",
"updateBy": "1376417684140326912",
"updateTime": "2024-11-16 11:24:44",
"deleteFlag": false,
"storeId": "1376433565247471616",
"storeName": "家家乐",
"goodsId": "1782595491175956482",
"skuId": "1783321570509451265",
"goodsName": "荣耀70 白色 254G",
"thumbnail": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/STORE/1376417684140326912/1376433565247471616/eac762d7e4ff4ec8bec211c5d683b7b7.jpg?x-oss-process=image/resize,h_100,m_lfit",
"startTime": [
"java.util.Date",
"2024-11-17 11:00:00"
],
"endTime": [
"java.util.Date",
"2024-11-17 11:59:59"
],
"promotionId": "1857604893620105217",
"promotionType": "SECKILL",
"goodsType": "PHYSICAL_GOODS",
"title": "2024-11-17 秒杀活动",
"num": 0,
"originalPrice": 0.1,
"price": 0.1,
"points": null,
"limitNum": null,
"quantity": 5,
"categoryPath": "1348576427264204941,1348576427264204942,1348576427264204943",
"scopeType": "PORTION_GOODS",
"scopeId": null
}
]
}
]
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 12,
"startTime": "1731729600",
"distanceStartTime": "1709",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 13,
"startTime": "1731733200",
"distanceStartTime": "5309",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 14,
"startTime": "1731736800",
"distanceStartTime": "8909",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 15,
"startTime": "1731740400",
"distanceStartTime": "12509",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 16,
"startTime": "1731744000",
"distanceStartTime": "16109",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 17,
"startTime": "1731747600",
"distanceStartTime": "19709",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 18,
"startTime": "1731751200",
"distanceStartTime": "23309",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 19,
"startTime": "1731754800",
"distanceStartTime": "26909",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 20,
"startTime": "1731758400",
"distanceStartTime": "30509",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 21,
"startTime": "1731762000",
"distanceStartTime": "34109",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 22,
"startTime": "1731765600",
"distanceStartTime": "37709",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
],
[
"com.jzo2o.mall.promotion.model.dto.SeckillTimelineDTO",
{
"timeLine": 23,
"startTime": "1731769200",
"distanceStartTime": "41309",
"seckillGoodsList": [
"java.util.ArrayList",
[
]
]
}
]
]
]
- 秒杀商品库存
String结构:
Key: SeckillSku_商品id
value:库存值
过期时间:永不过期,由定时任务维护
示例:
Kye: {SeckillSku}_1797528556507529217
value:100
- 秒杀交易信息
String 结构
Key: SeckillOrder_订单ID
value:交易信息(json)
示例:
Kye: {SeckillOrder}_17975285565543543
value:
- 秒杀成功队列
结构:hash
大Key: QUEUE:SECKILL:SYNC:{用户id%10}
小Key: 订单id
Value: 用户id
过期时间:永不过期,由秒杀结果同步任务维护
示例:
Key: QUEUE:SECKILL:SYNC:{7}
小Key: 17975285565543543
Value: 17975285667655743
3)交互流程
-
通过定时任务将秒杀商品的库存同步到Redis、将秒杀活动及活动的商品信息同步到Redis
-
用户通过redis查询秒杀活动及秒杀活动中的商品
-
用户点击立即抢购秒杀商品,通过Redis+Lua方式扣减库存
如果扣减库存成功在redis记录购买的交易信息,并将购买商品的信息记录存储到Redis 的秒杀成功队列
-
lua脚本扣减库存成功就算秒杀成功,后端向前端返回交易信息(此时交易信息还在redis),用户开始支付
-
通过定时任务加多线程机制将支付成功的交易信息同步到MySQL数据库的订单表中。
-
如果用户未支付时取消订单或订单超时未支付自动取消,也是通过redis+lua方式去回滚库存,回滚库存成功删除redis中的秒杀交易信息。
代码环境:
jzo2o-orders: dev_02分支
学习目标:
1 需求分析
1)派单调度流程
在抢单业务中,用户下单成功由服务人员或机构进行抢单,抢单成功服务人员上门服务,除了抢单业务系统还设计了派单业务,由系统根据用户订单的特点自动派给合适的服务人员。
系统派单的目的是什么?
根据订单的特点及服务人员的特点进行撮合,提高交易成功率。
暂时无法在飞书文档外展示此内容
系统如何派单?
暂时无法在飞书文档外展示此内容
2)调度系统的应用场景
调度系统的应用场景广泛(如下):
即时配送服务: 将用户提交的订单派发给最近且可用的配送员,以确保订单在最短时间内送达。
服务行业派单: 将用户的服务请求分配给合适的服务人员,考虑到距离、技能、时间等因素。
出租车调度: 将用户的打车请求派发给附近空闲的出租车,以最快速度响应用户需求。
餐饮外卖配送: 将用户的订单分派给附近的餐馆,并将已经制作好的食物分派给配送员,以保证食品的新鲜度和送达时间。
快递中心派单: 将到达的包裹分派给不同的快递员,根据各自的配送区域和计划。
工程项目任务分配: 在建筑、工程项目中,需要将不同的任务分配给相应的工程师、技术人员或施工队,以确保项目按计划进行。
通过项目中派单调度的学习有能力迁移到类似场景,比如:工单调度。
另外尝试去理解其它领域的调度业务,从而对调度系统的开发有深刻的理解,有能力迁移到其它的场景。
3) 小结
本项目派单调度的业务流程是什么?
为了提高交易成功率,派单调度模块会自动匹配订单与服务提供者,进行撮合匹配,流程如下:
-
首先获取待分配的订单。
-
根据订单的属性,包括:地理位置、服务项目等去服务提供池中匹配服务提供者。
-
根据派单策略对符合条件的服务提供者进行规则匹配,每个派单策略通常有多个规则,从第一个规则逐个匹配其它规则。
-
最后获取匹配成功的服务提供者,系统进行机器抢单。
-
机器抢单成功,派单成功。
2 系统设计
2.1 总体设计
1)总体设计
根据需求分析,从以下几个问题出发进行设计。
- 涉及到距离搜索,参考抢单业务需要借助Elasticsearch搜索服务人员,所以需要将服务人员同步到Elasticsearch。根据搜索匹配的条件:服务技能,服务时间、接单范围,需要将服务人员和机构的相关信息同步到Elasticsearch。
同步的方式使用Canal+MQ实现。
-
如果为订单派单失败每隔3分钟再次对订单进行派单,考虑性能问题在redis创建派单池,调度程序扫描派单池获取订单,所以需要将MySQL中派单池的数据同步到Redis。
-
根据需求,派单策略有三种,后期还可能扩展,这里使用策略模式实现,提高系统扩展性。
-
根据需求,每个派单策略有多个规则,按规则逐个去匹配,只要匹配成功或规则使用完成,这里使用责任链儿模式,提高系统扩展性。
-
派单程序将订单和服务人员匹配成功,接下来调用抢单接口进行机器抢单,这样也体现公平性,因为派单的同步有服务人员也在抢单,这里是机器(平台)和人工(服务人员)在共同抢单。
整体交互流程如下:
暂时无法在飞书文档外展示此内容
说明:
-
使用Canal+MQ将服务提供者(服务人员和机构)信息(经纬度坐标、接单状态、当前接单数等)同步Elasticsearch中。
-
将派单池同步到Redis,派单池中是待派单的订单信息。
-
通过定时任务定时派单,从派单池查询多个订单,使用线程池对多个订单进行派单,每个线程负责一个订单。
-
派单过程首先根据订单信息(服务地址、服务项目)从Elasticsearch中的服务提供者索引中搜索。
-
找到多个服务提供者,根据派单策略去匹配服务提供者,通过责任链模式分别匹配每个规则,最终找到一个服务提供者。
-
系统调用抢单接口为服务提供者派单。
2)服务提供池索引结构
根据设计,第一步需要向Elasticsearch中同步服务提供者的数据。
将customer数据库的serve_provider_sync表和orders数据库的serve_provider_sync表同步到ES的服务提供池。
暂时无法在飞书文档外展示此内容
customer数据库的serve_provider_sync表中除了evaluation_score字段以外,其它字段是由服务人员设置服务技能、接单开关、接单范围保存的信息。
evaluation_score字段存储服务人员的评分,用户通过评价系统评分,由评价系统同步到此字段中。
orders数据库的serve_provider_sync表中记录了服务人员接单数统计,当服务人员或机构抢单成功后进行统计得到。
创建服务提供者索引结构,索引字段对应上图中两个表的字段。
先删除serve_provider_info,再创建serve_provider_info索引。
DELETE serve_provider_info
PUT /serve_provider_info
{
"mappings" : {
"properties" : {
"acceptance_num" : {
"type" : "integer"
},
"city_code" : {
"type" : "keyword"
},
"evaluation_score" : {
"type" : "double"
},
"id" : {
"type" : "long"
},
"location" : {
"type" : "geo_point"
},
"pick_up" : {
"type" : "integer"
},
"serve_item_ids" : {
"type" : "long"
},
"serve_provider_type" : {
"type" : "integer"
},
"serve_times" : {
"type" : "integer"
},
"setting_status" : {
"type" : "long"
},
"status" : {
"type" : "long"
}
}
}
}
3)Redis派单池
两种情况进入派单池:
- 订单分流处理
在订单分流中对于距离服务开始时间在120分钟(默认值可修改)以内时将订单写入orders数据库的派单池表。
- 定时任务处理
抢单池的订单没有人抢,距离服务开始时间在120分钟以内时将订单写入orders数据库的派单池表。
派单池表的结构如下:
120分钟的配置在foundations数据库的config_region表中,如下图:
此表配置了一些业务参数。
根据设计第二条将orders数据库的派单池表数据同步到Redis的派单池中。
Redis的派单池用什么数据结构呢?
根据需求,订单派单失败每隔3分钟再次对失败的订单进行派单,有什么办法可以在查询订单时将失败的订单过滤掉,并且还能根据时间对早进入派单池的订单进行优先派单,这里涉及到排序,很自然我们想到了Redis的ZSet结构。
Redis中派单池使用ZSet结构,value为是订单id、score为进入派单池的时间,当派单失败我们score加3分钟,第一次查询SortedSet查询score小于当前时间的订单。
暂时无法在飞书文档外展示此内容
4) ZSet测试
下边测试向ZSet添加元素、查询元素的方法。
package com.jzo2o.orders.dispatch.service;
import com.jzo2o.api.foundations.RegionApi;
import com.jzo2o.api.foundations.dto.response.RegionServeInfoResDTO;
import com.jzo2o.common.constants.UserType;
import com.jzo2o.common.model.CurrentUserInfo;
import com.jzo2o.mvc.utils.UserContext;
import com.jzo2o.orders.base.model.domain.OrdersDispatch;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.redis.core.RedisTemplate;
import javax.annotation.Resource;
import java.util.List;
import java.util.Set;
@SpringBootTest
@Slf4j
class IDispatchServiceTest {
@Resource
private RedisTemplate redisTemplate;
@Test
public void testSortedSet_add(){
for (int i = 0; i < 10; i++) {
//向key为:test_sortedset的sortedset中添加10个元素,value为i,socre为当前时间(秒)
redisTemplate.opsForZSet().add("test_sortedset",i, DateUtils.getCurrentTime()*1d);
}
}
@Test
public void testSortedSet_rang(){
//使用rangeByScore查询score范围从0到当前时间
Set test_sortedset = redisTemplate.opsForZSet().rangeByScore("test_sortedset", 0, DateUtils.getCurrentTime(),0,5);
//降序
// Set test_sortedset = redisTemplate.opsForZSet().reverseRangeByScore("test_sortedset", 0, DateUtils.getCurrentTime(),0,5);
test_sortedset.stream().forEach(System.out::println);
//将key为test_sortedset中的vlaue为0的元素的socre加180秒
redisTemplate.opsForZSet().incrementScore("test_sortedset", 0, 180);
}
}
testSortedSet_add方法:向ZSet写入10个元素,value从0到9,socre为当前时间。
testSortedSet_rang方法:取出socre为0到当前时间的元素。
通过incrementScore方法对ZSet的第0个元素增加socre的值为180秒,对派单失败的订单就实现了3分钟之后继续派单。
5)小结
派单调度的整体交互是什么?
2.2 责任链模式
1)什么是责任链模式
责任链模式是一种行为型设计模式,它允许你将请求沿着处理者链进行传递,直到有一个处理者处理请求为止。每个处理者都可以决定是否将请求传递给下一个处理者。
根据派单的需求,根据订单信息从服务提供池中获取师傅及机构的信息,通过距离优先规则、评分优先规则等最终获取一个要派单的服务或机构。
下图描述了按距离优先派单的过程:
暂时无法在飞书文档外展示此内容
下边用责任链模式实现。
UML图示如下:
暂时无法在飞书文档外展示此内容
首先,阅读下边的数据处理规则的接口:
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import com.jzo2o.orders.dispatch.rules.IDispatchRule;
import lombok.Builder;
import lombok.ToString;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description TODO
* @date 2023/11/24 5:56
*/
public interface IProcessRule {
/**
* 根据派单规则过滤服务人员
* @param serveProviderDTOS
* @return
*/
List<ServeProviderDTO> filter(List<ServeProviderDTO> serveProviderDTOS);
/**
* 获取下一级规则
*
* @return
*/
IProcessRule next();
}
根据需求定义距离优先规则。
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import com.jzo2o.orders.dispatch.rules.IDispatchRule;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author Mr.M
* @version 1.0
* @description 按距离排序
* @date 2023/11/24 5:58
*/
public class DistanceRule implements IProcessRule {
private IProcessRule next;
public DistanceRule(IProcessRule next) {
this.next = next;
}
public List<ServeProviderDTO> doFilter(List<ServeProviderDTO> serveProviderDTOS) {
System.out.println("按距离排序,拿到距离最近的服务提供者");
//如果serveProviderDTOS元素个数小于2则直接返回
if(CollUtils.size(serveProviderDTOS) < 2) {
return serveProviderDTOS;
}
//按serveProviderDTOS中的距离升序排序
serveProviderDTOS.sort((o1, o2) -> o1.getAcceptanceDistance() - o2.getAcceptanceDistance());
//取出第一个元素
ServeProviderDTO serveProviderDTO = serveProviderDTOS.get(0);
//获取相同级别的对象
List<ServeProviderDTO> sameLevelServeProviderDTOS = serveProviderDTOS.stream().filter(serveProviderDTO1 -> serveProviderDTO1.getAcceptanceDistance() == serveProviderDTO.getAcceptanceDistance()).collect(Collectors.toList());
return sameLevelServeProviderDTOS;
}
@Override
public List<ServeProviderDTO> filter(List<ServeProviderDTO> serveProviderDTOS) {
List<ServeProviderDTO> result = this.doFilter(serveProviderDTOS);
if(CollUtils.size(result) > 1 && next != null) {
return next.filter(result);
}else {
return result;
}
}
@Override
public IProcessRule next() {
return next;
}
}
再定义最少接单数规则:
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author Mr.M
* @version 1.0
* @description 按接单数
* @date 2023/11/24 5:58
*/
public class AcceptNumRule implements IProcessRule {
private IProcessRule next;
public AcceptNumRule(IProcessRule next) {
this.next = next;
}
public List<ServeProviderDTO> doFilter(List<ServeProviderDTO> serveProviderDTOS) {
System.out.println("按接单数排序,拿到最少接单少的服务提供者");
if(CollUtils.size(serveProviderDTOS) < 2) {
return serveProviderDTOS;
}
//按serveProviderDTOS中的接单数升序排序
serveProviderDTOS.sort((o1, o2) -> o1.getAcceptanceNum() - o2.getAcceptanceNum());
//取出第一个元素
ServeProviderDTO serveProviderDTO = serveProviderDTOS.get(0);
//获取相同级别的对象
List<ServeProviderDTO> sameLevelServeProviderDTOS = serveProviderDTOS.stream().filter(serveProviderDTO1 -> serveProviderDTO1.getAcceptanceNum() == serveProviderDTO.getAcceptanceNum()).collect(Collectors.toList());
return sameLevelServeProviderDTOS;
}
@Override
public List<ServeProviderDTO> filter(List<ServeProviderDTO> serveProviderDTOS) {
List<ServeProviderDTO> result = this.doFilter(serveProviderDTOS);
if(CollUtils.size(result) > 1 && next != null) {
return next.filter(result);
}else {
return result;
}
}
@Override
public IProcessRule next() {
return next;
}
}
下边将规则组成一个链儿,调用链儿中第一个规则的filter方法,最终获取处理后的结果,如果处理结果的数量大于1则随机选择一个,否则取出唯一的结果。
注意:上边的规则类还未实现,下边的代码暂时不运行,当前目的是去理解责任链模式。
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import java.util.Arrays;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description TODO
* @date 2023/11/24 6:05
*/
public class RuleHandlerTest {
public static void main(String[] args) {
// 策略1:构建责任链,先距离优先,距离相同再判断接单数
IProcessRule rule = new AcceptNumRule(null);
IProcessRule ruleChain = new DistanceRule(rule);
// 策略2:构建责任链,先评分优先,评分相同再判断接单数
// IProcessRule rule = new AcceptNumRule(null);
// IProcessRule ruleChain = new ScoreRule(rule);
// 策略3:构建责任链,先接单数优先,接单数相同再判断评分
// IProcessRule rule = new ScoreRule(null);
// IProcessRule ruleChain = new AcceptNumRule(rule);
// 创建数据
List<ServeProviderDTO> serveProviderDTOS = Arrays.asList(
//1号 接单数最少
ServeProviderDTO.builder().id(1L).acceptanceNum(0).acceptanceDistance(30).evaluationScore(50).build(),
//2号 得分最高
ServeProviderDTO.builder().id(2L).acceptanceNum(1).acceptanceDistance(10).evaluationScore(100).build(),
//3号 得分最高
ServeProviderDTO.builder().id(3L).acceptanceNum(2).acceptanceDistance(10).evaluationScore(100).build(),
//4号 距离最近
ServeProviderDTO.builder().id(4L).acceptanceNum(2).acceptanceDistance(5).evaluationScore(50).build(),
//4号 距离最近
ServeProviderDTO.builder().id(5L).acceptanceNum(1).acceptanceDistance(5).evaluationScore(50).build()
);
// 发起处理请求
List<ServeProviderDTO> list = ruleChain.filter(serveProviderDTOS);
//处理结果
ServeProviderDTO result = null;
// 3.1.唯一高优先级直接返回
int size = 1;
if((size = CollUtils.size(list)) == 1) {
result = list.get(0);
}
// 3.2.多个高优先级随机返回,生成0到size之间的随机整数
int randomIndex = (int) (Math.random() * size);
result = list.get(randomIndex);
System.out.println(result);
}
}
下边进行测试
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import java.util.Arrays;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description TODO
* @date 2023/11/24 6:05
*/
public class RuleHandlerTest {
public static void main(String[] args) {
// 策略1:构建责任链,先距离优先,距离相同再判断接单数
IProcessRule rule = new AcceptNumRule(null);
IProcessRule ruleChain = new DistanceRule(rule);
// 策略2:构建责任链,先评分优先,评分相同再判断接单数
// IProcessRule rule = new AcceptNumRule(null);
// IProcessRule ruleChain = new ScoreRule(rule);
// 策略3:构建责任链,先接单数优先,接单数相同再判断评分
// IProcessRule rule = new ScoreRule(null);
// IProcessRule ruleChain = new AcceptNumRule(rule);
// 创建数据
List<ServeProviderDTO> serveProviderDTOS = Arrays.asList(
//1号 接单数最少
ServeProviderDTO.builder().id(1L).acceptanceNum(0).acceptanceDistance(30).evaluationScore(50).build(),
//2号 得分最高
ServeProviderDTO.builder().id(2L).acceptanceNum(1).acceptanceDistance(10).evaluationScore(100).build(),
//3号 得分最高
ServeProviderDTO.builder().id(3L).acceptanceNum(2).acceptanceDistance(10).evaluationScore(100).build(),
//4号 距离最近
ServeProviderDTO.builder().id(4L).acceptanceNum(2).acceptanceDistance(5).evaluationScore(50).build(),
//4号 距离最近
ServeProviderDTO.builder().id(5L).acceptanceNum(1).acceptanceDistance(5).evaluationScore(50).build()
);
// 发起处理请求
List<ServeProviderDTO> list = ruleChain.filter(serveProviderDTOS);
//处理结果
ServeProviderDTO result = null;
// 3.1.唯一高优先级直接返回
int size = 1;
if((size = CollUtils.size(list)) == 1) {
result = list.get(0);
}
// 3.2.多个高优先级随机返回
int randomIndex = (int) (Math.random() * size);
result = list.get(randomIndex);
System.out.println(result);
}
}
2)责任链模式优化
上边的代码每个规则中filter方法和next方法都是重复一样的,我们创建抽象类提取
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author Mr.M
* @version 1.0
* @description 规则抽象类
* @date 2023/11/24 11:00
*/
public abstract class AbstractProcessRule implements IProcessRule{
private IProcessRule next;
public AbstractProcessRule(IProcessRule next) {
this.next = next;
}
public abstract List<ServeProviderDTO> doFilter(List<ServeProviderDTO> serveProviderDTOS);
@Override
public List<ServeProviderDTO> filter(List<ServeProviderDTO> serveProviderDTOS) {
List<ServeProviderDTO> result = this.doFilter(serveProviderDTOS);
if(CollUtils.size(result) > 1 && next != null) {
return next.filter(result);
}else {
return result;
}
}
@Override
public IProcessRule next() {
return next;
}
}
修改每个规则类:
下边以DistanceRule举例,其它的规则类自行修改。
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
/**
* @author Mr.M
* @version 1.0
* @description 按距离排序
* @date 2023/11/24 5:58
*/
public class DistanceRule extends AbstractProcessRule {
// private IProcessRule next;
public DistanceRule(IProcessRule next) {
super(next);
// this.next = next;
}
public List<ServeProviderDTO> doFilter(List<ServeProviderDTO> serveProviderDTOS) {
System.out.println("按距离排序");
//如果serveProviderDTOS元素个数小于2则直接返回
if(CollUtils.size(serveProviderDTOS) < 2) {
return serveProviderDTOS;
}
//按serveProviderDTOS中的距离升序排序
serveProviderDTOS.sort((o1, o2) -> o1.getAcceptanceDistance() - o2.getAcceptanceDistance());
//取出第一个元素
ServeProviderDTO serveProviderDTO = serveProviderDTOS.get(0);
//获取相同级别的对象
List<ServeProviderDTO> sameLevelServeProviderDTOS = serveProviderDTOS.stream().filter(serveProviderDTO1 -> serveProviderDTO1.getAcceptanceDistance() == serveProviderDTO.getAcceptanceDistance()).collect(Collectors.toList());
return sameLevelServeProviderDTOS;
}
// @Override
// public List<ServeProviderDTO> filter(List<ServeProviderDTO> serveProviderDTOS) {
// List<ServeProviderDTO> result = this.doFilter(serveProviderDTOS);
// if(CollUtils.size(result) > 1 && next != null) {
// return next.filter(result);
// }else {
// return result;
// }
// }
//
// @Override
// public IProcessRule next() {
// return next;
// }
}
重新 进行测试。
3)小结
责任链模式用在哪里?怎么使用?在开发中注意什么?
2.3 定义派单策略
1)技术方案
在前边我们测试责任链模式,如下代码怎么优化:
// 策略1:构建责任链,先距离优先,距离相同再判断评分
IProcessRule rule = new AcceptNumRule(null);
IProcessRule ruleChain = new DistanceRule(rule);
// 策略2:构建责任链,先评分优先,评分相同再判断接单数
// IProcessRule rule = new AcceptNumRule(null);
// IProcessRule ruleChain = new ScoreRule(rule);
// 策略3:构建责任链,先接单数优先,接单数相同再判断评分
// IProcessRule rule = new ScoreRule(null);
// IProcessRule ruleChain = new AcceptNumRule(rule);
根据需求我们平台支持距离优先策略、评分优先策略、最少接单优先策略,针对上边的代码我们可以基于策略模式定义不同的策略去优化。
首先阅读下边的策略接口:
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description 策略接口
* @date 2023/11/24 10:56
*/
public interface IProcessStrategy {
/**
* 从服务人员/机构列表中获取高优先级别的一个,如果出现多个相同优先级随机获取一个
*
* @param serveProviderDTOS 服务人员/机构列表
* @return
*/
ServeProviderDTO getPrecedenceServeProvider(List<ServeProviderDTO> serveProviderDTOS);
}
根据策略接口实现不同的策略类,
暂时无法在飞书文档外展示此内容
2) 编码实现
每个策略类都需要实现getPrecedenceServeProvider(List
阅读抽象策略类:
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import com.jzo2o.orders.dispatch.rules.IDispatchRule;
import java.util.List;
import java.util.Objects;
/**
* @author Mr.M
* @version 1.0
* @description 抽象策略类
* @date 2023/11/24 11:53
*/
public abstract class AbstractStrategyImpl implements IProcessStrategy {
private final IProcessRule processRule;
public AbstractStrategyImpl() {
this.processRule = getRules();
}
/**
* 设置派单规则
*
* @return
*/
protected abstract IProcessRule getRules();
@Override
public ServeProviderDTO getPrecedenceServeProvider(List<ServeProviderDTO> serveProviderDTOS) {
// 1.判空
if (CollUtils.isEmpty(serveProviderDTOS)) {
return null;
}
// 2.根据优先级获取高优先级别的
serveProviderDTOS = processRule.filter(serveProviderDTOS);
// 3.数据返回
// 3.1.唯一高优先级直接返回
int size = 1;
if ((size = CollUtils.size(serveProviderDTOS)) == 1) {
return serveProviderDTOS.get(0);
}
// 3.2.多个高优先级随即将返回
int randomIndex = (int) (Math.random() * size);
return serveProviderDTOS.get(randomIndex);
}
}
定义各个策略类:
定义距离优先策略类
package com.jzo2o.orders.dispatch.strategys;
/**
* @author Mr.M
* @version 1.0
* @description 先距离优先,距离相同再判断评分
* @date 2023/11/24 11:56
*/
public class DistanceStrategyImpl extends AbstractStrategyImpl {
@Override
protected IProcessRule getRules() {
//构建责任链,先距离优先,距离相同再判断接单数
IProcessRule rule= new AcceptNumRule(null);
IProcessRule ruleChain = new DistanceRule(rule);
return ruleChain;
}
}
定义最少接单优先策略:
package com.jzo2o.orders.dispatch.strategys;
/**
* @author Mr.M
* @version 1.0
* @description 最少接单优先
* @date 2023/11/24 11:59
*/
public class LeastAcceptOrderStrategyImpl extends AbstractStrategyImpl {
@Override
protected IProcessRule getRules() {
// 构建责任链,先接单数优先,接单数相同再判断评分
IProcessRule rule = new DistanceRule(null);
IProcessRule ruleChain = new AcceptNumRule(rule);
return ruleChain;
}
}
3) 测试
package com.jzo2o.orders.dispatch.strategys;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.orders.dispatch.model.dto.ServeProviderDTO;
import java.util.Arrays;
import java.util.List;
/**
* @author Mr.M
* @version 1.0
* @description TODO
* @date 2023/11/24 6:05
*/
public class StrategyTest {
public static void main(String[] args) {
// 创建数据
List<ServeProviderDTO> serveProviderDTOS = Arrays.asList(
//1号 接单数最少
ServeProviderDTO.builder().id(1L).acceptanceNum(0).acceptanceDistance(30).evaluationScore(50).build(),
//2号 得分最高
ServeProviderDTO.builder().id(2L).acceptanceNum(1).acceptanceDistance(10).evaluationScore(100).build(),
//3号 得分最高
ServeProviderDTO.builder().id(3L).acceptanceNum(2).acceptanceDistance(10).evaluationScore(100).build(),
//4号 距离最近
ServeProviderDTO.builder().id(4L).acceptanceNum(2).acceptanceDistance(5).evaluationScore(50).build(),
//4号 距离最近
ServeProviderDTO.builder().id(5L).acceptanceNum(1).acceptanceDistance(5).evaluationScore(50).build()
);
//获取距离优先策略
IProcessStrategy processStrategy = new DistanceStrategyImpl();
//通过策略bean进行匹配处理
ServeProviderDTO precedenceServeProvider = processStrategy.getPrecedenceServeProvider(serveProviderDTOS);
System.out.println(precedenceServeProvider);
}
}
4)小结
基于策略模式如何灵活定义派单调度策略?
3 系统开发
3.1 同步服务提供者
1) 数据结构
- ES中服务提供者
作用:涉及到距离搜索,所以将数据库中服务提供者的信息及服务提供者的接单数据同步到 ES中的服务提供池中。
索引结构如下:
DELETE serve_provider_info
PUT /serve_provider_info
{
"mappings" : {
"properties" : {
"acceptance_num" : {
"type" : "integer"
},
"city_code" : {
"type" : "keyword"
},
"evaluation_score" : {
"type" : "double"
},
"id" : {
"type" : "long"
},
"location" : {
"type" : "geo_point"
},
"pick_up" : {
"type" : "integer"
},
"serve_item_ids" : {
"type" : "long"
},
"serve_provider_type" : {
"type" : "integer"
},
"serve_times" : {
"type" : "integer"
},
"setting_status" : {
"type" : "long"
},
"status" : {
"type" : "long"
}
}
}
}
- Redis中服务提供者接单数统计表
为了方便派单进行判断服务提供者是否达到接单数限制,及接单时间冲突,将服务提供者的接单数统计信息及接单时间信息同步到Redis中。
暂时无法在飞书文档外展示此内容
2)阅读代码
同步服务提供者的代码有两处:
-
customer服务
-
orders-dispatch派单服务
-
customer服务:
package com.jzo2o.customer.handler;
import com.jzo2o.canal.listeners.AbstractCanalRabbitMqMsgListener;
import com.jzo2o.common.expcetions.BadRequestException;
import com.jzo2o.common.expcetions.CommonException;
import com.jzo2o.common.model.Location;
import com.jzo2o.common.utils.BeanUtils;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.customer.constants.EsIndexConstants;
import com.jzo2o.customer.model.domain.ServeProviderInfo;
import com.jzo2o.customer.model.domain.ServeProviderSync;
import com.jzo2o.es.core.ElasticSearchTemplate;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
/**
* @author 86188
*/
@Data
@Component
@Slf4j
public class ServeProviderHandler extends AbstractCanalRabbitMqMsgListener<ServeProviderSync> {
@Resource
private ElasticSearchTemplate elasticSearchTemplate;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-customer-provider"),
exchange = @Exchange(name = "exchange.canal-jzo2o", type = ExchangeTypes.TOPIC),
key = "canal-mq-jzo2o-customer-provider"),
concurrency = "1"
)
public void onMessage(Message message) throws Exception {
parseMsg(message);
}
@Override
public void batchSave(List<ServeProviderSync> data) {
List<ServeProviderInfo> serveProviderInfos = BeanUtils.copyToList(data, ServeProviderInfo.class, (sync, info) -> {
info.setLocation(new Location(sync.getLon(), sync.getLat()));
});
log.debug("serveProviderInfos : {}", serveProviderInfos);
if(!elasticSearchTemplate.opsForDoc().batchUpsert(EsIndexConstants.SERVE_PROVIDER_INFO, serveProviderInfos)){
throw new CommonException("服务人员或机构信息同步异常");
}
}
@Override
public void batchDelete(List<Long> ids) {
elasticSearchTemplate.opsForDoc().batchDelete(EsIndexConstants.SERVE_PROVIDER_INFO, ids);
}
}
- orders-dispatch服务
package com.jzo2o.orders.dispatch.handler;
import com.jzo2o.api.customer.ServeProviderApi;
import com.jzo2o.canal.listeners.AbstractCanalRabbitMqMsgListener;
import com.jzo2o.common.utils.BeanUtils;
import com.jzo2o.es.core.ElasticSearchTemplate;
import com.jzo2o.orders.base.model.domain.ServeProviderInfo;
import com.jzo2o.orders.base.model.domain.ServeProviderSync;
import com.jzo2o.orders.base.utils.RedisUtils;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import static com.jzo2o.orders.base.constants.RedisConstants.RedisKey.SERVE_PROVIDER_STATE;
/**
*
* 服务提供者服务状态同步类
* @author 86188
*/
@Component
public class ServeProviderStateSyncHandler extends AbstractCanalRabbitMqMsgListener<ServeProviderSync> {
@Resource
private ElasticSearchTemplate elasticSearchTemplate;
@Resource
private ServeProviderApi serveProviderApi;
@Resource
private RedisTemplate redisTemplate;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-orders-provider"),
exchange = @Exchange(name = "exchange.canal-jzo2o", type = ExchangeTypes.TOPIC),
key = "canal-mq-jzo2o-orders-provider"),
concurrency = "1"
)
public void onMessage(Message message) throws Exception {
parseMsg(message);
}
@Override
public void batchSave(List<ServeProviderSync> data) {
List<ServeProviderInfo> serveProviderInfos = BeanUtils.copyToList(data, ServeProviderInfo.class);
// 1.同步es
elasticSearchTemplate.opsForDoc().batchUpsert("serve_provider_info", serveProviderInfos);
// 2.同步redis
//服务提供者id
List<Long> serveProviderIds = data.stream().map(ServeProviderSync::getId).collect(Collectors.toList());
//获取服务提供者的城市编码
Map<Long, String> serveProviderIdAndCityCodeMap = serveProviderApi.batchCityCode(serveProviderIds);
data.stream().forEach(serveProviderSync -> {
//获取服务提供者的城市编码
String cityCode = serveProviderIdAndCityCodeMap.get(serveProviderSync.getId());
int index = RedisUtils.getCityIndex(cityCode);
// 服务时间状态redisKey
String serveProviderStateRedisKey = String.format(SERVE_PROVIDER_STATE, index);
Map<String, Object> map = new HashMap<>();
// 服务时间列表
map.put(serveProviderSync.getId() + "_times", serveProviderSync.getServeTimes());
// 服务数量
map.put(serveProviderSync.getId() + "_num", serveProviderSync.getAcceptanceNum());
//写入redis
redisTemplate.opsForHash().putAll(serveProviderStateRedisKey, map);
});
}
@Override
public void batchDelete(List<Long> ids) {
}
}
3)测试
测试流程:
启动Elasticsearch、Kibana
通过Kibana清除serve_provider_info中的文档
可以通过DELETE命令删除文档:
DELETE /serve_provider_info/_doc/1696338624494202882
也可以删除整个索引再创建索引:
启动customer服务
启动orders-dispatch派单服务
启动canal、rabbitMQ服务
注意:如果canal读取binlog日志失败需要参考“配置搜索及数据同步环境v1.0”进行处理。
启动网关
启动publics服务
启动服务端(前端)
服务人员登录,设置接单范围、开启接单、服务技能。
预期结果:
将customer数据库的serve_provider_sync表和orders数据库的serve_provider_sync表的记录向Elasticsearch的serve_provider_info同步数据成功。
3.2 同步派单池
1)数据结构
为了快速获取派单信息,使用redis的SortedSet存储派单信息。
详细见前边讲解的“Redis派单池”
暂时无法在飞书文档外展示此内容
2)阅读代码
两种情况进入派单池表:
- 订单分流处理
在订单分流中对于距离服务开始时间在120分钟(可配置)以内时将订单写入orders数据库的派单池表。
订单分流代码如下:
package com.jzo2o.orders.base.service;
import com.jzo2o.orders.base.model.domain.Orders;
/**
* 订单分流
*/
public interface IOrdersDiversionCommonService {
/**
* 订单分流,所有订单均可抢单
*
* @param orders
*/
void diversion(Orders orders);
}
-
定时任务处理写入派单池
-
抢单池的订单没有人抢,距离服务开始时间在120分钟以内时将订单写入orders数据库的派单池表。
进入抢单工程
package com.jzo2o.orders.seize.handler;
import com.jzo2o.api.foundations.RegionApi;
import com.jzo2o.api.foundations.dto.response.ConfigRegionInnerResDTO;
import com.jzo2o.common.utils.BeanUtils;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.common.utils.ObjectUtils;
import com.jzo2o.es.core.ElasticSearchTemplate;
import com.jzo2o.orders.base.constants.EsIndexConstants;
import com.jzo2o.orders.base.model.domain.OrdersDispatch;
import com.jzo2o.orders.base.model.domain.OrdersSeize;
import com.jzo2o.orders.base.utils.RedisUtils;
import com.jzo2o.orders.seize.model.dto.response.OrdersSeizeListResDTO;
import com.jzo2o.orders.seize.service.IOrdersDispatchService;
import com.jzo2o.orders.seize.service.IOrdersSeizeService;
import com.jzo2o.redis.annotations.Lock;
import com.jzo2o.redis.constants.RedisSyncQueueConstants;
import com.jzo2o.redis.sync.SyncManager;
import com.xxl.job.core.handler.annotation.XxlJob;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.util.List;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
import static com.jzo2o.orders.base.constants.RedisConstants.RedisFormatter.SEIZE_TIME_OUT;
import static com.jzo2o.orders.base.constants.RedisConstants.RedisKey.ORDERS_RESOURCE_STOCK;
import static com.jzo2o.orders.base.constants.RedisConstants.RedisKey.ORERS_SEIZE_SYNC_QUEUE_NAME;
/**
* 抢单xxl-job任务
*/
@Component
@Slf4j
public class SeizeJobHandler {
/**
* 当前时间距离服务预约时间间隔小于配置值时进入派单池
*/
@XxlJob("seizeTimeoutIntoDispatchPoolJob")
public void seizeTimeoutIntoDispatchPoolJob() {
List<ConfigRegionInnerResDTO> configRegionInnerResDTOS = regionApi.findAll();
for (ConfigRegionInnerResDTO configRegionInnerResDTO : configRegionInnerResDTOS) {
try {
//传入配置的下单时间距离服务预约时间间隔
owner.seizeTimeoutIntoDispatchPool(configRegionInnerResDTO.getCityCode(), configRegionInnerResDTO.getDiversionInterval());
} catch (Exception e) {
log.error("抢单订单超时处理异常,e:", e);
}
}
}
...
- 由派单池表到Redis派单池:
package com.jzo2o.orders.dispatch.handler;
import com.jzo2o.canal.listeners.AbstractCanalRabbitMqMsgListener;
import com.jzo2o.common.utils.DateUtils;
import com.jzo2o.orders.base.constants.RedisConstants;
import com.jzo2o.orders.base.model.domain.OrdersDispatch;
import com.jzo2o.orders.base.utils.RedisUtils;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static com.jzo2o.orders.base.constants.RedisConstants.RedisKey.DISPATCH_LIST;
/**
* @author 86188
*/
@Component
public class OrdersDispatchSyncHandler extends AbstractCanalRabbitMqMsgListener<OrdersDispatch> {
@Resource
private RedisTemplate redisTemplate;
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "canal-mq-jzo2o-orders-dispatch"),
exchange = @Exchange(name = "exchange.canal-jzo2o", type = ExchangeTypes.TOPIC),
key = "canal-mq-jzo2o-orders-dispatch"),
concurrency = "1"
)
public void onMessage(Message message) throws Exception {
parseMsg(message);
}
@Override
public void batchSave(List<OrdersDispatch> data) {
// 1.同步派单列表
// 1.1.派单列表过滤(人工派单的不进入派单列表)
//ZSetOperations.TypedTuple表示Sorted Set有序集合的元素,包括:value和分数
Set<ZSetOperations.TypedTuple> ordersDispatchIdTypedTupleSet = data.stream()
.filter(ordersDispatch -> ordersDispatch.getIsTransferManual() == 0 || DateUtils.now().compareTo(ordersDispatch.getServeStartTime()) < 0)
.map(ordersDispatch -> ZSetOperations.TypedTuple.of(ordersDispatch.getId(), DateUtils.getCurrentTime() * 1d))
.collect(Collectors.toSet());
// 1.2.同步派单列表
redisTemplate.opsForZSet().addIfAbsent(DISPATCH_LIST, ordersDispatchIdTypedTupleSet);
}
@Override
public void batchDelete(List<Long> ids) {
// 清空派单列表
redisTemplate.opsForZSet().remove(DISPATCH_LIST, ids);
}
}
3)测试
下边我们测试通过订单分流进入派单池的流程。
测试流程:
启动redis
启动orders-manager订单管理服务
启动orders-dispatch派单服务
启动网关
启动customer服务
启动publics服务
启动用户端(前端)
用户登录小程序,进行下单,开始服务时间选择最近的时间
由于前端对时间进行限制,选择的最近的开始服务时间距离当前时间会大于2小时,需要修改foundations数据库的config_region表的diversion_interval字段值,这里统一改为180(表示180分钟)
注意:在下单时选择的家政服务和测试的服务人员的服务技能及地理范围保持一致。
这里服务技能选择:日常 保洁。
地理范围:选择北京黑马程序员附近
预期结果:
向派单池表orders_dispatch写成功
向redis派单池“ORDERS:DISPATCH:LIST” 同步成功
示例:
查看orders_dispatch表:
[
{
"id": 2311240000000000042,
"orders_code": null,
"city_code": "010",
"serve_type_id": 1678649931106705409,
"serve_item_name": "日常保洁",
"serve_type_name": "保洁清",
"serve_item_id": 1685894105234755585,
"serve_address": "北京市北京市昌平区北京市昌平区回龙观街道弘文恒瑞文化传播公司正泽商务中心",
"serve_item_img": "https://yjy-xzbjzfw-oss.oss-cn-hangzhou.aliyuncs.com/aa6489e5-cd92-42f0-837a-952c99653b8b.png",
"orders_amount": 1.00,
"serve_start_time": "2023-11-24 17:00:00",
"lon": 116.34351,
"lat": 40.06024,
"pur_num": 1,
"is_transfer_manual": 0,
"create_time": "2023-11-24 15:28:08",
"update_time": "2023-11-24 15:28:08"
}
]
查看redis派单池:
3.3 派单
1)阅读代码
进入orders-diapatch工程。
根据系统设计:
- 通过定时任务,每分钟执行一次dispatchDistributeJob()方法,代码如下:
package com.jzo2o.orders.dispatch.handler;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.jzo2o.common.utils.CollUtils;
import com.jzo2o.common.utils.DateUtils;
import com.jzo2o.orders.base.constants.RedisConstants;
import com.jzo2o.orders.base.mapper.OrdersDispatchMapper;
import com.jzo2o.orders.base.model.domain.OrdersDispatch;
import com.jzo2o.orders.dispatch.service.IOrdersDispatchService;
import com.jzo2o.redis.annotations.Lock;
import com.xxl.job.core.handler.annotation.XxlJob;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Set;
import java.util.concurrent.Executor;
import static com.jzo2o.orders.base.constants.RedisConstants.RedisKey.DISPATCH_LIST;
/**
* 派单分发xxl-job定时任务
*/
@Component
@Slf4j
public class DispatchJobHandler {
@Resource
private RedisTemplate redisTemplate;
@Resource(name = "dispatchExecutor")
private Executor dispatchExecutor;
// @Resource
// private DispatchDistributeServiceImpl owner;
@Resource
private IOrdersDispatchService ordersDispatchService;
@Resource
private OrdersDispatchMapper ordersDispatchMapper;
/**
* 派单分发任务
*/
@XxlJob("dispatch")
public void dispatchDistributeJob(){
while (true) {
Set<Long> ordersDispatchIds = redisTemplate.opsForZSet().rangeByScore(DISPATCH_LIST, 0, DateUtils.getCurrentTime(), 0, 100);
log.info("ordersDispatchIds:{}", ordersDispatchIds);
if (CollUtils.isEmpty(ordersDispatchIds)) {
log.debug("当前没有可以派单数据");
return;
}
for (Long ordersDispatchId : ordersDispatchIds) {
dispatch(ordersDispatchId);
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
//由于一个订单3分钟处理一次,所以加锁控制3分钟内只加入线程池一次
@Lock(formatter = RedisConstants.RedisFormatter.JSONDISPATCHLIST,time = 180)
public void dispatch(Long id) {
dispatchExecutor.execute(() -> {
ordersDispatchService.dispatch(id);
});
}
}
-
首先通过redisTemplate.opsForZSet().rangeByScore()方法取出派单池中的一批订单
-
调用 dispatch(Long id)方法通过线程池执行
进入ordersDispatchService.dispatch(id)方法:
代码如下:
注意阅读策略模式和责任链模式的代码。
public void dispatch(Long id) {
// 1.数据准备
// 1.1.获取订单信息
OrdersDispatch ordersDispatch = ordersDispatchService.getById(id);
if (ordersDispatch == null) {
// 订单不在直接删除
redisTemplate.opsForZSet().remove(DISPATCH_LIST, id);
return;
}
// 1.3.服务时间,格式yyyyMMddHH
int serveTime = ServeTimeUtils.getServeTimeInt(ordersDispatch.getServeStartTime());
// 1.4.区域调度配置
ConfigRegionInnerResDTO configRegionInnerResDTO = regionApi.findConfigRegionByCityCode(ordersDispatch.getCityCode());
// 1.5.获取派单规则
DispatchStrategyEnum dispatchStrategyEnum = DispatchStrategyEnum.of(configRegionInnerResDTO.getDispatchStrategy());
// 2.修改下次执行时间(默认3分钟),防止重复执行
ConfigRegionInnerResDTO configRegion = regionApi.findConfigRegionByCityCode(ordersDispatch.getCityCode());
redisTemplate.opsForZSet().incrementScore(DISPATCH_LIST, id, configRegion.getDispatchPerRoundInterval());
// 2.获取派单人员或机构
// 2.1.获取派单服务人员列表
List<ServeProviderDTO> serveProvidersOfServe = searchDispatchInfo(ordersDispatch.getCityCode(),
ordersDispatch.getServeItemId(),
100,
serveTime,
dispatchStrategyEnum,
ordersDispatch.getLon(),
ordersDispatch.getLat(),
10);
// 2.3.机构和服务人员列表合并,如果为空当前派单失败
log.info("派单筛选前数据,id:{},{}",id, serveProvidersOfServe);
if (CollUtils.isEmpty(serveProvidersOfServe)) {
log.info("id:{}匹配不到人",id);
return;
}
// 3.派单过规则策略
// 3.1.获取派单策略
IDispatchStrategy dispatchStrategy = dispatchStrategyManager.get(dispatchStrategyEnum);
// 3.2.过派单策略,并返回一个派单服务人员或机构
ServeProviderDTO serveProvider = dispatchStrategy.getPrecedenceServeProvider(serveProvidersOfServe);
log.info("id:{},serveProvider : {}",id, JsonUtils.toJsonStr(serveProvider));
// // 4.机器抢单
OrderSeizeReqDTO orderSeizeReqDTO = new OrderSeizeReqDTO();
orderSeizeReqDTO.setSeizeId(id);
orderSeizeReqDTO.setServeProviderId(serveProvider.getId());
orderSeizeReqDTO.setServeProviderType(serveProvider.getServeProviderType());
ordersSeizeApi.machineSeize(orderSeizeReqDTO);
}
2)测试
测试流程:
启动orders-dispatch派单服务
启动orders-seize抢单服务
启动xxl-job
在xxl-job中添加派单任务
断点跟踪程序运行:
示例:
- 从redis派单池查询订单
- 将派单任务放入线程池
- 执行派单任务
从服务提供者池找到符合条件的服务人员
获取派单策略:距离优先策略
通过责任链模式匹配规则,匹配一个服务人员:
匹配成功调用抢单接口进行机器抢单
派单成功,相应的服务人员登录查看订单,并自动删除redis派单池中的订单。
无法派单的情况:
-
订单与师傅的服务范围、服务技能不匹配。
-
订单的预约服务时间与师傅的服务时间冲突,比如:订单的预约是8点而师傅在上午8点已有单子。

浙公网安备 33010602011771号