文章目录
1. 运营端
1.1 服务类别管理
增、删、改、查
增:数据库服务类型名字设置唯一索引,服务名字重复则新增失败。
删:先看删除的ID存在不(根据ID查),存在再看服务类别的启用状态(只有草稿状态才能删
,启用或者禁用状态不能删除)
改(内容):在更新完type表后,还需同步更新ES:将待更新数据同步更新到sysn表
改(启用):只有草稿状态和禁用状态才能启用
改(禁用):只有启用状态才能禁用,并且:只有下属的服务项全部为非启用状态
才能禁用。
查:分页查
1.2 服务项管理
增、删、改、查
增:要选择该服务项所属的服务类别
删:只有服务项为草稿状态才能删除。
改(启用):当前状态草稿和禁用状态才能启用,并且所属的服务类型为启用才能启用
改(禁用):当前状态为启用状态,并且没有区域在运营该服务项才能禁用。(如果该服务项在某些区域正在运营将无法禁用,需要先将该服务项在所有区域下架方可禁用。)
要点:
- 启用看左,大范围启用我才能启用,服务类别启用服务项才能启用
- 禁用看右:我如果禁用会影响到谁,没有区域运行该服务项,我这个服务项才能禁用
1.3 区域管理
增、删、改、查
删:只有区域状态为草稿才能删除
改(启用):只有当前区域为草稿和禁用状态才能启用,并且该区域下存在上架的服务才能启用
改(禁用):只有当前区域为启用状态才能禁用,并且该区域下不存在上架的服务才能禁用
1.4 区域服务管理
增、删、改、查
增(向区域添加服务):选择一个地区,根据服务项ID来查看服务是否启用,只有服务项是启用状态才能加入到地区。
删(在区域下删除服务):选择一个地区和该地区下的一个服务,点击删除,只有当前区域状态为草稿才能删除。
改(区域服务上架):确定一个区域和一个服务,点击上架,只有当区域服务状态为草稿或者下架状态,并且:该服务为启用状态(服务项启用状态),才能上架成功。注意:修改成功后需同步数据,向serve_sync表添加记录。
- 上架要加入缓存:@CachePut(value = RedisConstants.CacheName.SERVE, key = “#id”, cacheManager = RedisConstants.CacheManager.ONE_DAY)
改(区域服务下架):确定一个区域和一个服务,点击下架,只有当前区域服务为上架状态才能下架。并且最后:删除serve_sync表的记录。
- 下架要删除缓存:@CacheEvict(value = RedisConstants.CacheName.SERVE, key = “#id”)
2. 客户管理
用户端 :小程序认证
- 先拿到登录凭证,之后再请求微信以登录凭证、appid、app密钥来换取openId,完成认证,在我们数据库如果openId是第一次登录则增加用户记录;将用户信息base64编码再转换为JWT令牌,将token返回给前端。之后前端发请求都会携带token信息,网关先对token解析验证,网关还会有黑名单、白名单的验证逻辑:看看uri是不是在这里面来放行和拒绝访问,然后拿到token解析验证,验证通过后才放行,并且将用户的信息向下传递:写入到http头中,再继续请求微服务,微服务只需要解析head中的用户信息,并且写入到ThreadLocal中,方便应用程序使用。
- GateWay这里是 filter:实现GatewayFilter
- 微服务这里是springMvc:实现HandlerInterceptor(AOP)
gateway代码
package com.jzo2o.gateway.filter;
import cn.hutool.core.util.IdUtil;
import com.jzo2o.common.constants.ErrorInfo;
import com.jzo2o.common.constants.HeaderConstants;
import com.jzo2o.common.model.CurrentUserInfo;
import com.jzo2o.common.utils.Base64Utils;
import com.jzo2o.common.utils.JsonUtils;
import com.jzo2o.common.utils.JwtTool;
import com.jzo2o.common.utils.StringUtils;
import com.jzo2o.gateway.properties.ApplicationProperties;
import com.jzo2o.gateway.utils.GatewayWebUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* token解析过滤器
*
* @author 86188
*/
@Slf4j
public class TokenFilter implements GatewayFilter {
/**
* token header名称
*/
private static final String HEADER_TOKEN = "Authorization";
private ApplicationProperties applicationProperties;
public TokenFilter(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1.黑名单和白名单校验
// 1.1.黑名单校验
String uri = GatewayWebUtils.getUri(exchange);
log.info("uri : {}", uri);
if (applicationProperties.getAccessPathBlackList().contains(uri)) {
return GatewayWebUtils.toResponse(exchange,
HttpStatus.FORBIDDEN.value(),
ErrorInfo.Msg.REQUEST_FORBIDDEN);
}
// 1.2.访问白名单
if (applicationProperties.getAccessPathWhiteList().contains(uri)) {
return chain.filter(exchange);
}
// 2.获取token解析工具JwtTool工具
// 2.1.获取token
String token = GatewayWebUtils.getRequestHeader(exchange, HEADER_TOKEN);
if (StringUtils.isEmpty(token)) {
return GatewayWebUtils.toResponse(exchange,
HttpStatus.FORBIDDEN.value(),
ErrorInfo.Msg.REQUEST_FORBIDDEN);
}
// 2.2.获取tokenKey
String tokenKey = applicationProperties.getTokenKey().get(JwtTool.getUserType(token) + "");
if (StringUtils.isEmpty(token)) {
return GatewayWebUtils.toResponse(exchange,
HttpStatus.FORBIDDEN.value(),
ErrorInfo.Msg.REQUEST_FORBIDDEN);
}
// 2.3.新建toekn解析工具对象jwtToken
JwtTool jwtTool = new JwtTool(tokenKey);
// 3.token解析
CurrentUserInfo currentUserInfo = jwtTool.parseToken(token);
if (currentUserInfo == null) {
return GatewayWebUtils.toResponse(exchange,
HttpStatus.FORBIDDEN.value(), "登录过期或访问被拒绝");
}
// 4.用户id和用户类型向后传递
String userInfo = Base64Utils.encodeStr(JsonUtils.toJsonStr(currentUserInfo));
// 4.1.设置用户信息向下传递
GatewayWebUtils.setRequestHeader(exchange,
HeaderConstants.USER_INFO, userInfo);
// 4.2.设置用户类型向下传递
GatewayWebUtils.setRequestHeader(exchange,
HeaderConstants.USER_TYPE, currentUserInfo.getUserType() + "");
// 4.3.请求id
GatewayWebUtils.setRequestHeader(exchange, HeaderConstants.REQUEST_ID, IdUtil.getSnowflakeNextIdStr());
// 4.请求放行
return chain.filter(exchange);
}
}
微服务MVC代码
package com.jzo2o.mvc.interceptor;
import com.jzo2o.common.constants.HeaderConstants;
import com.jzo2o.common.model.CurrentUserInfo;
import com.jzo2o.common.utils.Base64Utils;
import com.jzo2o.common.utils.JsonUtils;
import com.jzo2o.mvc.utils.UserContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* @author itcast
*/
@Slf4j
public class UserContextInteceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1.尝试获取头信息中的用户信息
String userInfo = request.getHeader(HeaderConstants.USER_INFO);
// 2.判断是否为空
if (userInfo == null) {
return true;
}
try {
// 3.base64解码用户信息
String decodeUserInfo = Base64Utils.decodeStr(userInfo);
CurrentUserInfo currentUserInfo = JsonUtils.toBean(decodeUserInfo, CurrentUserInfo.class);
// 4.转为用户id并保存
UserContext.set(currentUserInfo);
return true;
} catch (NumberFormatException e) {
log.error("用户身份信息格式不正确,{}, 原因:{}", userInfo, e.getMessage());
return true;
}
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
// 清理用户信息
UserContext.clear();
}
}
服务人员端:手机验证码登录
- 手机验证流程:后端随机生成6位,短信服务商发送验证码,并且将验证码存储在redis中(5分钟),用户拿到短信验证码后,进入登录流程,拿到用户输入的和redis中存储的是否一样,一样再拿手机号去数据库查,第一次就新增,最后生成token返回。
机构端:账号密码登录(注册和重置密码需要手机号验证)
运营端:账号密码
- 密码采用BCrypt这种hash算法加密,是不可逆的,即使密码一样每次加密后生成的密文是不一样的,存储在数据库中更加安全,加密调用encode方法进行hash加密,解密拿到DB中存储的密文,再由用户输入的密码进行match方法,其实就是也将用户输入的密码进行加密,最后判断两个的hash,来进行密码判断。
2.1 用户端定位
小程序定位功能:小程序定位成功后用户在查询家政服务项目时会根据定位的城市查询该城市有哪些服务项目。
开发:
- 前端会调用微信的wx.getLocation接口,获取当前位置的经纬度。
- 之后拿着经纬度请求高德api(地理逆编码请求:根据经纬度获取地理信息),获取到经纬度对应的地理位置信息,需要配置访问高德接口的key
- 题外话:高德这种第三方组件,如果要用百度地图呢?像动态线程池,用到了redis作为注册中心,那引入了动态线程池组件但是我们还想在项目中用redis,怎么确保redis的bean不会重复?其实可以定义一个开关,@ConditionalOnMissBean,@ConditionalOnProperty(prefix = “amap”, name = “enable”, havingValue = “true”),这种思路就是当开关打开我们才用这个组件,才将bean注入。
2.2 我的账户设置
服务端设置银行账户、机构端设置银行账户
saveOrUpdate(MybatisPlus框架):如果主键没有,则插入,有,则更新
2.3 实名认证
题外话:服务端和机构端提交了认证信息,在运营端进行审核。
服务端的功能模块:
机构端的功能模块:
运营端功能模块:
认证流程:服务人员或者机构提交认证,运营端查询到待审核的记录进行审核:服务人员提交记录,新增申请资质认证记录,在worker_certification_audit表插入一行并且设置审核状态为0(未审核),并且查询worker_certification表(同一用户),设置认证状态为认证中,如果有记录则更新,没记录则新增。
之后机构端审核,设置worker_certification_audit表中审核状态为1、认证状态为通过或不通过,并将认证信息同步到worker_certification表。
3. 门户
总言:门户访问量大,提升速度是关键。
静态资源:图片、视频、CSS、Js等 静态资源放到 CDN上面 ,减少网络路由耗时。
动态资源:异步请求数据,数据做缓存(redis)、前端也可以做缓存
Nginx负载均衡:部署多个Nginx服务器共同提供服务。
3.1 缓存技术方案
redis客户端:Jedis、Lettuce,Spring Boot 默认使用的是 Lettuce 作为 Redis 的客户端。
两种访问redis的框架
SpringCache缓存框架、Spring Data redis(RedisTemplate),这两种都是通过Lettuce 去访问redis的。
Spring Cache:
@EnableCaching:开启缓存注解功能
@Cacheable:查询数据时缓存,将方法的返回值进行缓存。
@CacheEvict:用于删除缓存,将一条或多条数据从缓存中删除。
@CachePut:用于更新缓存,将方法的返回值放到缓存中
@Caching:组合多个缓存注解;
@CacheConfig:统一配置@Cacheable中的value值
Spring Cache这种缓存框架,注解的方式,都是基于AOP实现的,对添加注解的类生成代理对象,代理对象中做缓存相关的逻辑,比如说@Cacheable,在代理对象中先查缓存,有的化直接拿出来,没有的话才执行方法。
缓存穿透:
- 缓存空值本项目采用
- 布隆过滤器:存在误判率,且删除困难,因为有hash冲突的存在。
- redisson实现的布隆过滤器
- redis的bitmap位图结构实现。
- google的Guava库实现。
缓存击穿:
- 加锁
- 热点数据不过期+定时任务刷新缓存:本项目采用
- 缓存预热:提前预热、定时预热
缓存雪崩:
- 对同一类型信息的key设置不同的过期时间(+一个随机值)
- 缓存预热
缓存不一致问题:
其实就是改DB时,缓存中的数据也得同步,操作数据库和操作redis都是通过网络来交互的,那通过网络交互那就可能出现:发就没发成功、发成功了并且更新成功了但是没拿到网络有延迟、还有可能说写操作异常了等等。在并发的环境下就会存在数据不一致的问题。
- 分布式锁:顺序执行,先写库,再更新缓存。两个都成功还好,那要是数据库成功了redis失败了数据库就得回滚,要使用分布式事务组件
- 延迟双删:先删缓存再写入主库,这时候别的线程通过从库查询出来放入缓存,延时一段时间后线程一再删除缓存。会有短暂得数据不一致
- canal+MQ最终一致:canal监听binglog日志,将数据变化日志写入到mq,缓存更新服务再从mq拿到数据进行更新。本项目采用
- 利用数据库层面得事务,双写表,将需要缓存得数据写入到任务表,定时任务再去扫描。
缓存方案:
缓存三类信息:区域相关和服务相关
区域相关:区域列表、区域下的首页服务列表、区域下的服务类型列表、区域下的热门服务列表
服务相关:单条服务、单条服务项
开通区域列表缓存实现:当前哪些城市开通了服务。
- 查询缓存:查询已开通区域列表,如果没有缓存则查询数据库并将查询结果进行缓存,如果存在缓存则直接返回。缓存为永久
- 启用区域:删除启用区域信息缓存,并且:删除首页图标、删除 热门服务、服务类型;最后再刷新缓存:启用区域列表、首页服务列表、热门服务列表、服务类型列表,springcache的话就是查一次。
- 为什么还要删除其他三种缓存:区域变了--------> 首页服务列表是根据区域来选择的、热门服务列表也是、服务类型也是,这些都得变。禁用区域同理。
- 禁用区域:删除启用区域信息缓存;并且:删除该区域下的其它缓存信息,包括:首页服务列表,服务类型列表,热门服务列表。
- 定时任务:每天凌晨缓存首页服务列表。
这些缓存:凌晨xxljob定时任务更新
Quartz不支持分布式环境下的任务调度,分布式下一个服务部署多个实例,就是多个JVM进程,每个实例都会执行更新缓存的任务,就会重复执行。
使用XXL-JOB就可以解决使用多个jvm进程重复执行任务的问题
XXL-JOB调度中心可以配置路由策略:
轮询策略、分片广播
缓存穿透:缓存null30分钟
@Cacheable:unless与condition区别,unless 才能对返回值进行判断。
@Cacheable(value = RedisConstants.CacheName.HOT_SERVE, key = "#regionId", unless = "#result.size() != 0", cacheManager = RedisConstants.CacheManager.THIRTY_MINUTES),
xxl:每天凌晨一点更新与区域挂钩的缓存信息,热门服务详情缓存更新每三小时更新。
3.2 搜索服务
使用ES做搜索服务,使用canal + MQ保证索引同步 :是索引
serve_sync表记录了(与服务有关的消息):服务项、类别、区域、服务、的 增删改操作,canal监听serve_sync的bing log。
canal:通过监听binglog日志将数据变更推送到MQ,消费消息,将变更同步到ES。
实现:首先mysql开启binglog,配置行格式,创建账户并赋予读的权限,这个账户用来canal读取binglog,canal中有两个配置文件,canal.properties配置使用哪种MQ,以及MQ的交换机啊账户啊这些信息;instance.properties这个配置文件配置表的正则规则,以及配置动态topic(解析到这些表要往哪个topic发),并在rabbitmq设置对应的交换机和队列,以及交换机和队列的绑定关系。
要在rabbitmq中新建一个用户,不然权限不够。
MQ:经典三问
- 如何保证MQ消息的可靠性?
- 总体方案:生产者失败重试,重试次数耗尽入库,并且通过MQ的提供的确认机制,nack入库,并且失败由xxl调度重发,当重发还是失败交由人工处理。消费端:自动ack,当消费失败重试,重试3次如果还失败会将消息投递到失败消息队列,由定时任务程序定时读取队列的消息,达到一定的次数还未成功则由人工处理。此外还有保证消息、队列、交换机持久化。
- spring注解@Retryable失败重试,重试三次,当重试耗尽时将消息保存到数据库;并且MQ提供了两种回调机制:ConfirmCallback和Return回调,ConfirmCallback:在发送消息时指定回调对象,这个回调对象就是实现了spring中的ListenableFutureCallback,如果没有返回ack则将消息记录到失败消息表,如果经过重试后返回了ack说明消息发送成功,此时将消息从失败消息表删除。Return回调:如果消息发送到交换机成功了但是并没有到达队列,此时会调用ReturnCallback回调方法,在回调方法中我们可以收到失败的消息存入失败消息表以便进行补偿。
- 如何保证MQ顺序消费?
- 要保证发送端发送到同一个队列并且队列中的消息是有序的,消费端消费的顺序也是有序的(单线程消费,只绑定一个消费者),大部分场景下发送到同一个队列的消息是顺序的,但是可能由于网络延迟重发机制等造成队列中的消息不是顺序的。
- 保证只有一个消费者消费队列,配置rabbitmq队列为单一活动消费者模式。
- 如果队列中的消息是乱序的,消费端需要做乱序的处理,以确保按照binlog顺序来消费消息,乱序的处理:binglog中会存储执行时间和偏移量,基于这两个做乱序的处理。
- 乱序处理:基于乐观锁机制,每次变更数据增加版本号,更新数据前比较版本号,如果更新数据的版本号与当前数据版本号不一致则不处理。
- 参考文章:https://www.cnblogs.com/itdream/p/13510860.html
搜索接口:对门户首页(bool查询):按照citycode(term)、关键字(multi_match)、服务类型(term,可无)------> 查询服务项消息。
4. 订单管理
表的设计:
一个订单只包括一种商品,此时无须记录订单明细,将购买商品的详细信息记录在订单表
订单表包括以下几部分:本项目中有单独的支付中心负责与微信交互,订单表中还包括交易单号(支付中心的订单id,在请求微信付款时携带此单号)
谁—购买了*------多少钱,还包括订单的基础信息
- 下单人id、名字、手机、地址、
- 服务信息、名字、价格、数量
- 总额、优惠卷额度、实付
- 订单id、订单状态、付款状态、交易渠道、第三方交易支付单号和退款单号、
订单Id:6位年月日+13位序号(redis中incre)
下单实现: 必传参数:地址簿id、预约时间、服务id。可选参数:优惠卷id。
下单接口要远程调用客户中心服务拿到地址、要远程调用运营基础服务拿到服务信息。
拿到这些信息,再组装数据,插入到order表
- 细节1:远程调用防止微服务雪崩使用sentinel熔断降级
- 服务熔断降级的逻辑在客户端,编写调用微服务的客户端,使用@SentinelResource注解,当执行方法异常时走降级逻辑,当异常数或者异常比例达到sentinel中配置的阈值时,走熔断逻辑,不会再远程调用直接走熔断方法返回。
- 远程调用怎么实现的?:首先在api工程定义远程调用接口,接口用@FeignClient标记,就会为这个接口创建代理类来请求,并拿到结果,在代理类中拿到想调用的微服务名字,在请求的时候nacos会将微服务名字解析成对应的地址,进行请求。在微服务的服务端,编写api接口的实现,方法返回到接口的代理类,实现了远程调用。
- 细节2:事务控制只加在对order表的save方法上,不在整个方法上加
- 整个方法中有远程调用,要通过网络请求,时间长会导致数据库连接耗尽,数据整个mysql服务不可用
所有支付业务统一由支付服务去实现:
支付服务包括:小程序形式的下单支付、二维码形式的下单支付、第三方平台的聚合支付等。
小程序版下单支付逻辑:
- 首先我们的系统下单都是统一请求到支付服务,再由支付服务请求第三方下单支付。
- 下单支付逻辑:查阅了微信小程序接口文档,在我们的项目中,首先用户选择要下单的服务(还包括上门地址、预约的时间)点击预约下单,在我们的系统会远程调用其他服务将订单所需要的信息聚合,聚合后会将订单的信息入库(订单系统的微服务数据库 )。这时候其实就是在我们自己的系统库中加入了订单信息,此时订单状态以及支付状态都是未支付,这时候当用户再点击确认支付后,会携带我们系统的订单号(业务单号)来请求支付服务,支付封装了请求微信下单的实现,这一步骤其实就是请求微信下单,然后拿到微信返回的prepay_id(预支付交易会话标识),prepay_id在之后前端请求微信小程序调起支付是需要用到的。补充:其实在支付服务中会构建支付服务的订单系统,请求微信时是以支付中心的交易单号请求的。
- 理解重复下单和重复支付:https://blog.csdn.net/qq_51240148/article/details/137977396
- 怎么避免重复支付:在支付中心服务中有多层的校验以及加锁:首先家政的订单请求到支付中心,会在其DB中先查询这个订单,但可能一个家政的订单对应支付中心多个订单(不同支付渠道),首先会查询同一个支付渠道下的交易单(根据业务系统应用标识、家政单号、支付渠道查询),如果发现有记录时说明已经请求过微信下单了,这时候不用重复请求直接将此条记录返回;以上是第一层保障,此后,还会判断用户是不是切换支付渠道了,如果用户第一次拿着业务订单号请求微信下单,第二次拿着同样的业务订单号请求支付宝下单,这时候会将第一次订单失效也就是请求微信关闭订单(这时候是对交易单加锁的);只有当前面这些都校验过那才是第一次下单,这时候请求微信下单,拿到prepay_id入库返回给业务系统。
- 怎么判断改变支付通道的?第一次请求支付接口,是微信通道,在支付中心创建支付中心的订单,之后请求微信,微信将二维码或者prepay_id返回,支付中心拿到,之后返回家政系统,之后保存到家政DB中,那这条订单就有微信通道相关信息;当我们没付款换成支付宝请求支付,那家政DB中已经有这条订单的微信通道,查出来一看和现在请求的不一样,设置改变通道,之后请求到支付中心,判断是不是改变通道,是的话就将该家政订单对应的交易单失效,请求微信对这个交易单取消。
- 支付中心DB消单逻辑:根据业务系统标识、业务订单Id查询支付中心DB,当查询到有记录并且DB中的支付通道和传入的不一致并且订单处于付款中,就请求微信对该交易单消单。
查询支付结果逻辑:
- 先查询家政DB中订单表,当订单的状态是未支付并且存在交易单号:表示这个订单已经请求过微信下单过了,这时候远程调用支付中心, 这时候先看支付中心DB中该订单状态,如果为已付款说明这时候微信已经回调过,并且DB也更新了,这时候直接返回这个订单相关信息,如果该订单还没支付则请求微信获取支付结果。
支付服务作为项目的公共支付服务,对接支付服务的可能不止家政服务订单还可能有其它收费订单
,比如:年会员
订单,购买优惠券
接收支付通知逻辑:
- 支付中心获取到微信的支付结果后会将其发送到MQ(xxl定时任务执行,五秒执行一次。分片广播的方式处理:每个JVM实例都是先查库中100个处于付款中的记录,for循环处理每条记录,采取订单ID%分片total==本机器Idx,才会处理这条记录(相当于增加机器实现多线程),处理这条记录就是请求微信获取支付结果),各个业务系统拿到对应的消息,更新数据库中订单状态。
- 支付服务将各种系统的订单都发送到MQ中,同一个交换机,那各个应用怎么只处理自己系统的订单?本项目采用支付中心发送到同一个交换机并且路由key相同,那各个系统应用都能拿到发送的所有消息,在支付中心交易单中有标识各个应用的字段,这个字段会作为消息的一部分,各个应用拿到消息后会判断是不是自己应用的订单消息,是的话才去处理。并且订单状态支付成功才接受消息处理,支付失败也不处理。
- 处理的逻辑:就是拿到消息更新本应用的订单状态为已支付。
- 支付服务将各种系统的订单都发送到MQ中,同一个交换机,那各个应用怎么只处理自己系统的订单?本项目采用支付中心发送到同一个交换机并且路由key相同,那各个系统应用都能拿到发送的所有消息,在支付中心交易单中有标识各个应用的字段,这个字段会作为消息的一部分,各个应用拿到消息后会判断是不是自己应用的订单消息,是的话才去处理。并且订单状态支付成功才接受消息处理,支付失败也不处理。
怎么保障接口的安全性:
使用https加密传输,并且使用接口签名以及敏感参数加密的方式保证接口的安全性。
怎么理解签名和加密?签名其实是为了防止内容被篡改,使用不可逆的加密算法像MD5、摘要算法、hash算法,对内容进行加密,生成签名,这样的话虽然内容是在请求体参数中,即使被劫持也不担心内容被改,因为改了之后在服务端经过验签之后就会发现被篡改,签名信息是解密不了的只能验证。
而加密解密其实是在网络传输中,我们有些敏感数据不想暴露,经过加密算法对其加密,在服务端再解密为原始内容。有两种类型:对称加密算法、非对称加密算法。
签名和验签:为了防止内容被篡改。
- 签名是对原始数据通过签名算法生成的一段数据(签名串),用于证明数据的真实性和完整性。签名通常使用密钥进行生成,这个密钥可以是对称密钥或非对称密钥。
- 验签是对签名串进行验证的过程,用于确认数据的真实性和完整性。验签的过程通常使用与签名过程中相对应的公钥进行解密。
加密与解密:为了防止内容被泄露,保证内容的机密性。
- 对称加密: 使用相同的密钥进行加密和解密。常见的对称加密算法包括 AES、DES、3DES。
- 非对称加密: 使用一对密钥,包括公钥和私钥,公钥用于加密,私钥用于解密,或者私钥用于加密,公钥用于解密。常见的非对称加密算法包括 RSA、ECC。
4.1取消订单功能 :要区别于人为取消和机器取消
人为取消(用户点击的取消):未支付下取消我们改DB中订单表,将状态改为取消并且添加新记录到订单取消记录表
机器取消(定时任务、懒加载查看订单信息这些是被动取消的订单):未支付下,15分钟超时取消,这时候是机器取消,查询本系统DB中未支付订单并且时间超时订单,但是注意:可能本系统DB中订单支付状态不是最新的,要再查一下支付中心查看最新订单的支付状态,没支付并且超时的订单才取消,这种方式在xxljob和懒加载取消超时订单中都要做。
自动取消未支付订单的思考:
未支付订单15分钟自动关闭,怎么实现?
我们使用了定时任务 + 懒加载的方式实现,其实有很多种实现,像xxljob定时任务,那如果说要做到及时性,就要将扫描的时间间隔缩短,每一秒扫描一次,其实xxl很多情况下都是cpu空转,每一秒看一下时间还没到,虽然说这种方式实时性好一些但是占用cpu资源;用MQ的延时队列也能实现,但是其实也是一样的道理,加延时队列了就得用cpu扫者看看时间到没到,并且引入MQ就还得考虑与mq相关的问题:不丢不重顺序消费等。其实根据我们自己下单的逻辑,如果说下错单了,没有支付,我们其实压根不关系这个订单到没到时间关闭没有,反过来我们下单了还没支付,这时候想要支付那就会找到这个订单再去支付,所以说我们没有必要一秒不差的到点的取消订单,因为用户如果不想下单他根本不会关心订单到期没有,只有用户想对订单支付这时候用户会查看这个订单再支付,这时候在用户查看的时候我们再去查询订单然后判断时间到没到,其余情况我们是用xxl定时任务每一分钟扫描一下,扫描到超过15分钟了默默关掉就可以。这样做不但性能好,而且也能做到实时性。
取消订单的总流程:
未支付订单:当订单状态为未付款才能将订单状态改为已取消
派单中订单:当订单状态为派单中才能将订单状态改为已关闭
退款sql:非退款中状态才更新订单的退款状态:只有当微信返回的退款记录是退款成功或者失败才更新DB中退款记录。sql为不等于退款成功才更新为退款成功,不等于退款失败才更新为退款失败。
取消订单分为未支付订单和已支付订单。
未支付订单:只需要在本系统的DB中修改订单记录(订单表状态改为取消、插入取消订单表记录)这个是针对人为操作取消。机器定时取消:订单15分钟超时自动取消订单是由xxljob每一分钟扫描 + 懒加载的方式实现(用户查看订单才去看看订单是不是超时)。
已支付订单:派单中订单的取消,因为要退款需要请求微信,为了保证修改了退款记录必须得退款,实现上利用DB事务,不但修改订单表退款状态、取消订单记录、还写入退款记录表中,xxl再扫描退款记录表,请求支付中心再请求微信退款,拿到返回的退款响应后,根据退款成功还是失败更新DB中订单表和退款记录表的退款状态。为了及时退款,这里在DB层事务控制下写入退款记录表后是开了新线程来请求微信退款的,xxl作为兜底,确保该退的都退掉,并且其实开新线程的目的是为了及时退款但退款的结果可能不能及时拿到,因为请求微信退款是一个异步接口,拿到的结果可能还是退款中而不是退款成功或者失败,但是更新DB订单表和退款记录表是根据退款成功或者失败来更新的,退款中是不更新的,这样就会导致DB中退款记录还是退款中但其实用户已经收到退款了,所有最后通过xxl定时任务再次请求微信退款拿到最新的退款结果后更新DB中的退款状态。
xxl中还包括:使用状态机更新订单状态为已关闭(包括新增订单快照)、删除退款记录表的相关记录因为已经处理完这条退款记录 。以上方法在一个事务中。
派单中取消订单:
4.2 使用状态机管理订单状态
使用状态机组件完成对订单状态的管理,避免在业务代码中对订单的状态进行硬编码,用状态机管理将来好扩展和维护。使用上:需要改变订单状态只需要调用状态机的changeState传入订单ID和订单的状态变更枚举,就会找到这个状态变更枚举对应的变更动作类,其实也是从spring容器中拿的,bean的名字是有特定的规则的,系统名字_+枚举变更类的code拼接而成,也是能拿到一个状态变更枚举对应的状态变更动作,也将是对应的handel,我们在对应的handle中编写修改订单状态的逻辑,将它从业务代码中抽离到handle中,解耦。
两张表:订单快照表、订单持久化表
- 订单快照:记录订单变化的历史信息,只要订单状态发生改变就会记录快照
- 持久化表:对订单状态的持久化记录
实现:
- 状态枚举类:定义了订单的所有状态
- 状态变更事件枚举类:定义了订单状态的触发事件,将订单由前一个状态改变为后一个状态
- 事件变更动作类:定义了每一种事件所对应的处理动作,在这个handler中定义具体的操作,像支付成功事件,此时修改order表订单状态变为已支付。
4.3 分库分表:ShardingSphere
为什么分库分表,当数据量达到一定程度的时候我们通过优化数据库索引不能解决性能问题,b+树大,内存占用大,查找慢或者内存放不下磁盘io更耗时,所以要进行分库分表。
垂直分库:微服务DB
垂直分表:冷热数据,给用户展示关键的信息,当用户点击时到另一个表查询详情
单表性能瓶颈
水平分库:将单表的数据分摊到两个库中(hash路由)
水平分表:同一个库中对一个表进行差分
用户ID为路由进行分库(hash),每个库中订单ID分表(范围)
4.4 订单查询优化
面向C端用户的订单查询类接口访问量非常大,对订单数据增加缓存,在DB中增加索引。
优化了:用户端订单详情、用户端订单列表、运营端订单列表。
用户端订单详情:放缓存 + 15分钟自动取消
将订单的快照放入缓存,从redis取,过期时间30分钟。
涉及到订单状态转换的,都会将状态变化的订单快照存入快照表(新增记录),并且设计到订单状态转换的都会删除redis中该订单的缓存,下一次再从库查,再放入缓存。
用户端订单列表:滚动查询(覆盖索引) + redis缓存(hash结构)
将属于用户订单信息存储在redis(hash)中,大key拼接userId,小key订单ID,value订单信息;查的时候使用覆盖索引,对订单状态 + userID + 排序ID创建聚合索引,使用覆盖索引先查出用户的订单IDs(订单ID列表),当订单IDs在缓存中直接拿,当不在缓存中从DB中拿,再将数据写入Redis。
扩展:
5. 秒杀抢购
抢卷模块的数据流:
优惠卷表的内容:用户信息、活动信息、核销信息
三个内容:
- 活动查询(已开始、待开始【一个月内开始的活动】)
- 优惠活动名称、优惠劵满减或折扣信息、起止时间、是否抢光、活动状态
- 抢卷
- 对于活动库存、抢卷成功队列、抢卷同步队列都使用Hash结构,其中redisKey后面都加{活动id%10},
- 我的优惠卷(未使用、已使用、已过期)
Pipeline与MULTI 的区别:
- pipline也可实现批量执行多个 redis命令,并没有保证这些命令的执行是原子的;multi实现的是将多个命令作为事务块去执行,保证整个操作的原子性。
- redis事务与mysql区别:其实就事务概念来说就是让操作都成功或者都失败,其实实现的化也多种方式,比如说存在分布式事务的情况下,mysql写入双表,定时任务再操作同步表来更新其他服务(redis、es),redis中的事务其实就是保证多个操作是一个原子操作,比如说抢卷,抢成功了不但要该库存还要记录抢成功的记录,再由定时任务从同步队列拿到再进行DB的修改啊落库。
redis+Lua解决超卖
- 使用Lua脚本执行一批redis命令,判断是不是已经抢过、没抢过就判断库存、库存够就表示可以抢、最后将抢卷的结果写入同步队列(之后由异步 任务读取队列数据,进行DB中减库存、加抢卷记录)
- 集群redis环境中怎么使用Lua:要保证Lua中操作的Key都路由到同你个hash槽,对所有的Key{***}。
解决活动状态延迟问题:
- 原因:预热程序每一小时将DB中活动信息预热到Redis,定时任务每一分钟根据活动开始和结束时间将活动状态变更,那么用户读到的是从缓存中拿活动信息,状态就不是实时的。
- 解决:当用户刷新的时候程序判断:从缓存中拿到缓存的活动信息(List),然后拿到活动状态,不一致出现的情况:拿到的是未开始,但实际活动已经开始了;拿到的是进行中,但实际活动已经结束了。
- 拿到的是未开始,但实际活动已经开始了:if 状态为未开始,当前时间在活动起止时间内,则最新的状态为进行中;if 状态为未开始,当前时间大于活动结束时间,则最新的状态为已结束。
- 拿到的是进行中,但实际活动已经结束了:if 状态为进行中,当前时间大于活动结束时间,则最新的状态为已结束。
- peek()方法:对于stream中的每个元素都会执行peek方法,解决DB中字段与要返回给前端的字段名字不对应问题。
库存预热:库存预热是定时任务,定时任务不但会吧活动的信息预热,还会将库存的信息进行预热,但是注意:活动一旦开始就不容许修改了,因为会造成数据不一致:redis扣减了库存并写入到同步队列,然后同步任务再拿到同步队列的进行在优惠卷表增加领取记录和修改活动库存,此时redis库存和DB库存因为时间间隔会有数据不一致情况,这时候再将DB中查到的老库存预热到redis不行。
库存预热到redis逻辑:对于未开始的活动进行库存预热,对于已经开启的活动:判断redis中有没有这个key,没有说明活动开启了但是还没预热库存这时候也将此库存预热到redis。
5.1 抢卷结果同步到DB
总体流程:我们使用xxl每隔1分钟调度将redis数据同步到DB,我们使用线程池来读取redis中同步队列中的数据,每个线程读取一个同步队列的数据(批量读取:scan方法、游标记得释放),然后更新DB中活动库存和新增用户领取优惠卷记录。这里要注意:xxl每隔一段时间调度一次,将十个同步队列分配给线程来执行同步任务,会存在线程安全问题,要对队列加锁。
多线程要点:开多线程为了加速处理*****,每个线程执行的任务是****,本项目中任务是读取一个同步队列数据同步到DB,为了避免多个线程操作同一个队列,对同步队列加锁。
同步队列:activityId%10,理论上讲抢卷成功了写入同步队列(hash,大key:activityId%10,小keyuserId,value:活动Id),但是一个同步队列并发性不好,将活动的同步队列差分为10个,每个队列用一个线程执行同步任务(优惠卷表增记录,活动表改库存,成功后删除redis中的同步记录 )。线程池处理。
线程池的配置:核心线程为1、最大线程为10、队列采取不存储任务的队列(在没有线程去消费时不会保存任务)、拒绝策略为丢弃并且不抛异常。
多线程处理同步队列导致的线程安全问题:
多个线程处理同一个队列(存在并发安全):定时任务每隔1分钟调用线程池处理一个数据同步任务,假如同步队列1的数据非常多
,一轮结束后线程1还在处理同步队列1
的数据,此时当第二轮
开始又会从同步队列1开始分配线程去处理
,将会找一个空闲线程
去处理同步队列1的数据,此时将会有多个线程处理一个同步队列
的数据。
加锁后:避免了多个线程处理同一个队列,对队列加锁(队列是共享资源)
问题原因:xxl第一次调度将A队列分配到B线程,但是A队列数据量大,线程B一直执行,xxl第二次调度又将A队列分配到B线程,A队列数据就由A线程和B线程执行同步任务,导致线程安全问题,要对队列加锁,避免一个队列由多个线程处理。
加锁的逻辑:在任务中,先根据队列名字加锁,只要加锁成功了才从redis获取同步队列同步,获取不到就说明有别的线程在处理这个队列,xxl这次调度就不对这个队列分配处理了。
5.2 优惠卷核销
核销和退回数据流:
原型分析:用户在下单时可以选择优惠卷(后端查出的符合规则的优惠卷列表),选择优惠卷后显示该优惠卷优惠的金额,订单的实付金额是总金额—优惠金额。当用户选择好优惠卷并保存订单时,要做两件事情(分布式事务):
- 使用后的优惠卷要核销:在核销表新增记录(谁、在哪个订单、用了啥卷) + 修改优惠卷表卷的核销状态(优惠券已使用、使用时间、订单id)。
- 订单表保存优惠价格
- 一张优惠券使用完毕将不能用在其它订单。
- 当订单取消后已使用的优惠券会重新退回到我的优惠券,此时用户又可以使用该优惠券。
可用优惠券列表筛选条件:用户ID + 优惠卷没过期 + 优惠卷没使用 + 订单金额 > 满减金额 + 订单的金额 > 优惠的金额。
5.2.1 核销中的分布式事务
什么是分布式事务:两点
- 跨服务完成一次事务
- 跨数据源完成一次事务
造成分布式事务的根本原因:
不同业务的数据不在一个数据库中,可能是本系统分库,也可能就不是一个微服务那自然DB也将不是同一个了,本地的两个库都不是同一个数据库连接那就不能用DB层事务控制了,不同微服务中的库不但不是同一个库而且还有网络的不确定性;总之就是操作两个DB要保证要么都成功要么都失败即分布式事务。
一般系统都是AP,那用什么技术方案满足AP:
-
(都成功的)一方先成功另一方最终成功:可以使用MQ或者双写表+定时任务
-
(有失败回滚的)一方成功另一个方失败,成功方进行回滚:使用Seata进行分布式控制
- XA、AT、TCC、SAGA
- AT 如下图:各分支事务都独自提交,向事务协调者报告事务状态,当有分支事务失败,则将事务回滚,执行undo log。
6. 抢单
服务人员在平台注册后需要进行实名认证、服务技能设置、服务范围设置、开启接单,这四项准备工作完成后方可在平台接单。
抢单数量限制是当前拥有的进行中的服务单的最大数量。服务人员默认是10,机构默认是100。
抢单数量限制的目的是规避恶意抢单。
服务人员抢单成功或系统派单成功将生成订单对应的服务单,服务单状态如下:
抢单的总体流程:
抢单的同步队列:hash结构,也是分为十个同步队列,再xxl定时任务,多线程处理。
抢单库存:hash
订单分流:订单支付成功后不但使用状态机修改订单状态,还,将订单分流到抢单池,当距离订单预定的服务开始时间小于2小时还会将订单分流到派单池中。
查询抢单列表:首先远程调用客户中心,拿到抢单人员的相关消息进行校验:抢单人员有没有认证通过、有没有设置接单地点、服务技能,校验通过后拿到前端传过来的接单范围、服务项在ES中查询 。ES查询中使用距离条件,搜索一定范围的单子,升序排列,并且支持滚动分页,以上一页最后一条记录的距离为条件(searchAfter)。
ES搜索附近怎么实现?
对于地理坐标不变的场景(订单中服务的地址),比如:搜索附近的酒店、搜索附近银行等,可以提前将搜索目标信息同步到Elasticsearch中,再通过Elasticsearch的geo去根据地理坐标去搜索附近几公里内的酒店、银行等。
使用geo时在索引中设置geo_point类型的字段,查询时需要传入经纬度坐标及距离(公里),将查询以此经纬度坐标为中心方圆几公里的信息。
对于地理坐标变的场景,比如:搜索附近的骑手、搜索附近的出租车,这里就需要在手机定时上报坐标到系统中,系统收到上传的坐标更新至Elasticsearch中,再通过geo去搜索附近的骑手、搜索附近的出租车等。
抢单:服务人员通过ES查询出抢单池中的订单,在点击抢单时,会进行判断,抢单人员有单子的数量限制和时间限制,在执行Lua抢单之前会判断:当抢的这个单子的服务时间和自己之前已经抢到的单子的服务时间冲突时是不能抢的(针对个人有这个限制,针对机构则没有)并且抢的单子的数量也有限制。都满足时Lua抢单:扣库存 + 写入抢单成功同步队列 + 个人数量和单子时间更新。
citycode为redis Key(citycode % 10),hash key为人员 ID + 时间 或者 人员 ID + 接单数量,分别表示接单时间列表和接单数量
抢单结果异步处理:
Todo:这里有个问题:怎么保证DB操作和删除Redis操作的事务性。
我的理解:同步队列十个也是加锁(和抢卷结果同步类似),我们要对抢单的结果进行处理,处理的逻辑是创建对应的服务单并且修改订单的状态、还要清除抢单池对应订单,这三个表在一个库中,使用事务是可以保证这三张表都成功或失败,关键就是要操作redis,删除同步队列记录和订单库存,这个操作是没办法保证成功的,可能有网络超时,超时也有多种情况的,比如说发就发超时了,还有就是发成功了操作redis也成功了但是返回超时了,这时候我们就要考虑以什么为准了,如果以redis为准,就是即使redis超时了但是我还是认为成功了,这种情况如果返回超时是不会影响的,因为其实redis已经操作成功了,但是如果发送超时了,也就是说redis就没操作我还认为成功了,这种情况就会导致这个Key没有删除掉,那下次xxl还会拿到redis的这个同步队列中的数据再执行同步,那我们就看看再执行一次会有什么影响,创建服务单有主键(订单Id)约束、修改订单状态是修改不成功的(上一次已经修改为待服务或者待分配,这再由派单中修改就失败)、删除抢单池已经没有了再删除也不影响,所以这三个表再执行也不影响,我们最后再加上删除redis中库存和同步队列数据;以上情况是以redis为准,就是说不管成功不成功我都认为成功,还有一种情况,如果超时我认为是异常了,那业务应该失败也将是说三张表回滚,但是超时我们不知道是发送超时还是返回超时,如果发送超时那还好等下一次调度,如果返回超时这其实我们是不能接受的,因为redis已经删除了记录,下次也不会调度了,也将是说用户下的订单没有服务人员接单,这种是不能接受的,所以,我们采取了默认redis是成功的,即使超时,我也将三张表保存了,无非就是redis这个key没有删除,下一次调度其实还有很多条件的校验,我们也不会造成资损。
服务单:谁(服务人员ID) + 服务哪个单子(订单ID、订单金额、服务时间) + 服务单状态(待分配、待服务、服务中、服务完成、)
服务单表分库分表策略:
分库策略:按服务人员id分库,表达式:jzo2o-orders-${serve_provider_id % 3}
分表策略:orders_serve_${(int)Math.floor(id % 10000000000 / 15000000)}
7. 总结订单管理
订单状态:
服务单状态:
对订单表服务单表分库分表:用户IDhash分库,0-1500万根据订单Id分表
- 创建订单:
- 订单号:2位年+2位月+2位日+13位序号(Redis中Incr)
- 优惠卷核销(分布式事务):根据传不传优惠卷ID来判断走不走分布式事务,不传就直接保存订单信息,传的话不但保存订单信息还修改营销系统卷的信息(优惠卷表改核销状态 + 插入核销记录)
- 防止订单重复提交:以用户ID + 服务 ID 来标识用户的一次下单操作,锁10秒,十秒内用户不能重复下单。
- 订单支付:
- 接口安全性:对于这些支付啊安全性要求高的场景,通常都是要进行签名的防止数据被篡改,比如说对接微信支付,要对请求的参数签名微信来验签保证数据不会被篡改,通过请求头Authorization来传递签名信息,对于一些敏感字段还可以使用加密算法加密。
- 通过请求支付中心服务支付下单拿到prapare_id再由微信小程序唤起支付。
- 支付结果、退款结果会通过支付中心发送到MQ,本系统再拿到MQ消费。
- 支付服务防止重复下单设计:我们使用的是微信小程序,不会存在重复支付问题,但是如果是二维码支付,则存在:同一个订单同一个支付渠道只生成一个支付二维码、在请求第三方支付下单使用分布式锁控制不会重复请求第三方下单、切换支付渠道时先关闭原渠道的交易单再生成新渠道的交易单、使用定时任务每天扫描交易单表,如果存在多个支付成功的交易单则进行自动退款。
- 查询订单:
- 订单详情:自动取消懒加载 + 查询redis缓存(缓存的是状态机中的
快照
:拿不到就先到快照表查,快照表保存了同一个订单的状态变化的所有的记录,按创建时间降序排列
拿到第一个记录就是最新的快照,缓存过期时间是30分钟
,当订单状态变更
,此时订单最新状态的快照有变更,会删除快照缓存
,当再次查询
快照时从数据库查询最新的快照信息进行缓存。) - 用户端订单列表:滚动查询 + redis中hash缓存十分钟 (sort_by作为滚动ID,是按照服务开始时间计算生成的,是递增的,使用覆盖索引查询出订单IDS,再去redis查,不在的再去DB查,DB中和redis缓存一致性方法采取的是:当用户订单信息改变了则删除Redis中缓存的数据:Hash结构,redisKey:用户ID,hashKey:订单ID,value:订单详情)
- 取消订单:使用策略模式
- 待支付:支付超时系统自动取消、用户取消订单,订单状态改为已取消。
- 派单中:用户和运营人员都可以取消订单,订单状态改为已关闭、到达服务时间还没有派单成功系统自动取消(删除抢单同步表,由canal同步到ES,删除Redis中抢单库存)、取消订单后自动退款(写入退款记录表,开启一个线程及时退款,xxl定时扫描退款记录兜底)、删除抢单池派单池记录(删除两张表记录canal同步)。
- 待服务:用户和运营人员都可以取消订单,订单状态改为已关闭、取消订单后自动退款。
- 服务中:只有运营人员可以取消,订单状态改为已关闭、取消订单后自动退款。
- 订单完成:只有运营人员可以取消,订单状态改为已关闭、取消订单后自动退款。
普通用户:可取消待支付、派单中、待服务的订单。
运营人员:可取消除待支付状态下的所有订单。
可以看出来订单取消的功能:订单的状态不同取消的逻辑不同、取消的用户不同权限也 不同
策略模式:不同的场景下执行取消订单的逻辑是不同的,编写策略接口定义取消订单方法,编写各个实现类,所有用户的所有状态都编写实现类,然后通过环境类将这些bean放入map中,在业务上执行取消订单时,只需要根据用户类型 + 订单状态拿到对应得bean,执行取消订单逻辑。小细节:如果用户使用优惠卷在取消订单时还需要退回优惠卷是分布式事务,为了尽量不影响性能,在用户使用了优惠卷再开启分布式事务,没有使用则本地事务。
-
删除订单:删除订单是不希望订单信息出现订单列表中。订单表隐藏字段:display (1:展示,0:隐藏)
-
服务单管理:开始服务前拍照片,服务完成拍照片
-
历史订单:冷热分离,将超过15天的订单放到历史订单数据库中TiDB,迁移完成远程调用删除本系统的订单记录。订单数据库存储的是热数据,历史订单数据库存储的是冷数据。
- 为什么使用TiDB:需要给用户和运营人员提供历史订单的
查询接口
,并且还需要对历史订单进行一些统计分析
处理。 - 采取方案:所以通过Canal+MQ将
完成的订单
(完成、取消、关闭)迁移
到历史订单数据库TiDB
,在历史订单服务对订单数据进行统计分析
,并通过定时任务迁移冷数据
。具体来说:在状态机中,当订单取消关闭完成时会将订单记录服务单记录写入到同步表canal监听发送到MQ,同步程序拿到MQ消息,写入到迁移表,迁移表提供历史订单的查询和分析,当订单超过15天,每天凌晨将昨天0点到昨天24点之间完成15日后的订单信息迁移到历史订单表。由定时任务将这些订单迁移到历史订单表。 - 订单冷热分离:订单完成15日后迁移到
历史订单表
和历史服务单表
,迁移一千条一次,先统计总的记录,分页的方式查询出迁移的数据进行插入历史表中,按天迁移。迁移完成后,查询两张表迁移的数据是否匹配(记录数一样),匹配说明迁移完成,删除待迁移表的记录。Mybatis中使用!CDATA[]完成转义。
- 为什么使用TiDB:需要给用户和运营人员提供历史订单的
-
订单统计:xxl凌晨将TiDB中待迁移表上统计的订单看板消息插入按天统计表中,看板实际请求的数据来源于这个表中(提前统计)。
-
统计结果导出(easyExcel):基于模板导出
8. 派单调度
本项目派单调度的业务流程是什么?
为了提高交易成功率,派单调度模块会自动匹配订单与服务提供者,进行撮合匹配,流程如下:
- 首先获取待分配的订单。
- 根据订单的属性,包括:地理位置、服务项目等去服务提供池中匹配服务提供者。
- 根据派单策略对符合条件的服务提供者进行规则匹配,每个派单策略通常有多个规则,从第一个规则逐个匹配其它规则。
- 最后获取匹配成功的服务提供者,系统进行机器抢单。
- 机器抢单成功,派单成功。
有两种同步服务,第一种同步服务提供者:
- 将服务人员的信息同步到ES,方便根据服务项、时间、范围查找;
- 同步服务人员或者机构的接单数量和时间限制(机构只有接单数限制)到Redis
第二种是同步派单池:为了快速获取派单信息也为了派单失败3分钟重新派单使用Redis的ZSet
- 同步派单池中的订单到Redis派单池。
两种进入派单池的方式:
- 支付成功后距离服务开始时间不足两小时,会写入抢单池表和派单池表
- 抢单池的订单没人抢,定时任务将距离服务开始时间在120分钟以内时将订单写入orders数据库的派单池表
派单流程:xxl每分钟调度,首先是一些同步,同步到ES、Redis,查询Redis派单列表ZSet结构每次查100,查询到后使用线程池开多线程处理每一次派单(核心线程15、最大线程30、队列容量5000、拒绝策略是主线程执行)。派单的逻辑是:首先是一些数据的准备和校验,根据准备好的数据,比如说城市、服务项、接单范围、服务时间、订单所在的经维度在ES中查询10个服务人员,再将这10个服务人员选择配置的策略进行规则过滤,过滤出一个人员,给这个人调用抢单接口抢单。一次派单后修改Redis中派单池中该订单的score,3分钟,避免一直派单。
抢单流程:同抢单,先校验,服务人员数量时间校验、再使用Lua签单,最后写入成功队列。
然后同步任务定时取redis同步队列,执行同步:增加服务单、改订单状态、改抢单人员时间数量限制、删除抢单池和派单池的这个订单记录、删除Redis该订单库存、删除Redis该订单同步队列数据。
当删除了派单池中记录后,Canal + MQ---->MQ消费,将Redis中的Zset结构的派单池删除。
当到达服务预约时间,也会删除派单池信息。