关于12306项目学习记录(2)——— ticket-service模块的接口详解

727 阅读29分钟

此为我对12306学习开源项目的个人部分心得和拙见,可以多多支持项目作者:马丁,项目来源地址:nageoffer.com/12306

TrainStationController

根据列车 ID 查询站点信息 listTrainStationQuery

首先我们点击车次的时候,可以看到所查看车站的所有车次,此处我们在前端点击的是车次,所以是按照车次即StringID来查询一个车次的所有站点的信息,如车站到达时间和出发时间,停留时间等

TicketController

根据查询条件查询站点v1

用户输入的条件有出发地、目的地、出发日

一、用户的查询要经过一层责任链判断,过滤对应的无效请求(先把责任链执行较快的部分执行)

这里的责任链会去获取缓存的实例,去查询哈希的结果,我们会去查询缓存QUERY _ALL_REGION_LIST里面的数据,根据起点和终点找到对应数据

  1. 获取Redis模板实例
    • 通过 distributedCache.getInstance() 方法获取 StringRedisTemplate 的实例,用于操作 Redis。
  1. 获取Hash操作对象
    • 通过 stringRedisTemplate.opsForHash() 获取 HashOperations 对象,用于对 Redis 中的 Hash 类型数据进行操作。
  1. 查询缓存中的数据
    • 使用 hashOperations.multiGet 方法查询缓存中关于请求参数 requestParam 指定的出发站和到达站的数据。这里 QUERY_ALL_REGION_LIST 是一个 Redis 中的 key。
  1. 检查查询结果
    • 通过 actualExistList 列表中的元素数量来判断出发站和到达站的数据是否存在。如果列表中没有元素为 null,则表示缓存中已经存在所有必要的数据,方法结束执行。
  1. 处理数据不存在的情况
    • 如果 emptyCount 为 1,表示有一个站点不存在;如果 emptyCount 为 2 且 CACHE_DATA_ISNULL_AND_LOAD_FLAGtrue 且缓存中存在 QUERY_ALL_REGION_LIST,则表示两个站点都不存在,抛出客户端异常。
  1. 获取分布式锁
    • 使用 redissonClient.getLock(LOCK_QUERY_ALL_REGION_LIST) 获取一个分布式锁,以确保在加载数据时不会有多个线程同时执行相同的操作。
  1. 尝试加载数据
    • 在获取锁之后,再次检查缓存中是否存在 QUERY_ALL_REGION_LIST。如果存在,重新获取数据并检查两个站点是否存在。如果不存在,继续执行数据加载逻辑。
  1. 从数据库加载数据
    • 通过 regionMapper.selectListstationMapper.selectList 方法从数据库中查询所有区域和站点的数据。
  1. 构建缓存数据
    • 将查询到的区域和站点数据构建到一个 HashMap 中,键为代码,值为名称。
  1. 将数据写入缓存
    • 使用 hashOperations.putAll 方法将构建好的 regionValueMap 写入 Redis 缓存中。
  1. 设置数据加载标志
    • 设置 CACHE_DATA_ISNULL_AND_LOAD_FLAGtrue,表示数据已经加载到缓存中。
  1. 检查请求的站点是否存在
    • 检查请求参数中的出发站和到达站是否都在缓存中,如果不在,抛出客户端异常。
  1. 释放锁
    • 最后,无论操作成功还是发生异常,都在 finally 块中释放锁。

这个方法的目的是确保在处理票务查询请求时,出发站和到达站的数据是存在的,如果数据不在缓存中,则从数据库加载并更新缓存。同时,使用分布式锁来保证数据加载的线程安全。

这个记录就是责任链在缓存存的地区表

二、

这段代码是一个方法,用于处理火车票查询的页面请求,并返回查询结果。以下是该方法的具体流程:

  1. 责任链模式验证
    • 使用 ticketPageQueryAbstractChainContext.handler 方法,通过责任链模式验证城市名称是否存在、出发日期是否有效等条件。
  1. 获取Redis模板实例
    • 通过 distributedCache.getInstance() 获取 StringRedisTemplate 实例,用于操作 Redis。
  1. 查询站点详情
    • 使用 stringRedisTemplate.opsForHash().multiGet 方法查询出发站和到达站的详情。
  1. 检查站点详情是否存在
    • 检查查询到的站点详情中是否有 null 值,如果有,则表示站点信息不完整。
  1. 获取分布式锁
    • 如果站点信息不完整,使用 redissonClient.getLock 获取锁,以确保线程安全地加载缺失的数据。
  1. 重新查询站点详情
    • 在锁的保护下,重新查询站点详情,以确保在加载数据之前没有其他线程修改了缓存。
  1. 加载站点数据
    • 如果站点信息仍然不完整,从数据库中查询所有站点信息,并构建站点与区域名称的映射。
  1. 更新Redis缓存
    • 将站点与区域名称的映射数据写入 Redis 缓存。
  1. 构建站点详情列表
    • 根据站点与区域名称的映射,构建站点详情列表。
  1. 查询车次信息
    • 构建一个基于出发站和到达站的 Redis key,并查询车次信息。
  1. 检查车次信息是否存在
    • 如果车次信息不存在,获取锁并重新查询,以确保数据的一致性。
  1. 从数据库加载车次信息
    • 如果车次信息不存在,从数据库中查询车次关系,并构建车次信息。
  1. 构建车次详情
    • 对于每个车次关系,查询车次详情,并构建 TicketListDTO 对象。
  1. 更新Redis缓存
    • 将车次详情写入 Redis 缓存。
  1. 构建座位信息
    • 查询座位价格信息,并构建座位信息列表。
  1. 更新座位信息
    • 根据座位价格信息和剩余票数,更新座位信息。
  1. 构建响应对象
    • 构建响应对象 TicketPageQueryRespDTO,包含车次列表、出发站列表、到达站列表、车次品牌列表和座位类别列表。
  1. 返回响应
    • 返回构建好的响应对象。

这个方法的主要目的是处理火车票查询请求,从 Redis 缓存中获取站点和车次信息,如果信息不完整,则从数据库中加载数据,并更新缓存。同时,它还处理座位信息的查询和构建,最终返回一个包含所有查询结果的响应对象。这个方法使用了分布式锁来确保在加载和更新缓存时的线程安全。

我们在平台页面进行查询的时候,缓存存的是train_station_price和region_station_price

如果我们一开始查一条路线的起点和终点,那么我们会把两个站点之间的所有两两关系记录下来,包括地区的关系和站点的关系,所以首先缓存的是所有火车站点的地区映射表

大概的分页查询流程就是先去查缓存的region_train_station_mapping,对起始点和终点作为key,region_train_station:出发地和终点,有没有两个站点的信息,没有的话就去数据库里查所有满足起点和终点的路径,之后将其写入缓存,下图就是查询出发地和目的地杭州的结果有四个车次(分布式锁查询)

之后我们要根据车次的结果去同时缓存每个车次的基本信息

查询车票的价格和价格对应的座位类型,对应的是train_info列车的基本信息,我们要通过列车的信息去封装我们的座位类型结果,每一个车次对应一个result,最后把result存进SeatResult集合中。把之前的result转成Json格式存进之前我们所说的region_train_station:出发地和目的地中

我们在前端查询北京、杭州东确实是有四条记录,但是前端中还有座位类型的数据和价格,这些从哪里来呢?

那之前我们是有一个SeatResult的集合,result是对应我们刚刚四条记录每一条车次信息,我们接下来会利用这些信息,去查询对应车次的座位类型,座位数量,座位价格。

首先根据price键去查询是否有对应缓存,没有的话就用safeget(执行查询数据库流程和查询结果放入缓存)(分布式锁+双重判定),去查询对应的price键,可以看到里面每一个车次都有具体的数据(座位类型和价格)

这里的trainStationPriceDOList就是上文的13个路径的座位类型和价格,我们要对每一个路径查询对应的座位数量,于是创建了seatClassList。

1、创建座位数量前缀键:train_station_remaining_ticket:train ID_出发地_目的地,先去缓存查询数量,如果缓存查询不到,就使用load方法加载缓存,查出对应的路径对应座位类型数量

load方法

基于以上这些查询,再封装对应RespDTO就可以返回对应的操作了

根据查询条件查询站点v2

可以看到,我们v1的操作使用了大量的分布式锁,这其实是一个上锁力度很重的情况,v1都是锁一条路径上的

v2这个方法的主要特点是使用管道(pipeline)操作来批量查询票价和剩余票数信息,这样可以减少网络延迟,提高查询效率。

购买车票V2

在v1的购买车票中,还是会有大量的购买请求打入Redis数据层。比如本次的车次的票量为100,但是有10000个购票请求进入争抢分布式锁,还是对数据层的压力很大。所以基于此,我们能不能在购买请求抢分布式锁之前,先对一部分请求进行令牌限流,令牌设置的数量大体与票量相近,让购票请求先去抢夺令牌,只有获得令牌的请求才可以去进行后续流程,但是没有获取到令牌的请求该怎么办呢,我们去调试看看。

责任链过滤和验证信息

首先对于请求,我们进行责任链的校验,可以看到对于用户的信息,请求里面是没有乘车人的真实信息,只有乘车人的ID和对应的seatType座位类型。

这里责任链的实现,是专门有一个责任链的容器上下文,在Spring初始化的时候,三个责任链(query、purchase、refund)被注册成bean注入在Spring的ApplicationContext中,所以我们从spring容器中根据类型获取三个责任链bean,注入到自定义责任链上下文容器中,这里也用到了stream流,用于根据ordered来排序责任链,来让各个责任链按指定的顺序正确执行。

购买参数非空验证责任链

车次存在性验证责任链

在执行完参数验证(乘车人不为空,当前日期验证(非空这些用的都是hutool的工具类))后,执行车次存在性验证。具体流程如下:

1、从我们的分布式缓存组件获取对应的车次实例,因为是购票的流程是需要验证车辆的合法性的,因为用户的购票请求很有可能是一个不存在的车次,所以我们需要去查找结果。

这里的distributed.safeget方法是有回写策略的,即缓存中不存在车次数据,就去数据库查找并回写缓存(Redisson的分布式锁)。过期时间设置为15天。查询结果为空,就抛出异常。这里的查询结果为空的话,就意味着我们用户购买请求提供的这个车次ID,是在缓存和数据库都不存在这个车次实例的,故这个购买请求一定是一个非法数据,要及时上报和通知。

2、就算用户提供的是正确的车次,但是这个车次也有可能是没有开通权限的车次。比如1月份才开始的车次,你12月份的请求就获取到了。肯定是不许的。使用Date类来获取时间戳进行验证。

3、查询车站是否在车次中,并获取我们该车次的所有站点,我们继续通过自定义分布式缓存组件(含有回写策略的safeget方法)去获取我们的车次信息(JSON数据)。并将其转换封装为ArrayList实例。再使用stream流验证我们获取到车次集合的完整性。

如何根据车次集合来验证我们的车次顺序的正确性呢?用户的购买请求就提供给了我们车次的起点和终点。首先在集合中,起点站一定要位于终点站的前面或等于终点站。若找不到终点站和起点站的索引,则说明我们的这个车次集合是非法的,要抛出异常。

车次库存责任链校验

最后执行车次库存的责任链校验:分别有如下步骤

1、将我们的车次ID、请求的起点站和终点站进行字符串拼接作为单一车次的key。

2、获取分布式缓存实例。获取请求中的乘车人集合,并使用stream流进行分组(根据乘车人选择的座位类型),以座位类型为key,座位类型对应的多个乘车人座位集合设立为val。

3、既然我们要获取库存,那么我们肯定要验证用户选择的座位数量是否超出了现有的车票库存。对于第2步生成的map,我们进行遍历。

这里不会执行余票扣减的逻辑,因为用户此时还没有真正执行购票逻辑,且用户不一定真正购买,所以我们只需要验证库存数量是否足够即可,通过index12306ticketservice_remaining_ticket_1去获取对应的余票库存。

3.1、统计当前座位类型的座位结果,从缓存中获取座位的库存,如果库存类型大于旅客的类型数量,就return,否则就说明站点是没有足够余票供顾客购买了。此时抛出自定义异常。

这个座位余量加载是专门使用了一个余量加载器来实现的,通过搜索对应的值(座位类型),统计一条车次的座位类型及其座位库存。

验证乘客是否重复购买责任链

令牌限流执行

请求经过了责任链的校验之后,但购买请求还没有真正执行购买流程。请求需要去获取令牌才可。执行takeTokenBucket方法,令牌的逻辑是怎么样的呢?

1、首先获取当前用户购买的车次信息,并从trainStationService里面获取对应的所有站点映射关系。这个车次有5个站点,就有10个笛卡尔积映射(也可以理解为是该车次上的10个路线)。之后获取分布式缓存实例,创建当前请求的一个相关令牌(名:ticket_avaliability_token_bucket_bucket_1,1是trainID),并返回boolean值HashKey,并查看当前的缓存是否存在该令牌,不存在令牌就可以去创建(创建分布式锁+双重判定锁)

2、首先创建分布式锁实例,先尝试重新获取锁tryLock,失败就抛出异常,否则双重判定,再次查询令牌结果hasKey2,为空的话就去开始创建令牌实例

3、每一个车次实体都有自己对应的车次类型(列车类型 0:高铁 1:动车 2:普通车),我们通过findseattypebycode传入对应的车次结果,其会统计该车次类型所拥有的座位类型集合。之后创建一个TokenMap,key为上面10个车次对应的站点路线和座位类型,val为对应座位类型的车次的数量结果。

一般来说,针对于某一车次,如果其有三个座位类型的话会有三个对应的key,那么这里有10个路线,预计生成30个key。key名称为起点站_终点站_座位类型

4、获取到了30个key之后,我们将其缓存,之后其他对该车次其中10个路线的购买请求可以复用这些座位库存数据。这里在购票请求获取令牌之后只会触发一次putAll操作,后续会被hasKey操作过滤掉,最后业务逻辑完成释放锁。

脚本写入令牌

此处是使用自建的单例对象容器。从容器中获取lua脚本actual。

首先获取到请求中对应的乘车人,将乘车人按照座位类型分类,生成一个seatTypeCountMap,之后将我们的Map通过stream流来转换为JSONArray类型的数据,对每一个entry创建一个JSONObject对象,放入对应的keyval,返回赋值的jsonObject,得到JSONArray

扣减多余的线路

之后我们执行了一个listtakeouttrainstationroute方法。这个方法是为了区分购票请求的线路和刚才那10个线路中不同于前者的线路,这部分有交集的线路是需要重复扣减座位库存的。

因为我们选的线路,是整个车次的起始站和终点站,所以该车次所有的线路的座位库存都需要扣减座位库存,即查出来的9条线路。

如果不是起点站和终点站呢。比如我们有A->B->C->D->E,买了B->D的线路,总线路是十个,那这里takeout扣减库存的关联线路应该为除了A->B、B->D、D->E以外的所有线路。之后将执行脚本,脚本key参数为tokenBucketHashKey, luaScriptKey,args参数为JsonArray数组seatTypeCountArray

此时我们获取token的结果不为空,故将获取到的结果转化为类result(TokenResultDTO),封装我们判断Token的结果。

令牌获取失败的逻辑
获取本地令牌缓存

在我们获取令牌的过程中,刚刚我们的token逻辑结果如果为空,则说明当前请求是没有成功获取到令牌的。要不就是令牌不够了,要不就是获取令牌的时候失败了。

这时候我们会从tokenticketsrefreshmap中获取一个令牌的对象缓存。这个tokenticketsrefreshmap是本地缓存(Caffeine)中的,令牌缓存为10分钟有效期(所以令牌不是存放在Redis的,而是存在JVM本地缓存中的)。

在这里,我们一开始获取不到令牌缓存中的令牌的话,我们会锁住TicketService共享资源,做一次令牌缓存的双重判定(因为这时候有可能其他请求归还了令牌了,就不用继续执行比较重的加锁操作了)。如果还是空的话,就创建一个令牌(new Object()),将其存入本地令牌缓存。并进行token令牌刷新操作(tokenIsNullRefresh Token)。

刷新令牌缓存

可以看到,是专门开了一个线程池来(newScheduledThreadPool,核心线程数等于最大线程数,数量设置为1)进行刷新令牌缓存的任务,此线程池还是定时任务线程池,说明刷新缓存是一个周期性的固定任务。

首先刷新Token令牌的时候,保证多个请求,只有一个请求刷新令牌,故使用分布式锁上锁(LOCK_TOKEN_BUCKET_ISNULL)。使用trylock失败就返回(因为可能已经有其他请求持有了该锁)。之后调用线程池创建定时任务(10秒之后开始执行任务)。

1、创建座位类型集合和Token令牌数量的Map,之后将刚刚执行token的结果,使用stream流来分割tokenresult。

这个tokenResult本身是有携带座位库存的数据的,我们将分割的结果,存入刚刚创建的map和集合中。所以一开始tokenresult中的数据为List,元素为座位类型_座位数量,例如0_810。

2、将数据存入集合和Map中后我们获取列车 startStation 到 endStation 区间可用座位数量,对于区间中每一个座位的数量,我们要查询令牌数量和库存数量是否满足一致性需求。

如果相同座位类型,令牌的数量是小于库存的数量的话,我们需要删除对应令牌。

删除令牌的话,首先我们要获取缓存的实例,构建要删除的令牌key,执行StringRedisTemplate delete命令。

最后执行解锁的操作逻辑。

创建ReentrantLock集合

对于获取了令牌的请求之后,我们正式执行购票的逻辑。但在购票之前,我们还需要考虑一些问题。

多个客户端会有不同的请求,不同的请求要路由到不同的实例。比如本地客户端有10个请求,有十个客户端,Redis的实例有5个,100个请求去争夺5个分布式锁。所以不仅仅是分布式锁有锁竞争,且客户端内部也有锁竞争。那么就有如下的关系。

首先单个客户端的实例要去先抢夺本地锁,10个请求设置5个本地锁,这样单个实例有本地锁的请求有5个,共50个请求。剩余含有各自本地锁的50个请求去争抢5个分布式锁,就涉及了两次争抢锁的过程。

故我们创建了本地基于AQS的ReentrantLock集合和Redisson的RLock集合。我们根据请求的购票数量(我们的订单涉及三个乘车人,每个乘车人选择了不同的座位类型)创建一个座位类型的Map(使用stream流按照座位类型分组,商务票两张,一般座位一张)。

对于每一个Map节点(座位类型,对应的票集合),我们进行上锁

首先这里有一个environment.resolvePlaceholders ,它是 Spring 框架中 Environment 接口的一个方法,它用于解析字符串中的占位符。当你在配置文件或代码中使用 ${...} 形式的占位符时,resolvePlaceholders 方法会被调用来替换这些占位符为实际的属性值。

我们根据该方法生成的字符串,来进行本地ReentrantLock本地锁的获取(localLock)。获取本地锁是在本地缓存Caffeine(也是一个Map)中获取的,若获取本地锁为空,我们就执行同步代码块方法,去进行双重判定(重新获取本地锁),还是为空就创建一个ReentrantLock实例(开启为true,公平锁),将这个锁实例放在本地缓存map中。

之后将本地锁加入了集合中,根据之前将的获取锁流程,我们还需要创建一个分布式锁实例(分布式锁的公平锁创建,因为下订单的先后顺序是需要保证的),根据订单的请求的座位类型数量决定分布式锁的数量。此处购买的票种包含的座位类型为2,所以本地锁和分布式锁(两个锁都是公平锁)都是为2。

购票逻辑启动!
上锁

经过本地锁和分布式锁的实例获取之后(注意此时还没有执行锁的lock操作的噢),终于确定开始下单了。

1、将上面逻辑获取到的本地锁集合和分布式锁集合全部执行lock操作进行上锁(先本地锁再分布式锁)。之后返回购票执行的结果。

可以看到这里9002线程上了锁,刚好是我们Ticket模块对应的接口。

真正的购票逻辑

2、此处执行购票逻辑executePurchaseTickets,我们看到这里作者有说一句:修复事务可见性导致座位超卖的问题。

我们可以看到购买方法使用了Spring的@Transactional(rollbackFor = Throwable.class)注解,说明购票流程的任何一步失败,都是需要回滚购票操作的。

购票返回的结果是什么样子的呢?肯定是要包含当前订单的相关信息和支付状态。所以我们需要对用户购买的每一个车票返回一个车票车票订单返回详情参数(List)。

首先在购买之前,重新加载一遍Redis缓存的数据,这个数据是我们具体车次Id为1的车辆实体,如果获取trainDO失败,就使用我们提供的Mybatis-Plus的语句查询数据库回写车次数据到缓存中(时间还是为15天)。

选择器!(亮点)select TrainSeatTypeSelector

获取到车次实体之后,我们创建车票购买结果(列车购票出参)集合,这里使用了一个购票时列车座位选择器 ,可以看到这里选择器使用了一个select方法,我们进去看看。

这个选择器主要是为了使用completableFuture并行缓存查询座位站点余票,根据座位类型来划分任务,每一个CompletableFuture来处理一个座位类型的查询任务。把结果封装在futureResults中

首先获取该订单所有的乘车人集合,之后按照各个乘车人对应的座位类型进行分组,获得一个seatTypeMap。之后创建一个List(上锁的版本),Collections.synchronizedList() 方法是 Java 标准库中的一个方法,它属于 java.util.Collections 类。这个方法的作用是将一个普通的 List 包装成一个线程安全的 List。虽然线程安全,但这种同步方式可能会导致性能下降,特别是在高并发环境下。因为它使用的是粗粒度锁定,即整个列表被一个锁保护。这里的列表也会有线程安全的问题。所以针对一个订单对应一个返回出参的原则,设置线程安全的列表。

之后观察我们的乘车人只有一个的话(座位类型只有一个这么解释也可以),我们就创建一个任务集合,类型为List<Future<List>>,可以看到我们这里即将使用CompletableFuture异步任务的编排方式处理每一个订单。

这里怎么实现呢?我们使用一个线程池来异步执行任务。使用submit方法去执行线程池任务是会返回一个Future结果的(结果类型为List),一般来说Callable接口实现的任务会有结果,但是Runnable接口创建的任务会获取为null,要注意。

对于每一个Map的节点,我们会使用hippo4j的动态线程池来实现任务的执行,调用distributeSeats(trainType, seatType, requestParam, passengerSeatDetails)方法。

distributeSeats方法

这里我们会获取火车的类型和座位的类型去创建一个字符串,并创建一个座位类型选择实体。

将此实体放入到我们的策略模式的选择上。

这里内置了5种策略,方法选择chooseAndExecute。

首先根据标识获取一个具体的策略。此处的策略为HIGH_SPEED_RAINBUSINESS_CLASS(是特快线还是啥)。。。

获取到了策略的实例,执行策略实例的executeResp方法返回结果。

这里的座位是先选择座位,因为就算知道座位类型,但是没有分配具体的座位也是不可以的。故执行分配座位的逻辑。

计算票点的余量,余量的数据计算结果,余票数量足够购买请求需要的座位的话就可以执行后续逻辑。

这里在某一座位类型达到3个人数及以上的时候,会触发selectComplexSeats方法,否则触发selectSeats方法。这是因为之前我们是有一个座位分配的具体策略的,具体在如下链接ja4ja1i9vjt.feishu.cn/wiki/UpXqwy…

selectSeats少于3人流程

  • 如果购票人数为两人,购买同一车厢,座位优先检索两人相邻座位并排分配。
  • 假设当前正在检索的车厢不满足两人并排,就执行搜索全部满足两人并排的车厢。
  • 如果搜索了所有车厢还是没有两人并排做的座位,那么执行同车厢不相邻座位。
  • 如果所有车厢都是仅有一个座位,就开始执行最后降级操作,不同车厢分配。

此处设置了3个map(2HashMap,1LinkedListHashMap)

首先针对票的数量,去遍历对应的车厢(01、16都是符合的),之后通过listAvailableSeat查看该车厢是否符合有相邻的位置,其实是找该车厢所有的空座位。

执行完任务后获得的Future结构加入到FutureResult集合中。

之后定义一个actualSeats来记录我们的一个车厢(一个二维数组),商务座的车厢比较小,进行for循环遍历。根据我们的一个座位号转换器,将刚刚01车厢的5个座位号数据放入我们的一个二维数组中,这样子不为0的座位为合法的座位(这里是不用特判座位号对应的座位是否有乘客的情况,因为在查车厢的时候查到的是没有乘客的座位)。

这个座位号转换器的逻辑如下:根据类型转换,num1获取到的是A

之后在我们的list座位表集合中查找是否存在次座位表,比如根据座位表类型转换得到01A,那我们会使用contains(01A)方法查找是否存在该座位,有为0无为1;

知道了座位在二维数组的分布,我们就可以执行座位选择的逻辑了。(SeatSelection)

首先遍历座位分布二维数组,如果当前元素为0,则当前位置有座位。开始遍历一整行(一整行是因为要给乘车人分配相邻的座位)。如果当前的座位连续,consecutiveSeats就加1,否则遇到了1,座位就不连续了,consecutiveSeats重新设置为0。

如果连续的位置等于我们要分配的一个座位数,我们就将当前选择的座位倒序遍历,得到一个选择座位集合(元素为二维数组)。

如果我们找到了一个合适的分配座位的方案,就退出当前循环。此次获取到的方案为二维数组的(1,0)和(1,1);之后把集合继续转化为二维数组,这样子就获得了座位分配的信息。

之后将对应的车厢01,和车厢01对应的选择方案存储在carriagesNumberSeatsMap中。

其他降级的方案后续继续补充。

之后我们要对01车厢的方案进行计数。使用stream流的flatMap方式进行多维度的映射,再计数,因为我们的座位数量为2,所以计数结果count为2

这个flatMap在这里怎么用呢?首先将carriagesNumberSeatsMap.values(),获得一个二维数组[[2,1],[2,2]],之后对这一个元素转化为流,通过flatMap对每一个二维数组中的元素转化为流进行计数,数量为2。.flatMap(Arrays::stream)flatMap 方法用于将流中的每个元素(在这个场景中是座位数数组或列表)转换为一个新的流,并将这些流合并成一个单一的流。这里使用 Arrays::stream 方法引用,将每个座位数数组或列表转换为流。.count():计算合并后的流中的元素总数,即所有车厢的座位数之和。(int):将 count() 方法返回的 long 类型结果强制转换为 int 类型。

之后我们针对获得的座位集合,将其转为原来业务上的车次座位类型。[2,1][2,2]对应于为"02A","02C"。

对这两个座位,每一个设置一个购票返回结果。并封装结果,实现一座位对应一个乘车人ID,最后遍历完返回逻辑结果。0号座位类型有两张票。

最终我们回到了策略执行的executeResp方法了,这个方法在获取到我们刚刚一乘车人ID一座位号的返回结果之后,就去查请求提供的路线,并针对该车次的所有关联的线路执行中间状态的座位库存扣减(Redis缓存的increment扣减,此例子中关联扣减9条路线(10-终点站那一条线)的座位库存。0号类型的座位扣减2个)。

注意这里使用的是分布式缓存的distributedCache.getInstance();

之后我们那个策略的RESPONSE,实际上返回的是我们一乘客ID一座位号的车票购买结果出参。这个就是我们最后定时任务线程池的执行结果。

多个并行流执行完了后续的结果。就返回了一乘车人一个座位号的结果。

这里我们将结果收集起来,使用stream流进行收集(收集条件为getPassengerId),我们的一个List就被转化为了List。

用户服务远程调用查询乘车人方法

这一步就是根据乘车人 ID 集合查询乘车人列表,这里查询的是乘车人的一些用户信息

  • value = "index12306-user${unique-name:}-service":这部分定义了被调用服务的名称。这里使用了 ${unique-name:} 属性占位符,它可能是用来动态替换为一个唯一的名称或环境特定的值。如果没有提供默认值,Spring Cloud 会尝试查找名为 index12306-user-service 的服务。
  • url = "${aggregation.remote-url:}":这部分定义了远程服务的基础 URL。这里使用了 ${aggregation.remote-url:} 属性占位符,它将从配置文件中获取 aggregation.remote-url 的值作为服务的 URL。如果没有提供默认值,那么这个 URL 必须在配置文件中明确指定。

远程调用的参数为url=http://index12306-user-curtardly0924-service,是我们之前定义的用户名。

这时候,我们要将之前的actualResult里面封装passenger对应信息的数据。继续使用stream流来过滤出actualResult和passenger集合相同的乘车人ID,如果存在passenger,就将passenger的信息存入actualResult中。

之后注入了乘车人的信息后,顺便去创建一个单人的一个座位类型票价QueryWrapper查询包装器,查询出对应的票价结果。

之后我们更新座位类型的状态,使用LockSeat方法,创建一个updateWrapper来修改对应座位的状态为LOCKED(1 座位上锁状态)。票是有三个状态的0、1、2,0是可出售,1是已锁定,2是已出售。

构建车票实体并远程调用订单创建接口

我们将用户的信息进行封装和座位的状态修改之后,我们就可以构建对应的车票实体了。将我们的车票集合通过stream流转化为List。

这里使用了一个Mybatis-plus的saveBatch函数,将车票的顺序新增转为了批量新增。saveBatch的参数是list集合类型,意味着多条数据可以一次性插入。当你需要批量插入或更新大量数据时,使用 saveBatch 可以提高性能,因为它减少了数据库的 I/O 操作次数。saveBatch 方法默认每次批量操作的条目数为 1000,如果超过这个数量,它会分批执行。你可以通过设置 mybatis-plus.global-config.db-config.batch-size 来修改这个值。

savebatch执行之后,订单的信息就被写入了数据库中。我们就对trainPurchaseTicketResults的每个元素创建一个orderItemCreateRemoteReqDTO和ticketOrderDetailRespDTO

最后存在它俩对应的集合中。

下图表示的是小订单

下图表示的是小订单的执行结果

之后获取我们购票订单当前对应的车次路线

下图是大订单的创建结果

可以看到这里包含了之前创建小订单的集合。之后开始执行创建订单方法,创建订单的执行方法是在order-service模块里的,故我们的ticket模块调用了order模块的方法

这里涉及到order方法的调用,具体看如下链接:车票订单创建接口createTicketOrder

释放本地锁和分布式锁

在finally块中,先对分布式锁解锁,再对本地锁解锁。

这里有一个细节,这些锁锁的资源是什么?

可以看到,上锁的key为方法名、请求提供的车次和座位类型。比如一个用户需要的资源为车次1_座位类型0,那么其他的用户就无法获取该车次1对应类型的座位0的资源。但是其他用户访问的不是该车次或者是该车次的其他座位类型的时候是不冲突的。例如访问车次1_座位类型2和车次2_座位类型0的时候是不冲突的。

所以购买中锁的粒度是针对车次座位类型的粒度,而不是车次的粒度。

6、接下来会触发幂等注解,这里是为了检查用户是否重复下单。

joinPoint参数为V2的购买方法

6.1、获取方法中幂等注解的信息,执行幂等注解的执行结果。这里的幂等判断使用SPEL表达式

可以看到这里是针对此方法,特意设置了对应的幂等信息处理,此处因为是用户操作前端的界面,为RESTAPI幂等场景。消息为触发了幂等异常逻辑后的发送消息。

6.2 根据幂等处理器(SPEL)在幂等工厂里获取对应的SPEL的实例(实际是从Spring上下文容器里面获取的),这里SPEL的实例对于

此处通过wrapper包装器,封装了joinPoint切入点,

SPEL内部使用了Redisson的分布式锁,来执行上锁操作,并将当前的锁标识和锁实例存入幂等组件的上下文。这个操作是在执行joinPoint.Proceed()实例前执行的,所以前面存锁标识的操作是在上锁流程里的,后置处理操作就是在joinPoint.Proceed()之后释放幂等组件的锁。最后幂等操作执行完毕,要清理幂等容器上下文。

支付接口getPayInfo

这里是使用了payservice模块的getPayInfo接口,使用订单号作为参数。具体查看如下链接:www.yuque.com/curtardly/o…

取消车票订单/api/ticket-service/ticket/cancel

这里是会调用对应order模块的取消订单接口,链接如下:www.yuque.com/curtardly/o…

首先调用cancelOrderResult的结果,因为是远程调用的方式,所以有可能会出现远程服务调用失败或者超时的情况。所以我们的返回结果如果是success,就进行特判。

首先执行远程调用order模块的订单号查询订单方法进行查询,获取查询结果的数据。

之后通过查询订单的具体乘车人小订单,因为这个查出的是大订单,真正的小订单集合在大订单中。之后对于所有此订单的关联线路的座位执行解锁操作,即将座位的状态从1改为0。

之后将令牌回滚,也把缓存中的关联线路的座位余量给回滚回去。

公共退款接口commonTicketRefund

把令牌座位余量回滚之后,取消了订单自然就要把付款人的金额退还回去。首先我们要执行对请求数据的过滤(TRAIN_REFUND_TICKET_FILTER),我们去看看这个退款责任链。

这个退款责任链是对应的结果,退款是分为部分退款和全额退款的,我们就去查找对应的结果检查是否非空即可。

首先我们去查找对应的订单查询结果,如果查询订单不成功,就说明车票订单不存在,就抛出异常。

之后继续调用订单的接口,使用大订单的接口去getData查询子订单,子订单是为了应对子订单的情况。

之后我们就对应全额退款和部分退款做出分类讨论。

部分退款的时候,创建一个小订单细项的车票查询实体,这个对应一个大订单,将其存入大订单订单号和小订单集合。

之后这个实体要用于调用ticketOrderRemoteService的远程方法来通过票的细项来进行查询。对于每一个旅客,使用流方法去查询每一个数据,并获取响应数据,看查询出来的订单是否有对应的乘车人数据,有的就过滤出来做集合。将这些订单设置为部分退款,因为刚刚的订单有可能过滤出来无效的小订单。

如果说一开始就是全额退款,那我们直接就塞数据进入实体,因为不用去找哪些订单是部分退款。直接设置即可。

之后退款就是计算流式数据的总和,这个就是我们退款的金额了,要设置一个退款逻辑的详情实体,然后调用公共退款接口,传入退款的详情,执行成功即可

公共退款接口使用的是PayService方式。

RegionStationController

查询车站&城市站点集合信息listRegionStation

首先还是从缓存中获取集合信息,查询StationDO的一条集合,如果没有就写入地区DO,这里采用了模糊匹配的方式右模糊匹配站点名称和首字母名称。

之后我们会再写一个查询,根据查询的方式来进行分类,查询方式分为6类。

第0类,对于热门标识进行查询

第1到4类,根据地区首字母进行查询

查询车站站点集合信息listAllStation

每一个车站都有自己的站点信息,那么所有站点只需要在分布式缓存中预先存储获取即可。不然就执行cacheLoader,查询所有车次集合即可。

MQ

消费者DelayCloseOrderConsumer

此处执行订单10分钟未付款逻辑,之前在order订单下单,并发送MQ延时消息。在消费的过程中也需要对MQ的消费实现幂等操作。

在实现消费之前,我们要创建一个消费者所关注的事件,并在我们的自定义包装器中包装此次事件。

延迟关闭订单事件肯定要包括所要关闭的订单实体,为了方便查询,我们还要封装对应的起点和终点。

而消息包装器是我们的一条消息,属性包括消息key、消息体、唯一标识、消息发送时间。

消费者就负责监听某一主题中发送的某一tag的消息,对应的cg(消费者组)的消息。MQ要专门的幂等消费,类型为SPEL表达式幂等。

订单数据删除

这里的订单关闭服务是在order模块中的,具体看closeTickOrder服务。

关闭订单的服务执行后,如果执行服务的结果不成功(没有成功取消超时订单)。就去获关闭订单的data,data不为空就说明用户已经支付了订单。否则就去获取我们的一个关闭订单的详细信息,比如订单对应的起点站终点站车次ID和车次的票集合。

回滚座位状态

我们首先将这些车次对应的座位解锁,即这些座位的status是被设置为1(已锁定),这些座位会影响所有的关联路线的座位购买情况,所以我们要查出这个起点和终点站所有的关联线路,并将关联线路对应的座位号状态设置为0(待出售状态)。

更新缓存和缓存中的令牌数量

上面更新完数据库之后,再更新缓存,最后是缓存中的令牌

这个订单的结果是大订单的结果(set每个旅客订单结果的集合)。

首先获取缓存实例,然后根据用户提供的乘车人对应的座位类型的数据,回滚缓存中关联路线(remaining_ticket)的余票库存数据。并将对应的令牌数量回滚。

令牌回滚的执行逻辑为rollbackInBucket,首先从单例容器中获得对应的lua脚本,之后封装为Redis的默认脚本。

之后我们获取对应的座位类型的数量Map,对于每一个Map的数据转换为JSON数组作为脚本的参数。

之后获取分布式缓存实例,创建令牌key和起点_终点key,获取起点终点的关联站点集合,执行RedisTemplate.execute脚本,参数为 stringRedisTemplate.execute(actual, Lists.newArrayList(actualHashKey, luaScriptKey), JSON.toJSONString(seatTypeCountArray), JSON.toJSONString(takeoutRouteDTOList));

返回令牌执行结果。

ticket中的两个lua文件,全是和令牌相关的,一是扣减令牌,二是回滚令牌。

自增令牌的逻辑

第一次查询的是当前的令牌表,key为起点站_终点站_座位类型,这个查询主要是看令牌的数量是否小于座位类型的实际数量,是的话就标记为当前座位类型的令牌失效,需要标记。令牌数量某一处不够了,就要回退了,触发更新令牌操作。

第二次查询使用的是查出来的关联路线集合,拼接和上文一样的key,将我们的令牌进行扣减(和座位类型的订单数量绑定)。之后返回结果。

Redis lua脚本令牌回滚