一文解析 火车中转方案怎么实现?Java开发——火车中转方案(阅读携程官方技术文章,自己进行实现,并有所不同)

614 阅读13分钟

参考原型:mp.weixin.qq.com/s/T9Dy8mLVV…

但是有自己的思路、实现、更改、测试。

1. 热门中转城市

12306中实际上可以明显察觉到,搜索中转方案是比搜索直达车票慢的,因为中转城市会比较多,会拼接出很多中转方案,再通过一些策略筛选(余票数量优先、最近时间优先),此时会导致执行时间偏久。

但是一些点进行中转是比较不合理的,比如北京到深圳的中转方案中,如果把哈尔滨中转点也算进去了的话,路线会很绕,因此这种中转方案是比较冗余的!

因此,
1)一个优化就是给两个车站之间中转指定一些热门中转城市,这些是交通很便捷的站点。 具体就是实现车站到所在城市、城市到所在省份的映射、省份+省份映射到一些热门的中转城市编码、热门中转城市映射当前的车站编码。
2)另一个优化就是,由于中国的省份有限而且这些数据的体量也不大,可以存入本地缓存中,本地缓存比redis大约还要快上10倍。

当然,作为弥补一些用户可能看不到的中转方案,可以让用户可以指定中转站、起点、终点进行查询,实际发现,12306也是这么做的。

7AD1D0F9B9FCC2FBE782047190C98827.png

2. 数据存储分布

81CA67964A1A445A41EA180FA2DCBC16.png

3. 获取路线信息

本地缓存最热的100个路线信息

为什么本地缓存使用Caffeine?
原因:一是 Caffeine与其他很多本地缓存相比性能比较好。避免了 统计和过期清理操作 与get和put同步进行,由此大大提升了执行效率。只需要将写入任务顺序写入任务队列即可,化随机写为顺序写,由异步线程完成任务。
二是,Caffeine的内存淘汰策略使用的是W-TLFU, 命中率比较高。能适应突发的访问的同时,也能较好的避免缓存污染等问题。

解释一下W-TLFU

yuque_diagram.png

分为三个区域: 窗口区使用LRU内存淘汰策略,能适应突发的流量,当窗口区中数据满时则淘汰末尾数据,比较访问频次来判断末尾数据是否能进入评估区。
评估区和保护区都使用LFU, 之所以分为两个是为了防止短时间内的大量访问造成的缓存污染问题,不让短时间内大量访问的数据放在保护区中,而是放在评估区中,避免它立即去淘汰保护区的数据而造成污染。

实现流程

yuque_diagram.png

代码实现

private final Cache<String, Object> routesMap = Caffeine.newBuilder()
        .maximumSize(100)
        .build();
ArrayList<Object> routesAll = new ArrayList<>();
List<Object> routes = stringRedisTemplate.executePipelined((RedisCallback<String>) connection -> {
    for(int i = 0; i < midLists.size(); i++){
        if(midLists.get(i).equals(finalFrom.toString()) || midLists.get(i).equals(finalTo.toString()))continue;
        String key1 = REGION_TRAIN_STATION + finalFrom + "_" + midLists.get(i) + "_ZSET_Arrival_TIME_SORT";
        String key2 = REGION_TRAIN_STATION + midLists.get(i) + "_" + finalTo + "_ZSET_DEPATURE_TIME_SORT";
        Object result1 = routesMap.getIfPresent(key1);
        Object result2 = routesMap.getIfPresent(key2);
        if(result1 != null){
            routesAll.add(result1);
            keysAll.add(key1);
        } else{
            keys.add(key1);
            connection.hashCommands().hGetAll(key1.getBytes());
        }
        if(result2 != null){
            routesAll.add(result2);
            keysAll.add(key2);
        } else{
            keys.add(key2);
            connection.hashCommands().hGetAll(key2.getBytes());
        }
    }
    return null;
});
for(int i = 0; i < keysAll.size(); i++){
    routesMap.put(keysAll.get(i), (HashMap) routesAll.get(i));
}
//log.info(keys.size() + "  " + routes.size());
//log.info("中转结果{}", routes.toString());
Map<String, HashMap<String, String>> map = new HashMap<>();
for(int i = 0; i < keys.size(); i++){
    map.put(keys.get(i), (HashMap) routes.get(i));
}

4. ForkJoinPool 工作窃取线程池进行方案拼接与筛选

如图所示深圳到北京的中转方案(地理不一定标准啦,每个球上下左右四个位置代表某地的四个站点,实际上表示了某地站点到某地站点的路线)。
实际上中转方案拼接时,不同中转点生成的中转方案是相互独立的,是计算密集型任务,很适合使用ForkJoinPool, 因为工作窃取的工作模式可以充分利用CPU资源。

yuque_diagram.png

ForkJoinPool原理:

  • ForkJoinPool 这是线程池的核心类 (核心参数 并行度、核心线程数0、最大线程数、 异常处理handler、 线程工厂、过期时间等等)
  • ForkJoinPool.WorkQueue 这是ForkJoinPool类的内部类
  • ForkJoinWorkerThread 线程池中运行的thread是作者定义的。
  • ForkJoinTask 这是ForkJoinPool支持运行的task抽象类。

• execute():可提交Runnbale类型的任务
• submit():可提交Callable类型的任务\ (future.get()可以try-catch处理异常) • invoke():可提交ForkJoinTask类型的任务,但ForkJoinTask存在三个子类: (也可以try-catch处理异常)
-• ①RecursiveAction:无返回值型ForkJoinTask任务
-• ②RecursiveTask:有返回值型ForkJoinTask任务
-• ③CountedCompleter:任务执行完成后可以触发钩子回调函数的任务

ctl是控制线程状态的关键long字段:
• 1-16bit/AC:记录池中的活跃线程数
• 17-32bit/TC:记录池中总线程数
• 33-48bit/SS:记录WorkQueue状态,第一位表示active还是inactive,其余15位表示版本号(避免ABA问题)
• 49-64bit/ID:记录第一个WorkQueue在数组中的下标。

WorkQueue的字段:scanstate字段: WorkQueue状态 + WorkQueue在数组中的下标
stackPred字段:记录前一个阻塞的scanstate. 线程池从 ctl 的 ID 字段获取栈顶 scanState,并通过 stackPred 逐层回溯,按后进先出(LIFO)顺序唤醒线程。

以invoke外部提交为例,需要等待线程池执行完任务返回结果,使用wait阻塞用户线程直到执行完提交的任务后唤醒。外部任务提交后,会signalwork()尝试唤醒一个阻塞栈中的线程, CAS ctl 和 unpark栈顶线程。

workqueues 初始容量为 大于等于并行度 x 2 的 2的整数次幂。 workqueue 存在一个较大的 2的整数次幂 初始容量, 空间不足时每次扩容2倍,有最大容量上限。 CAS的方式扩容时将对应位置设置为null。

1)线程:继承于Thread, 实现ForkJoinPool的线程,适应于当前的task。

2)任务队列:有一个workqueues数组,奇数索引位置和偶数索引位置包含不同的队列。
线程池为每个线程绑定一个workQueue(奇数位置), 可以供其他线程窃取;默认参数下,绑定线程读取任务LIFO + CAS, 窃取任务队列FIFO + CAS。外部任务提交的subminssionQueue(偶数位置)共享队列 所有线程都是FIFO, 通过CAS的方式获取。
工作队列中,通过CAS解决并发冲突的问题,由于绑定线程和窃取线程获取任务的位置不同、绑定线程都有自己绑定的任务队列,这可以大大的降低了并发冲突。

3)线程工作方式:某个线程当自己的工作队列没有任务时,会去SCAN其他队列中是否需要帮忙,如果遇到竞争,会再次随机生成一个数组坐标,再次CAS的FIFO获取任务,可线性移动。
若是遍历一遍没有找到可以获取的任务,将自己操作ctl 字段将活跃线程数 - 1, scanstate设置为灭活状态 设置为ctl低32位,设置前一个ctl低32位设置为 stackpred 形成阻塞链;当有新任务提交或者下一次遍历拿到任务时,可以恢复状态。否则进入park的阻塞状态! 当线程数大于1时,阻塞等待一段时间后,会对线程进行回收。

当小于 最大并行度时,会再次重新创建新线程。
另外,当某个线程CAS发现队列中任务数>1时,也唤醒阻塞链中的线程。

为什么工作队列线程先进后出,窃取工作的线程先进先出?

——> 降低并发冲突

——> 任务是可分割的,那队列中较旧的任务最有可能粒度较大,因为它们可能还没有被分割,而空闲的线程则相对更有“精力”来完成这些粒度较大的任务**。能更加充分的利用空闲线程。 (工作窃取线程获取时使用CAS的方式获取任务)

——>最近提交的任务(队列头部)通常是当前线程刚生成的子任务,其所需的数据可能仍保留在CPU缓存中。优先处理这些任务能最大化利用时间局部性,减少缓存未命中(Cache Miss)的开销。

4)内部队列的提交,fork操作将拆分的子任务放进当前线程对应的workqueue中。task.join()时会CAS检查当前workqueue中自己要等待的子任务,去执行子任务。如果发现为null说明任务已经被窃取线程窃取,可以去帮助窃取线程执行本地任务, helperstole如果仍然join, 会继续检查,直到这个链中的join或任务执行完成(helpstoler的链断掉)。当helpstoler执行结束时,若仍然需要等待子任务时,此时线程会进入阻塞状态,阻塞前会进行补偿操作补偿线程,此时可能导致线程池的线程数超过最大并行度!

forkjoinpool实践

1.不要在任务中调用invoke方法提交子任务。 使用task.fork()可以把任务交到workqueue专属队列中。

2.大任务拆分时,不要将所有任务全拆到子任务中,可以给自己留一些执行,避免join时切换任务去执行的开销。

3.forkjoinpool 通过managedblocker可以支持阻塞型任务,线程阻塞时创建新的线程。

拼接过程中使用的轻量级对象

只对最终的方案才返回全量的对象,全量包含余票、价格、行程路线、列车完整信息等,提升性能。

/**
* 轻量的中转方案
*/
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MidWayLightInfo {
    // 前一程列车id
    private String pre;
    // 前一程到达时间
    private Long arrival1;
    // 后一程离站时间
    private Long depart2;
    // 后一程列车id
    private String next;
    // 出发城市
    private String from;
    // 中转城市
    private String mid;
    // 到达城市
    private String to;
}

5. 筛选合适的列车路线

筛选出所有满足时间间隔要求的路线

筛选出所有满足时间间隔要求的路线,因为要留给乘车人比较充分的中转时间。

可以使用数据结构skipList来帮忙更快的进行筛选, 使用多层链表实现:(key:列车id, score:发车时间)

举例深圳 -> 西安 -> 北京:

第二程西安 -> 北京,所有方案基于发车时间构建跳表。遍历深圳 -> 西安的路线方案,通过这个第一程的到达时间筛选出第二程中时间间隔大于30分钟的方案,进行合并。

yuque_diagram.png

为什么使用跳表不用红黑树?

1)跳表使用随机化层数的思想构建,模拟了二叉搜索树的查找时间复杂度。
2)跳表相对于二叉搜索树来说,构造起来更容易,不必担心由于避免退化而进行平衡的调整操作。
3)跳表最后一层的双向指针方便范围遍历,适合当前场景;且相对于红黑树等 所需的指针少,空间占用更少。

所有方案汇总时,筛选出比较条件中最优越的前k个

比较器有两个比较值。先比较发车时间,发车时间早的排前面;在发车时间相同的基础上,再比较余票数量,余票数量多的排前面。使用堆排序,Java中的优先级队列。

完全二叉树:除了最后一层,其余每一层的节点数必须达到最大,最后一层的叶子节点都靠左排列。

完全二叉树最适合用数组存储,因为它的节点都是紧凑的,只有最后一层节点数不满。可以方便的直接根据父节点和子节点的index索引对应关系去用数组构建。实际Java中的优先级队列,也是数组Object[] 存储。

堆:堆也是一颗完全二叉树,但是它的元素节点值必须满足每个节点的值都不大于或都不小于其父节点的值。

包装出完整方案数据

最终,筛选出k个最好的方案(对应k个轻量级对象),使用Redis的pipeline批处理操作获取全量的列车数据、列车行驶路线数据、价格数据等。(当然,价格数据也可以提前全量获取作为一个筛选条件)

6.中转方案缓存

短时间内有大量访问时,会更快的响应,要使用redis将中转方案缓存。
注意点:
1)为了减轻Redis的内存压力,缓存的应该是轻量级的中转方案数据。
2)过期时间的选择。中转方案难以长时间缓存的一大阻碍是列车可能没有余票,本方案测试通过canal监听Mysql 通过RocketMQ异步更新redis余票,测试25个列车扣减到没票所需时间:非批量消息的canal更新所需时间是15分钟以上,产生非常大量的消息约有6万条。
使用批量消息更新则可以大大的节约时间,大约6分钟可以将余票扣减完成。

因此过期时间选择5 min比较合理,用户中转方案查询时命中redis缓存,此时再去筛选出有票的方案 + 包装完整方案。当所有方案都没票时,可以触发重新生成中转方案!
另外,如果考虑非高峰时间段,可能有的列车余票变化的并不剧烈,可以设置长时间的过期时间 30min,避免redis频繁删除数据。当所有方案都没票时,再触发重新生成中转方案的代码!

3)分布式锁优化,避免大量相同中转方案计算任务同时进行,给工作窃取线程池造成压力。采用分布式锁的方式保证只是一个线程会去执行中转方案的计算!

//有中转缓存,则走中转的缓存
String s = stringRedisTemplate.opsForValue().get(REGION_TRAIN_STATION + "MID" + "_" + from + "_" + to);
if(s != null && !s.isEmpty()){
    return loadMidCache(s);
}

//分布式锁:保证并发时 相同中转方案,同一时间只有一个线程去计算
RLock queryMidKey = redissonClient.getLock(String.format(MID_WAY_LOCK, from, to));
boolean get;
try {
    get = queryMidKey.tryLock(2, TimeUnit.MINUTES);
    if(!get){
        throw new ServiceException("请求超时");
    }
} catch (InterruptedException e) {
    throw new ServiceException("请求失败,稍后重试");
}

try{
    //再次检查缓存
    s = stringRedisTemplate.opsForValue().get(REGION_TRAIN_STATION + "MID" + "_" + from + "_" + to);
    if(s != null && !s.isEmpty()){
        return loadMidCache(s);
    }
 catch(Exception e){
 }finally{
    queryMidKey.unlock
 }

7.最终测试(没存,后续补测试结果图)

JMETER压力测试,我考虑了一下可以评估最坏和最好时的QPS,那么我们性能的范围就可以确定了
预计每个中转计算有 25x24个方案 -> 25个优秀方案

最坏

全部都由ForkJoinPool计算中转方案,不走缓存
JMETER 100个线程共并发测试10000次下,QPS达到260/sec

最好

可以全部都去命中缓存,预计性能瓶颈在redis JMETER 100个线程共并发测试10000次下,QPS达到400/sec

所以,性能范围为 260/sec ~ 400/sec.

8.拓展

获取中转一次方案的同时,想再获取一些中转两次的方案,如何实现?(看携程提供的功能有感)