灵目平台-货拉拉地图领域流量回放体系的场景化探索

1,984 阅读13分钟

作者简介:

徐勋雄,来自货拉拉/技术中心/质量保障部,资深测试工程师,主要负责地图服务端测试及相关质效能力建设

一、背景与挑战

随着货拉拉业务持续快速的发展,地图作为货运领域的一个基础能力,在整个履约过程中承担着重要的角色。它不仅仅是一个简单的导航工具,更是连接货主和司机,实现货物高效、安全运输的关键桥梁。如何确保货主和司机位置的准确性和实时性、路线规划的合理性、预计到达时间的准确性以及封闭道路/限行道路规避的及时性,便成为了地图后端测试亟待解决的难题。面对这些困难,我们从整体测试体系出发,思考现有测试方案的局限性,主要有以下几点:

  • 物理世界实时信息难获取

    • 规划路线是否可通行,“世上本无路,走的人多了便有了路”,言外之意路线需要真实走过后才能拿到是否可通行、是否好走的测试结论;
    • 规划路线是否有影响通行的信息,如下图,国内两家较出名图商的路线,一个没有道路施工,一个有道路施工,真实情况如何,测试无从而知;

    • 预计到达时间是否准确,影响用户从A点到B点的时间因素有很多,如路况、红绿灯、高速、封路,甚至和司机的驾驶习惯均有关系,这些因素的不确定性和实时变化,使得测试工作变得异常复杂和困难。
  • 体验类效果测试尚未涉及

    • 产品需求迭代,最终效果需按照预期进行提升。但路线、诱导的好坏偏主观,满足大多数人对导航的预期在早期测试阶段并没有比较好的手段来介入;
    • 静态信息、动态信息更新,产品效果不能有回退。但地图信息更新频繁、量级巨大,是否有降低效果的极端badcase出现对测试也是一种挑战。

二、方案与目标

在设计之初,我们进行了深入的研究,探索了业界常见的流量录制和回放方案,如下图所示:

工具对比.png

然而,当这些方案遇到货拉拉地图特定的应用场景时,挑战接踵而至,比如不支持C++服务、不支持gRPC通信协议等。面对这些挑战,我们深入分析了LLRepeater的《货拉拉流量回放体系搭建与应用》方案,并从中获得了宝贵的启示。这些启示引领我们探索一种全新的、针对货拉拉地图场景化的流量录制和回放平台,这个平台的设计需要满足以下三个关键目标:

  • 完成流量录制回放能力搭建:在流量录制环节对服务零侵入的前提下,支持C++服务、支持gRPC通信协议,满足线上流量录制线下单集群多节点、多集群单节点等多形式的回放模式,定制化支持地图特有的结果diff能力;
  • 提升地图全景能力测试覆盖:基于录制回放能力,拿线上流量扩充测试集并做为测试真值,使得每次产品需求迭代,都能确保线上效果不回退;
  • 提升地图服务问题分析效率:每一个diff都可能会涉及到路线不同、路线相同但时间/距离不同,如何快速准确的分析潜在的问题,就需要我们将整个分析过程线上化、可视化。

为了实现上述设定的目标,我们设计并打造了地图灵目平台,该平台不仅优雅地解决了地图服务在特定通信协议下流量录制回放的痛点,还在测试覆盖度提升和复杂问题的高效分析上表现出色。接下来,我们将详细阐述“灵目”平台的核心架构设计,并展示其在货拉拉地图领域的应用效果。

三、灵目能力建设

3.1 灵目架构设计

whiteboard_exported_image (8).png

如上图所示,灵目平台架构由展示层、逻辑层、存储层和数据层组成,这些模块协同工作来捕获、管理和模拟用户产生的线上流量。这种精确的流量管理有助于我们深入挖掘和发现潜在的需求问题,从而显著提升产品的准出质量。此外,灵目平台还提供了开放的API接口,支持流量查询和回放触发等功能。这种开放性使得我们的平台能够广泛应用于自动化测试、性能测试等场景,为开发和测试团队提供了强大的工具。

接下来我们将按照流量录制、回放调度、结果diff、问题分析这四大模块进行详细介绍。

3.2 灵目核心功能实现

3.2.1 流量录制

在构建一套流量录制回放平台时,首要任务是考虑如何进行流量采集。在这个过程中,我们需要重点考虑两个方面:

  • 流量采集过程对业务代码零侵入,同时尽可能减少对服务器资源的占用;
  • 考虑到路况、封开等因素的影响,必须进行准实时流量回放。

目前,我们可以通过动态代码增强或日志离线解析的方式进行流量采集,但这些方法都无法很好地满足地图流量采集的需求。因此,我们需要寻找新的解决方案,该方案采用流量异步上报的方式,通过离线存储服务将报文写入Kafka,然后各个服务通过离线存储的接口将数据异步上报。这种方式实现了流量上报与流量写入的解耦,同时也可以通过配置实现降级、限流等处理。具体如下图所示:

whiteboard_exported_image (18).png

3.2.2 回放调度

基于货拉拉地图服务的gRPC通信协议,需要构建一种通信方式,可以满足gRPC请求的回放。gRPC没有像HTTP一样的通用客户端(OkHttp、HttpClient),属于强类型协议,需要在本地预编译生成存根类,通过存根类进行通信。

whiteboard_exported_image (19).png

在构建灵目平台时,我们发现采用预编译生成存根类进行远程通信的方式存在两个主要问题:

  • 高维护成本

    • 构建通用平台可能导致大量存根类代码,增加维护复杂性
    • 服务接入或协议更新时,需要重新编译Proto文件和重启服务,进一步增加维护成本
  • 无法感知协议更新

    • 协议更新后,本地协议版本未能及时更新,将导致大量流量回放失败

经过研究,我们采用了一种新的方案:动态编译Proto协议文件,生成动态gRPC客户端。这种方案能自动感知协议更新,无需重启服务,提高了系统的灵活性和可用性。

whiteboard_exported_image (20).png

/**
 * Proto协议文件更新时,触发下载更新,并重新编译
 */
@Override
    public ResponseEntity<String> handleWebhook(Event event) {
        if (event instanceof PushEvent) {
            PushEvent pushEvent = (PushEvent) event;
            Long projectId = pushEvent.getProject().getId();
            String branch = pushEvent.getBranch();
            if (branch.contains("release")||branch.contains("master")) {
                updateProtoToOss(projectId, "/",  branch,  true);
            }
            // 清理编译缓存,使Proto文件更新后重新编译
            serviceResolverMap.clear();
            methodsMap.clear();
            feishuMsgService.sendProtoUpdateMsg(modifiedFile);
            return ResponseEntity.ok("Release branch updated, download completed.");
        }
        return ResponseEntity.ok("Not a release branch update event.");
    }
}
/**
  * 缓存中获取ServiceResolver,如无,则重新编译生成
  */
public static ServiceResolver getServiceResolver(
        String protoFile, String libFolder, boolean reload) {
    ServiceResolver serviceResolver;
    try {
        String serviceResolverKey = protoFile + libFolder;
        if (!reload) {
            serviceResolver = serviceResolverMap.get(serviceResolverKey);
            //双重检查锁定模式
            if (serviceResolver == null) {
                synchronized (lock) {
                  serviceResolver = 
                    serviceResolverMap.get(serviceResolverKey);
                    if (serviceResolver == null && 
                    StringUtils.isNotBlank(protoFile)) {
                        final DescriptorProtos.FileDescriptorSet 
                        fileDescriptorSet;
                        ProtocInvoker invoker = 
                        ProtocInvoker.forConfig(protoFile, libFolder);
                        //执行编译
                        fileDescriptorSet = invoker.invoke();
                        serviceResolver =
                        ServiceResolver.fromFileDescriptorSet(
                        fileDescriptorSet);
                        serviceResolverMap.put(serviceResolverKey,
                        serviceResolver);
                        return serviceResolver;
                    }
                }
            }
            return serviceResolver;
        }
    } catch (Exception e) {
        throw new RuntimeException("Unable to resolve service by invoking 
        protoc" + e.getMessage(), e);
    }
    throw new RuntimeException(
            "Unable to resolve service by invoking protoc. The proto folder 
            path is empty");
}

解决gRPC请求的回放问题之后,我们就要考虑使用哪些回放策略进行流量调度,我们目前主要提供四种回放策略,如下图所示:

whiteboard_exported_image (21).png

下面以批量回放和静默回放为例,图示整体回放流程,如下图所示:

whiteboard_exported_image (22).png 可以从下面的动图中更加直观的看到灵目平台流量录制、回放调度等相关能力的前端展示效果:

3.2.3 结果diff

在地图领域,我们面对的是静态信息和动态信息共同影响后的效果,以路线规划为例,最终呈现给用户的路线会受到实时交通信息的影响导致即使是相同起终点,其返回的路线也会有较大的差异。

  • 交限、封闭/开通等信息的动态变化下,路线结果存在不确定性
  • 不同时间请求算路,路线的预测到达距离和时间会有可容忍的偏差;
  • 不同序列化方式带来的数字类型精度差异

whiteboard_exported_image (23).png 如果不处理上述噪点,直接进行结果差异比较,会出现较多非预期内的失败。因此平台采用了多级降噪手段,以确保最大程度地去除噪点,并筛选出真正存在问题的回放记录。

whiteboard_exported_image (24).png

/** 
 * 加载降噪配置,执行diff流程
 */
public void performReplayDiffAssessment(Replay replay, Boolean denoise) {
    // Initialize configurations
    DiffConfig diffConfig = loadDiffConfig(replay);
    // Deserialize JSON responses
    Object actualObj = deserializeJson(replay.getReply());
    Object expectObj = deserializeJson(replay.getResponse());
    if (actualObj == null || expectObj == null) {
        return; // If deserialization fails, exit the method
    }
    // Pre-process paths if necessary
    if (denoise) {
        preProcessPaths(actualObj, expectObj);
    }
    // Perform comparison
    Comparable comparable = ComparableFactory.instance().createDefault();
    CompareResult result = comparable.compare(actualObj, expectObj);
    if (denoise) {
        coverLinkIdToSwId(replay, result);
    }
    // Apply noise reduction rules
    applyNoiseReductionRules(replay, result, diffConfig, denoise);
    // Set the final status and diff result
    setFinalDiffResult(replay, result, diffConfig);
}

下图详细展示了灵目平台目前具备的降噪能力以及实际使用效果:

3.2.4 问题分析

流量回放失败案例的人工分析面临挑战,如长链路、高沟通成本、多系统和重复工作等,导致分析效率低下。为提高效率,构建智能化问题分析工具是关键。智能问题分析的架构设计主要包括以下几层:

  • 数据源层:提供问题分析基础,包含数据库和中间结果数据;
  • 回放信息封装层:包含关键回放信息,如起终点、请求ID等,助力理解回放场景和问题分析;
  • 分析API层:基于底层数据和回放信息,提供各服务自动化分析API,实现人工干预最小化。

通过构建智能问题分析能力,我们能够实现一键式问题分析。如下图所示,一次回放任务产生四条diff,通过平台我们可以快速的得出结论:测试环境首条路线非最优。

3.3 灵目面临的挑战及解决方案

3.3.1 海量流量选取

在进行流量查询时,我们发现由于Elasticsearch存在深分页问题,导致最大查询数量被限制在10000条。然而,地 图业务的流量选取通常非常庞大,远超这个限制,因此我们需要找到一种方法来突破这个限制。

/**
 * Index setting describing the maximum value of from + size on a query.
 * The Default maximum value of from + size on a query is 10,000. This was chosen as
 * a conservative default as it is sure to not cause trouble. Users can
 * certainly profile their cluster and decide to set it to 100,000
 * safely. 1,000,000 is probably way to high for any cluster to set
 * safely.
 */
public static final Setting<Integer> MAX_RESULT_WINDOW_SETTING =
 Setting.intSetting("index.max_result_window", 10000, 1, Property.Dynamic, 
 Property.IndexScope);
                

为了解决该问题,我们的流量筛选实现了迭代器接口,并采用了滚动查询(Scrolling)的方式。滚动查询是Elasticsearch提供的一种机制,可以用于检索大量数据,而不需要一次性将所有数据加载到内存中。通过滚动查询,我们可以分批获取流量数据,每次只获取一部分数据,从而避免了深分页问题,最终实现了大规模流量数据的查询。

/**
 * 迭代器+滚动查询实现流量检索
 */
public class ElasticsearchIterator implements Iterator<List<Record>> {
    public ElasticsearchIterator(SearchRequest searchRequest, int maxResults) throws Exception {
        // 构建搜索请求
        searchRequest.scroll(new Scroll(TimeValue.timeValueHours(3)));
        SearchResponse searchResponse;
        this.recordRepository = (ElasticsearchTemplate<Record, String>)  
        SpringContextUtil.getBean("recordElasticsearchTemplate");
        // 执行搜索请求
        searchResponse = recordRepository.search(searchRequest);
        this.hits =Arrays.asList(searchResponse.getHits().getHits()).stream()
        .map(r -> JSON.parseObject(r.getSourceAsString(), 
        Record.class)).collect(Collectors.toList());
        this.maxResults = maxResults;
        this.scrollId = searchResponse.getScrollId();
    }

    @Override
    public boolean hasNext() {
        if (!isFirstTime) {
            // 获取下一批结果
            ScrollResponse<Record> recordScrollResponse;
            try {
                recordScrollResponse =
                recordRepository.queryScroll(Record.class, 1, scrollId);
                this.hits = recordScrollResponse.getList();
                this.scrollId = recordScrollResponse.getScrollId();
            } catch (Exception e) {
                this.hits = new ArrayList();
            }
        }

        if (this.maxResults > 0) {
            boolean finish = this.hits.size() > 0 && alreadyReturnRecord.get() 
            < this.maxResults;
            return finish;
        } else {
            return this.hits.size() > 0;
        }
    }

    @Override
    public List<Record> next() {
        this.isFirstTime = false;
        int hitsSize = this.hits.size();
        int numHitsToReturn = hitsSize;
        if (this.maxResults > 0) {
            numHitsToReturn = Math.min(numHitsToReturn, this.maxResults - 
            alreadyReturnRecord.get());
        }
        List hitsToReturn = new ArrayList<>(numHitsToReturn);
        for (int i = 0; i < numHitsToReturn; i++) {
            if (this.maxResults > 0 && alreadyReturnRecord.get() >= 
            this.maxResults) {
                break;
            }
            hitsToReturn.add(this.hits.get(i));
            alreadyReturnRecord.incrementAndGet();
        }
        this.hits.clear();
        return hitsToReturn;
    }
}

3.3.2 环境噪音

我们的回放模式是通过在测试环境中回放线上流量,然后比较线上响应与测试环境响应的差异,主要用于效果类的测试。然而,尽管我们已经尽力使线上与测试环境的外部依赖保持一致,但由于环境差异,仍然存在一些无法消除的噪音。因此,在需求增量测试阶段使用该平台会有一些限制。为了解决该问题,我们引入了指定节点回放的方案,以下是普通回放模式与指定节点回放模式的差异:

screenshot-20240527-224230.png

同样一份线上流量,针对两种不同的回放模式,可以从下图中看出其工作流程: whiteboard_exported_image (25).png

四、实践应用

灵目平台能力建设完成之后,我们首先在货拉拉导航领域进行了推广试用。在试用过程中,我们积极响应业务需求,对提出的优化建议进行了快速迭代和改进。经过近一年的投入和使用,平台已经取得了显著的成效。

4.1 研发效能提升

  • 研发流程优化

    • 灵目赋能研发自测,使用线上真实流量打通研发开发机环境,快速支持研发本地自测;
    • 增量测试环节,将线上流量分别打到不同集群,对比新老版本代码的差异,进而挖掘潜在的功能和效果问题。
  • 测试全面性提升,借助平台能力,测试可以发现潜在的模型、策略、数据等问题,很好的提升了测试覆盖度

  • 问题分析效率提升,基于问题智能分析能力,目前平台的分析时长由之前的 2人天/单diff,提升至 分钟级/单diff,分析效率提升 99%

whiteboard_exported_image (26).png

4.2 产品体验改善

  • 用户满意度提升,通过平台提供的能力,我们将更加优质的服务提供给广大司机群体,帮助司机们快速、精准的到达目的地
改善前改善后
改善前.gif改善后.gif
  • 极端差badcase快速发现,借助线上流量与平台的问题分析能力,我们可以在产品上线前发现一些极端badcase,比如封闭道路未按规定时间解封导致司机绕路、孤立路网导致较优路线不可通行等。

五、灵目未来规划

随着灵目多方用户的深度使用,我们也开始展望未来,思考如何将灵目推向更高的层次:

  • 能力拓展:需要具备多节点回放、智能数据分析以及轻导航核心模块问题分析等能力,赋能更多用户;
  • 业务赋能:平台在货拉拉国内货运领域带来了不小的收益,未来需要支持货拉拉国际化领域的不同业务场景;
  • 能力协同:结合公司现有平台能力(CI/CD、自动化、性能平台),最大化平台收益。