目录
核心业务流程
首先明确本模块在核心业务流程的位置,下图是项目的核心业务流程:
用户下单后服务人员通过app进行抢单,机构通过pc进行抢单,抢单成功后服务人员开始现场服务。
服务端抢单原型
服务人员在平台注册后需要进行实名认证、服务技能设置、服务范围设置、开启接单,这四项准备工作完成后方可在平台接单。
服务技能设置
服务技能即服务人员服务范围,比如:服务人员A提供日常保洁、日常维修服务。设置服务技能后服务人员可以抢与自己的技能匹配的订单,比如:服务人员A提供日常保洁、日常维修服务,所以它只能抢日常保洁、日常维修服务的订单。
服务范围设置
设置服务范围后能抢服务范围内的订单,下图中的服务城市是北京,接单范围是朱辛庄地铁站,在抢单查询时以接单范围为中心点查询方圆几公里的用户创建的订单,具体方圆公里数在查询条件中选择。
接单设置
开启接单后方可抢单。
开始抢单
进入抢单页面,选择服务距离和服务类型查询订单,点击“立即抢单”进行抢单。
服务距离:距离服务人员接单范围的距离。
服务类型:
抢单成功查询订单
服务人员抢单成功在我的订单中查询
机构端抢单原型
机构端抢单与服务端抢单的区别是:
1、机构使用pc端,服务端使用app
2、机构端抢单成功后订单状态为待分配,表示待将订单分配给机构下的服务人员;服务端抢单成功后订单状态为待服务,因为服务人员抢到的订单服务人员就是自己。
3、抢单数量限制不同
抢单数量限制是当前拥有的进行中的服务单的最大数量。服务人员默认是10,机构默认是100。
抢单数量限制的目的是规避恶意抢单。
因为机构下的服务人员较多所以机构的签单数量限制大于服务人员。
下边说明机构端抢单的界面原型:
机构端也需要进行实名认证、服务技能与服务范围设置、开启接单后方可在平台接单。
服务单初始状态:待分配或待服务
机构抢单成功:待分配。
服务人员抢单成功:待服务。
开始服务: 待服务---》服务中
服务完成:
服务中---》服务完成
用户取消订单:
待分配---》已取消
待服务---》已取消
运营人员取消订单:
服务中---》已取消
服务完成---》已取消
小结
通过需求分析,抢单模块分如下子模块:
-
抢单设置子模块
设置服务技能、服务范围、开启抢单。
-
抢单查询子模块
查询抢单池中的订单
-
抢单子模块
点击“立即抢单”开始抢单。
抢单成功后生成服务单,服务单的状态包括:待分配、待服务、服务中、服务完成、已取消。
系统整体设计
整体分析
根据抢单需求,抢单的流程和抢券的流程很相似,核心内容也包括三部分:
抢单查询(对应到抢券查询)
在抢单界面查询抢单池中的订单,订单属于抢购的资源,在抢券中优惠券也属于争抢的资源,此处的查询存在高并发需要使用缓存技术进行优化。
查询抢单池中的订单可以根据下单用户的地理位置进行搜索订单,此功能类似“搜索附近”的业务,比如:搜索附近的酒店、搜索附近的房源信息等。(因为抢单本就是高并发,所以不能让其访问数据库,但是设计多条件查询,并且涉及地理位置搜索,所以es是最佳选择,虽然redis也有地理坐标查询,但是他不能多条件进行搜索,搜索出来还要手工进行筛选实现起来比较负责,所以我们选择了es)
结合上边的要求,本项目使用Elasticsearch查询抢单资源,使用ES的基于地理坐标搜索功能完成“搜索附近”功能的开发,Elasticsearch通过全文检索技术提高查询性能。 全文检索:提供模糊搜索等自动度很高的查询方式,并进行相关性排名,高亮等功能
如果要使用Elasticsearch查询抢单池信息就需要将待派单的订单同步到Elasticsearch,这里我们使用Canal+MQ的方式实现数据同步,如下图:
订单支付成功后进行订单分流(当前时刻举例服务时刻小于两个小时,把订单放入派单池(派单表),如果大于两小时放入抢单池(抢单表),因为使用了canal,记录了binlog日志,一旦 canal检测到表出现了变化,就会发送消息到mq,然后消费者消费到消息,把数据添加到es中,用于让用户搜索到,并加入到redis库存中,用于redis的抢单。抢单也是基于redis的lua脚本进行抢单,把抢单的信息记录进同步队列,然后用xxl-job启用多线程进行同步消息到数据库,然后把redis中的库存信息和es中该订单删除)
下单支付成功通过订单分流程序将订单信息写入抢单池,满足系统自动派单需求的订单同时写入派单池,派单部分在后边章节讲解。
通过Canal将抢单池的信息同步到Elasticsearch。
抢单查询从Elasticsearch中查询订单信息,使用ES的基于地理坐标搜索功能完成搜索附近的订单。
抢单(技术方案同领取优惠券)
我们在分析抢单技术方案时结合 抢券 的技术方案进行分析,多个人争抢同一个订单,这里仍然存在超卖问题,我们使用同抢券一样的技术方案去解决超卖问题。
下图中加入了抢单的交互流程:
订单支付成功后进行订单分流(当前时刻举例服务时刻小于两个小时,把订单放入派单池(派单表),如果大于两小时放入抢单池(抢单表),因为使用了canal,记录了binlog日志,一旦 canal检测到表出现了变化,就会发送消息到mq,然后消费者消费到消息,把数据添加到es中,用于让用户搜索到,并加入到redis库存中,用于redis的抢单。抢单也是基于redis的lua脚本进行抢单,把抢单的信息记录进同步队列,然后用xxl-job启用多线程进行同步消息到数据库,然后把redis中的库存信息和es中该订单删除,和同步队列中的信息删除)(这样也是为了减少tps的事务线,通过异步方案进行流量削峰)
说明:
参考抢券的方案,使用Redis+Lua的技术解决超卖问题,这里将抢单库存同步到Redis,每个订单的库存就是1。
抢单执行Lua脚本完成抢单,具体包括:扣减库存、抢单成功写入同步队列。
抢单同步队列的作用是通过异步任务将抢单结果信息同步到数据库。
抢单结果异步处理(技术方案同抢券结果异步处理)
(这样也是为了减少tps的事务线,通过异步方案进行流量削峰)
秒杀抢购业务的并发高,为了避免直接操作数据库这里使用异步任务的方式将抢单结果同步到数据库。
抢单成功创建服务单,异步任务的主要职责是根据抢单结果创建服务单,并且更新订单的状态。
抢单结果同步异步任务的具体的内容如下:
创建服务单:
服务单记录了服务人员进行家政服务的信息,关键字段有:订单ID、订单金额、服务人员ID、服务单状态、服务时间、服务照片等。
服务单初始状态:待分配或待服务
机构抢单成功:待分配。
服务人员抢单成功:待服务。
服务单的详细状态如下图:
更新订单的状态:
用户下单并支付完成后订单的状态为“派单中”,服务人员抢单成功订单状态为“待服务”,机构抢单成功订单状态为“待分配”。
订单状态图如下,详细说明请参见第四章内容。
抢单结果同步成功删除抢单池等相关信息:
抢单结果同步成功后删除抢单池中该订单的信息,这样服务人员在抢单界面无法查询到该订单。
删除数据库中抢单池的记录,将Elasticsearch中对应的抢单记录删除。
删除Redis中该订单的库存信息。
删除Redis中抢单同步队列中的记录。
抢单设置
根据需求,机构和服务人员都需要作抢单前的准备工作方可抢单。
准备工作包括:实名认证、服务技能设置、服务范围设置、开启接单。关于实名认证的业务流程请参考第二章,下边通过阅读代码理解服务技能设置、服务范围设置、开启接单的设置。
下边以服务端为例说明。
服务技能设置
服务人员新注册账号后进入下边的界面:
通过此界面进行服务技能设置、服务范围设置、接单设置。
点击上图中的“去设置”进入服务技能设置界面:
点击编辑开始设置服务技能:
选择服务技能,点击保存。
服务范围设置
进入服务范围设置界面:
服务技能表:存储服务提供者的技能信息。
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;
订单分流
表设计
根据数据流分析需要设计抢单池表(表里的字段是基于业务,业务需要什么字段,表里面就填什么字段)
-- 抢单池(资源池)
-- 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);
经纬度的设置
这里前段穿来得及经纬度是个字符串,我们把他分开即可
订单分流代码
首先当用户支付成功后进行订单分流
这里就是组装数据,然后插入到抢单池中,如果服务预约时间小于指定时间,则插入派单池让系统自动派单。
package com.jzo2o.orders.base.service.impl;
@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);
}
}
}
抢单查询
抢单池同步
同步方案
根据系统设计方案,抢单池的信息需要同步到Elasticsearch,使用Canal加RabbitMQ完成同步,如下:
索引结构设计
在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"
}
}
}
}
查看微服务es的文档
创建成功进行查询 索引列表,命令如下,找到orders_seize。
GET /_cat/indices?v
查询orders_seize的索引结构,命令如下,对照是否和上边创建的orders_seize的索引一致。
GET orders_seize 或 GET /orders_seize/_mapping
抢单池库存结构
抢单池库存信息存储在Redis,在抢单时通过Redis扣减库存。
设计如下:
缓存结构:Hash
RedisKey:ORDERS:RESOURCE:STOCK:{citycode%10}
HashKey:订单id
HashValue: 库存,为1
过期时间:永不过期
缓存一致性方案:通过Canal进行同步
{} 是为了设置路由到哪个节点,只要{}里面内容一致就一定会路由到同一个节点
concurrency是只有一个消费者前来消费消息
@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));
}
}
首先配置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为同步表即可。
mq中收到数据:
通过 "type": "INSERT" 及data可以获取到新增的数据
测试
登录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"
}
}
]
}
}
小结
抢单池是怎么设计的?
-
在数据库中创建抢单池表,存储待抢单的订单信息。
-
用户下单并支付成功将订单信息写入订单表和抢单池表。
-
通过Canal+MQ将抢单池的信息同步到Elasticsearch和Redis中。
同步到Elasticsearch是为了通过ES的地理坐标搜索功能查询订单信息。
同步到Redis是为了将抢单池库存信息同步到Redis,服务人员抢单时请求Redis完成。
-
服务人员抢单完成扣减Redis中抢单池的库存,并记录抢单结果。
-
最后通过异步任务将抢单结果同步到数据库。
抢单查询
定义controller
@RestController
@Api(tags = "服务端 - 抢单相关接口")
@RequestMapping("/worker")
@Slf4j
public class WorkerOrdersSeizeController {
@GetMapping("")
@ApiOperation("服务端抢单列表")
public OrdersSeizeListResDTO queryForList(OrdersSerizeListReqDTO ordersSerizeListReqDTO) {
return null;
}
...
首先在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"(英里)等
}
}
]
}
服务人员可以选择服务类型和距离进行筛选
根据上边编写语句通过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;
}
抢单
抢单流程
参考抢券的方案,使用Redis+Lua的技术解决超卖问题,这里将抢单库存同步到Redis,每个订单的库存就是1。
抢单执行Lua脚本完成抢单,具体包括:扣减库存、抢单成功写入同步队列。
抢单同步队列的作用是通过异步任务将抢单结果信息同步到数据库。
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进行同步
package com.jzo2o.orders.seize.controller.worker;
@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
@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);
}
}
这个开始时间的队列,在lua脚本中没有使用
向redis中存抢单用户的服务开始时间和当天的已接单数,但是好像文中抢单时,没有往redis中里面写已经抢了多少单,没有更新,而且第二天的时候也不会把这些删除。只是抢单的时候从redis中取了一下,存在bug
抢单结果异步处理
异步处理方案
抢单成功根据抢单结果进行异步处理:
交互流程如下:
创建服务单:
服务单记录了服务人员进行家政服务的信息,关键字段有:订单ID、订单金额、服务人员ID、服务单状态、服务时间、服务照片等。
服务单初始状态:待分配或待服务
机构抢单成功:待分配。
服务人员抢单成功:待服务。
更新订单的状态:
用户下单并支付完成后订单的状态为“派单中”,服务人员抢单成功订单状态为“待服务”,机构抢单成功订单状态为“待分配”。
抢单结果同步成功删除抢单池等相关信息:
抢单结果同步成功后删除抢单池中该订单的信息,这样服务人员在抢单界面无法查询到该订单。
删除数据库中抢单池的记录,通过Canal将Elasticsearch中对应的抢单记录删除。
删除Redis中该订单的库存信息。
删除Redis中抢单同步队列中的记录。
表设计
服务单表设计
-- 服务任务
-- 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)}
抢单成功同步xxl-job
/**
* 抢单成功同步任务
*/
@XxlJob("seizeSyncJob")
public void seizeSyncJob() {
syncManager.start(ORERS_SEIZE_SYNC_QUEUE_NAME, RedisSyncQueueConstants.STORAGE_TYPE_HASH, RedisSyncQueueConstants.MODE_SINGLE);
}
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);
}
当抢单数据同步到数据库后,会在抢单池中删除该订单,canal然后发送到mq,mq监听到删除命令
根据类别判断是删除还是添加
@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));
}