作者简介
简言,携程后端开发经理 ,关注技术架构、性能优化、交通规划等领域。
由于交通规划和运力资源的限制,用户查询的两地之间可能没有直达交通,或者在重大节假日时,直达交通都已售罄。不过,通过火车、飞机、汽车、船舶等两程或多程中转的方式,用户仍然可以到达目的地。此外,中转交通有时在价格和耗时方面更具有优势。例如,对于从上海到运城,通过火车中转可能比直达火车更加快捷和便宜。
图1 携程火车中转交通列表
在提供中转交通方案时,很重要的一个环节是将两程或多程的火车、飞机、汽车、船舶等拼接起来组成可行的中转方案。而中转交通拼接的第一个难点是拼接空间极大,仅考虑上海做中转城市,就可以产生近亿种组合;另一个难点在于对实时性有要求,因为产线数据随时变化,需要不断地查询火车、飞机、汽车、船舶的数据。中转交通拼接需要大量的计算资源和IO开销,因此,对其性能进行优化显得尤为重要。
本文将结合实例,介绍在中转交通拼接性能优化过程中所遵循的原则、分析和优化方法,旨在为读者提供有价值的参考和启示。
性能优化需要在满足业务需求的前提下,在各种资源和约束条件下去平衡和取舍,遵循一些大的原则有助于消除不确定性,去逼近解决问题的最优解。具体来说,中转交通拼接优化过程中主要遵循以下三个原则:
虽然本文是关于性能优化的,但仍需要在最开始强调:不要为了优化而优化。满足业务需求的方式有很多,性能优化只是其中一种。有时候问题非常复杂,限制也很多,在不显著影响用户体验的前提下,通过放宽限制或采用其他流程来减少对用户的影响,这也是解决性能问题的好方法。在软件开发中,存在许多通过牺牲少量性能来实现大幅降低成本的事例。例如,在Redis中用于基数统计(去重)的HyperLogLog算法,它在标准误差为0.81%的前提下,只需要12K空间就能够统计264的数据。
回到问题本身,由于需要频繁地查询产线数据,并且进行海量的拼接操作,那么如果要求每个用户查询时都立刻返回最新鲜的中转方案,成本将会非常高。为了降低成本,需要在响应时间和数据新鲜度之间进行平衡。经过仔细考虑选择可以接受分钟级的数据不一致,对于一些冷门线路和日期,可能在首次查询时没有好的中转方案,此时引导用户重新刷新页面即可。
Donald Knuth在《Structured Programming With Go To Statements》中提到:“程序员们浪费大量的时间去思考、担忧非关键路径的性能,而尝试优化这部分性能,对整体代码的调试和维护都有非常严重的负面影响,因此97%的情况,我们应该忘记小的优化点”。简而言之,在没有发现真正的性能问题之前,在代码层面过度炫技式的优化,不仅不会提高性能,反而可能会导致更多的错误。然而作者同样也强调:“对于剩下关键的3%,我们也不要错过优化的机会”。因此,需要时刻关注性能问题,不做会影响性能的决策,并在必要的时候做正确的优化。
正如前一节所述,在进行优化之前,首先要量化性能并找出瓶颈,这样优化的才更有针对性。量化分析性能可以借助耗时监控、Profiler性能分析工具、Benchmark基准测试工具等,重点关注耗时特别长或者执行频率特别高的地方。正如阿姆达尔定律所述:“系统中对某一部件采用更快执行方式所能获得的系统性能改进程度,取决于这种执行方式被使用的频率,或所占总执行时间的比例”。
此外,还需要注意到性能优化是一场持久战。随着业务的不断发展,架构和代码也不停地变化,因此更需要持续量化性能,不断分析瓶颈和评估优化效果。
在性能分析之前,首先要梳理业务流程。中转交通方案拼接主要包含以下四个步骤:
a. 加载线路图,如北京经南京中转到上海,只考虑线路本身的信息,与具体的班次无关;
b. 查火车、飞机、汽车、船舶的产线数据,包括出发时间、到达时间、出发站、到达站、价格和余票信息等;
c. 拼接出所有可行的中转交通方案,主要是考虑换乘时间不能过短,以免无法完成换乘;同时也不宜过长,以免等待太久。拼接出可行的方案后,还需要完善业务字段,例如总价格、总耗时和换乘信息等;
d. 根据一定的规则,从拼接出的所有可行中转方案中筛选出一些用户可能感兴趣的方案。
(1)增加耗时监控
耗时监控是一种最直观的从宏观角度观察各个阶段耗时情况的手段。它不仅可以查看业务流程各阶段的耗时值与耗时占比,还可以长期观察耗时变化趋势。
耗时监控可以借助公司内部的指标监控告警系统,在中转交通方案拼接的主要流程中增加耗时打点。这些流程包括加载线路图、查询班次数据并进行拼接、筛选和保存拼接方案等。各个阶段的耗时情况如图2所示,可以看到,拼接(含查产线数据)的耗时占比最高,因此成为未来重点优化的目标。
图2 中转交通拼接耗时监控
(2)Profiler性能分析
耗时打点可能会侵入业务代码,并对性能产生影响,因此不宜过多,更适合监控主要流程。与之对应的Profiler性能分析工具(例如Async-profiler),可以生成更具体的调用树以及各函数的CPU占用比例,从而帮助关键路径和性能瓶颈的分析与定位。
图3 拼接调用树与CPU占比
如图3所示,拼接方案(combineTransferLines)占53.80%,查产线数据(querySegmentCacheable,已使用缓存)占21.45%。在拼接方案中, 计算方案评分(computeTripScore,占48.22%)、创建方案实体(buildTripBO,占4.61%)和检查拼接可行性(checkCombineMatchCondition,占0.91%)是占比最大的三个环节。
图4 方案打分调用树和CPU占比
继续分析占比最高的计算方案评分(computeTripScore)时,发现主要与自定义的字符串格式化函数(StringUtils.format)有关,包括直接调用(用于展示方案评分细节),以及通过getTripId间接调用(用于生成方案的ID)。自定义的StringUtils.format中占比最高的是java/lang/String.replace,Java 8原生的字符串替换是通过正则实现的,效率比较低(这一问题在Java9之后已经改进了)。
// 计算方案评分(computeTripScore) 中调用的StringUtils.format代码示例 StringUtils.format("AAAA-{0},BBBB-{1},CCCC-{2},DDDD-{3},EEEE-{4},FFFF-{5},GGGG-{6},HHHH-{7},IIII-{8},JJJJ-{9}", aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh, iiii, jjjj) // getTripId 中调用StringUtils.format代码示例 StringUtils.format("{0}_{1}_{2}_{3}_{4}_{5}_{6}", aaaa, bbbb, cccc, dddd, eeee, ffff) // 通过Java replace实现的自定义format函数 public static String format(String template, Object... parameters) { for (int i = 0; i < parameters.length; i++) { template = template.replace("{" + i + "}", parameters[i] + ""); } return template; }
(3)Benchmark基准测试
借助Benchmark基准测试工具可以更准确地测量代码的执行时间。在表1中,我们通过JMH(Java Microbenchmark Harness)对三种字符串格式化方法和一种字符串拼接方法进行耗时测试。测试结果表明,使用Java8的replace方法实现的字符串格式化性能最差,而使用Apache的字符串拼接函数性能最佳。
表1 字符串格式化与拼接性能对比
实现 | 执行1000次平均耗时(us) |
使用Java8的replace实现的StringUtils.format | 1988.982 |
使用Apache replace实现的StringUtils.format | 656.537 |
Java8自带String.format | 1417.474 |
Apache的StringUtils.join | 116.812 |
通过以上的性能分析,我们发现拼接和查询产线数据是性能瓶颈,字符串格式化影响尤其大。因此,我们将致力于优化这些部分,以提高性能表现。
优化代码逻辑是最简单且性价比最高的方法,可以是修正有问题的代码或替换为更好的实现。不同的实现,哪怕减上几纳秒,累加起来也是很可观的。借助一些经典算法或数据结构(如快速排序、红黑树等)可以在时间和空间复杂度方面带来显著优势。回到中转交通方案拼接性能优化本身,优化的代码逻辑主要包括:
(1)优化字符串拼接性能
如前面的JMH的结果所示,自定义的字符串格式化函数性能最差,因此作为重点优化目标。优化前后的对比如下所示:
// 优化前,通过Java replace实现的format函数 public static String format(String template, Object... parameters) { for (int i = 0; i < parameters.length; i++) { template = template.replace("{" + i + "}", parameters[i] + ""); } return template; } // 优化后,通过Apache replace实现的format函数 public static String format(String template, Object... parameters) { for (int i = 0; i < parameters.length; i++) { String temp = new StringBuilder().append('{').append(i).append('}').toString(); template = org.apache.commons.lang3.StringUtils.replace(template, temp, String.valueOf(parameters[i])); } return template; }
根据JMH的测试结果,即使是优化后的格式化函数,其性能也不是最优的。在不显著影响可读性的前提下,应尽量使用性能更优的StringUtils.join函数。
// 优化前 StringUtils.format("{0}_{1}_{2}_{3}_{4}_{5}_{6}", aaaa, bbbb, cccc, dddd, eeee, ffff) // 优化后 StringUtils.join("_", aaaa, bbbb, cccc, dddd, eeee, ffff)
为进一步提升性能,可以在computeTripScore 函数中添加一个开关,仅在调试模式下才展示评分细节,这将确保该字符串格式化函数仅在需要时才被调用。
if (Config.getBoolean("enable.score.detail", false)) { scoreDetail = StringUtils.format("AAAA-{0},BBBB-{1},CCCC-{2},DDDD-{3},EEEE-{4},FFFF-{5},GGGG-{6},HHHH-{7},IIII-{8},JJJJ-{9}", aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh, iiii, jjjj); }
优化后的CPU占比如图5所示,此时字符串格式化已经不再是性能瓶颈。
图5 优化后的拼接调用树和CPU占比
(2)增加索引降低拼接时间复杂度
图6 增加索引降低拼接时间复杂度
在中转拼接过程中,我们需要将第一程每个班次的到达时间与第二程每个班次的出发时间进行比较,以判断中转时间是否过短或过长。为简化说明,假设换乘时间间隔需要满足大于30分钟且小于6小时。以北京到上海经南京中转的两程火车为例,3月9日北京到南京有66个班次,南京到上海有275个班次,考虑到隔夜车,还需要算上3月10日南京到上海的275个班次,那么最多需要比较36300(66*275*2)次。
为避免频繁比较,参考了MySQL B+树索引的思想,将第二程南京到上海的所有火车班次数据构建成红黑树。其中,树的键为秒级时间戳,例如2023-03-09 11:29出发的G367键为1677247680,值为G367的班次数据。有了索引树,最多只需要10次比较,就可以找到最近的满足最小换乘时间要求的班次。同理,最多需要10次比较,就能找到满足最大换乘时间要求的最晚班次。两者之间的所有班次都满足耗时要求,直接进行拼接即可。改进后最多需要比较1320(66*(10+10))次,约为原来的1/27.5。
(3)使用多路归并求Top-K算法
在筛选方案时,会存在以下场景:有多个中转点,每个中转点都有数百个得分较高的方案(内部已按得分由高到低排序,通过小根堆实现)。最终需要将这些方案合并,并从中筛选出得分最高的K个方案。
最简单的方法是使用快速排序将所有的方案排序,然后选取前K个,时间复杂度约为O(nlog2n)。然而,这并没有利用到每个队列自身有序的特点。通过多路归并算法时间复杂度可降为O(nlog2k),具体步骤为:
a. 从每个队列中拿出第一个元素(得分最高的方案),放入大根堆中;
b. 从大根堆堆顶拿出最大的元素,放到结果集中;
c. 如果该元素所在的队列还有剩余元素,则将下一个元素加入堆中;
d. 重复步骤2和3,直到结果集中包含K个元素或所有的队列都为空。
图7 多路归并求Top-K算法
缓存是一种典型的以空间换时间策略,可以缓存数据和计算结果,缓存数据可以提高访问效率,缓存结果避免了重复计算。缓存在带来性能提升的同时,又会引入新的问题:
在中转交通方案拼接过程中,需要使用大量的基础数据(如车站、行政区域等),以及海量的动态数据(例如班次数据)。综合以上因素并结合中转交通拼接的业务特点,缓存架构做如下设计:
图8 多级缓存结构
尽管理论上可以选择任意城市作为两地的中转点,但实际上大部分中转城市都无法拼接出优质的方案。因此,先通过离线预处理筛选出部分高质量的中转点,从而将求解空间从几千降至数十。相对于动态变化的班次,线路数据是相对固定的,每天计算一次即可。此外离线预处理可以借助大数据技术,处理海量数据,相对对耗时不敏感。
在一次拼接过程中,需要处理数十条不同中转点的线路。每个线路的拼接是相互独立的,因此可以采用多线程处理,这样可以最大程度地降低处理时间。但受线路班次数量和缓存命中率的影响,不同线路的拼接耗时很难一致。很多时候,分配相同任务数量的两个线程,即使一个线程很快执行完,也要等待另外一个线程执行完才能进行下一步操作。为避免这种情况,这里借助ForkJoinPool的work-stealing机制。这个机制可以确保每个线程在完自己的任务后,还会分担其他线程未完成的工作,提高并发效率,减少空闲时间。
但是多线程也不是万能的,使用时需要注意:
通过将计算推迟到必要的时刻,可能避免很多多余的开销。例如,在拼接完中转方案后,需要构建方案实体并完善业务字段,这部分也比较消耗资源。而且并非所有拼接的方案都会被筛选出来,这意味着这部分未被筛选的方案仍然需要耗费计算资源。因此延迟完整方案实体对象的构建,先将拼接过程中的数以万计的方案保存为轻量的中间对象,只对筛选之后的数百个中间对象构建完整的方案实体。
中转交通拼接项目是基于Java 8的,并使用G1(Garbage-First)垃圾收集器,部署在8C8G机器上。G1在实现高吞吐量的同时尽可能满足停顿时间的要求,系统架构部门设置的默认参数已经能够适用于大多数场景,通常不需要专门的优化。
但有些线路中转方案过多,导致报文太大,超过Region大小的一半(8G 默认Region大小是2M),导致很多应该进入年轻代的大对象直接进入了老年代,为了避免这种情况,将Region大小改为16M。
通过以上的分析和优化,拼接耗时变化如图9所示:
图9 中转交通方案拼接性能优化效果
虽然每个业务和场景都有各自的特点,性能优化时也需要具体分析。但原理是相通的,依然可以参考本文所述的分析和优化方法。本文所有的分析和优化方法总结如图10所示。
图10 中转交通方案拼接优化总结
以上是优化携程中转交通方案的拼接性能的详细内容。更多信息请关注PHP中文网其他相关文章!