10年Java工程师:如何开发控制3500台机器人的系统

本文要点

  • Ocado技术使用Java成功地开发了需要高性能的应用程序。
  • 离散事件模拟的使用使开发团队可以在长时间周期之上分析性能,而不需要等待结果。
  • 确定性软件对于有效的调试必不可少。本质上,实时系统是不确定的,因此Ocado技术一直在努力协调这种不一致。
  • 洋葱架构强调应用程序中关注点的分离。使用这种架构,Ocado技术可以相对轻松地持续调整和变更其应用程序。

在Ocado Technology,我们使用最先进的机器人来支撑我们高度自动化的配送中心。在我们位于最大的在线杂货自动化仓库Erith的网站上,我们最终将招募3500多台机器人,每周处理22万份订单。如果你还没有看过我们在运转中的机器人,可以在我们的YouTube频道看一下。

我们的机器人以每秒4米的速度移动,彼此之间的距离不超过5米!为了协调我们的机器人群,并最大限度地提高仓库的效率,我们开发了一个控制系统,它类似于空中交通控制系统。

我们将介绍在开始开发任何应用程序时需要做出的三个典型决策,并解释我们为控制系统做出的语言、开发原则和架构选择。

语言的选择

并不是每个人都可以仅仅根据编程语言的技术优点和对特定问题的适用性来选择他们要使用的编程语言。微服务和容器化的一个好处经常被提及,那就是能够采用一种多语言的开发环境,但在许多组织中,还必须考虑其他因素,比如:

  • 现有经验及专长
  • 招聘的考虑
  • 工具链的支持
  • 企业战略

在Ocado技术,我们大量投资于Java——我们的控制系统是用Java开发的。我们经常听到(也经常问自己)的一个常见问题是,为什么我们使用的是Java,而不是c++之类的语言,或者最近出现的Rust。答案是——我们不仅在优化我们的控制系统,还在优化我们开发人员的生产力,这种权衡不断地将我们引向使用Java。我们选择使用Java是因为它的性能、开发速度、发展平台和人才招聘。让我们依次看看这些因素。

性能

有些人认为Java比用C或c++编写的类似程序“慢”,但这实际上是一个谬论。比如,有一些Java编写的高性能应用程序早已众所周知,它们证明了用Java可以实现什么,比如 LMAX Disruptor。在比较语言时,还需要考虑应用程序性能的许多因素,例如,可执行文件大小、启动时间、内存占用和原始运行时速度。此外,在本质上很难比较特定应用程序在两种语言之间的性能,除非您能够用两种语言编写这款应用程序。

虽然在Java中开发高性能应用程序时可以遵循许多推荐的软件实践,但是在JVM中,与其他语言相比即时(JIT)编译器可能是提高应用程序性能的最重要的概念。通过分析正在运行的字节码并在运行时将适当的字节码编译为本机代码,Java应用程序的性能可以非常接近本机应用程序的性能。此外,当JIT编译器在最后可能的时刻运行时,它拥有AOT编译器无法获得的可用信息,主要包括应用程序运行时所使用的确切芯片组和关于实际应用程序的统计信息。有了这些信息,JIT编译器可以执行AOT编译器无法保证安全的优化,所以在某些情况下,JIT编译器的表现实际上可以比AOT编译器更好。

开发速度

许多因素使得用Java比其他语言的开发速度更快:

因为Java是一种类型化的高级语言,所以开发人员可以专注于业务问题并尽早捕获错误。

现代IDE为开发人员首次编写正确的代码提供了丰富的工具。

Java有一个成熟的生态系统,几乎所有东西都有库和框架。在中间件技术中,对Java的支持几乎无处不在。

平台的发展

Java架构师Mark Reinhold指出,20年来,JVM开发的两个最大驱动因素是开发人员生产力和应用程序性能的改进。因此,随着时间的推移,我们已经能够从我们的前两个关注点(性能和开发速度)中获益,这仅仅是因为我们处在一个不断发展和改进的语言和平台上。例如,在Java 8和Java 11之间观察到的性能改进之一是G1垃圾收集器的性能,它允许我们的控制系统有更多的应用程序时间来执行计算密集型的计算。

人才招聘

最后,对于一家成长中的公司来说,能够轻松地招募到开发人员至关重要。在包括 TiobeGitHub、 StackOverflow 和 ITJobsWatch在内的所有流行语言的排名中,Java总是名列前茅。这个职位意味着我们拥有一个非常庞大的全球开发人员库,可以从中招募到最优秀的人才。

开发原则

在选定语言之后,我们在系统中做出的第二个关键决策是作为一个团队开发应用程序所采用的开发原则或实践。这里讨论的决策类似于Jeff Bezos让亚马逊面向内部服务的著名决策。与是否使用结对编程之类的决策不同,这些决策不易更改。

在Ocado Technology,我们应用三个主要原则来开发我们的控制系统:

  • 广泛的模拟测试和研究
  • 确保我们所有的代码都可以在研发期间确定性地运行,并且相一代码也可以在实时上下文中运行
  • 避免过早优化

模拟

维基百科上关于模拟的文章是这样描述的:

仿真是对过程或系统运行的近似模拟;模拟首先需要建立一个模型。

在一个机器人仓库的上下文中,我们可以模拟许多流程和系统,例如自动化硬件、执行业务流程的仓库操作员,甚至其他软件系统。

我们的仓库模拟这些方面有两个主要好处:

  • 我们越来越相信,新的仓库设计将提供我们所设计的吞吐量。
  • 我们能够在软件中测试和验证算法变更,而不需要在物理硬件上进行测试。

为了在上面的两个模拟场景中获得有意义的结果,我们通常需要模拟运行许多天或几周的仓库操作。我们可以选择实时运行我们的系统,并等待数天或数周,直到我们的模拟完成,但这样做非常低效,我们使用离散事件模拟(DES)的形式可以做得更好。

DES的工作原理是假设系统的状态只在处理事件时发生变化。在此假设下,DES可以维护要处理的事件列表,并且在处理事件的时候,能够适时跳转到下一个事件的时间。正是这种“时间旅行”使得DES在大多数情况下比同等的实时代码运行得快得多。这种为开发人员和仓库设计团队提供的快速反馈提高了我们的生产率。

值得明确说明的是,为了能够使用离散事件模拟,我们必须将控制系统设计为基于事件的,并确保不会随着时间的推移发生状态更改。这个架构需求引出了我们使用的下一个开发原则——确定性。

确定性

实时系统本质上是非确定性的。除非您的系统使用的是实时操作系统,它提供了严格的调度保证,否则很大一部分不确定性行为可能源于操作系统,即不可控的事件调度,以及不可预测的事件观察处理时间。

确定性在控制系统的研发过程中非常重要,尤其是在仿真过程中。没有确定性的话,如果发生了不确定的错误,开发人员常常不得不凭借日志的搜寻再加上临时测试来重现错误,而无法保证能够重现错误。这会消耗开发人员的时间和积极性。

由于实时系统永远不会是确定性的,所以我们的挑战是开发出的软件既能在DES期间确定性地运行,又能在实时情况下非确定性地运行。我们通过使用我们自己的抽象——时间和调度来实现这一点。

下面的代码片段显示了我们对时间的抽象,引入时间抽象是为了控制时间的流逝:

@FunctionalInterface
public interface TimeProvider {
    long getTime();
}

利用这个抽象,我们可以提供一个实现,让我们在离散事件模拟中“时间旅行”:

public class AdjustableTimeProvider implements TimeProvider { private long currentTime;

public class AdjustableTimeProvider implements TimeProvider {
    private long currentTime;

    @Override
    public long getTime() {
        return this.currentTime;
    }
    
    public void setTime(long time) {
        this.currentTime = time;
    }
}

在我们的实时生产环境中,我们可以用一个依赖于获取时间的标准系统调用来替换这个实现:

public class SystemTimeProvider implements TimeProvider {
    @Override
    public long getTime() {
        return System.currentTimeMillis();
    }
}

为了调度,我们还引入了自己的抽象和实现,而不是依赖于Java中的 Executor 或ExecutorService 接口。我们这样做是因为Java执行器接口没有提供我们需要的确定性保证。我们将在本文后面探讨原因:

public interface Event {
    void run();
    void cancel();
    long getTime();
}

public interface EventQueue {
    Event getNextEvent();
}

public interface EventScheduler {
    Event doNow(Runnable r);
    Event doAt(long time, Runnable r);
}

public abstract class DiscreteEventScheduler implements EventScheduler {
    private final AdjustableTimeProvider timeProvider;
    private final EventQueue queue;

    public DiscreteEventScheduler(AdjustableTimeProvider timeProvider, EventQueue queue) {
        this.timeProvider = timeProvider;
        this.queue = queue;
    }

    private void executeEvents() {
        Event nextEvent = queue.getNextEvent();
        while (nextEvent != null) {
            timeProvider.setTime(nextEvent.getTime());
            nextEvent.run();
            nextEvent = queue.getNextEvent();
        }
    }
}

public abstract class RealTimeEventScheduler implements EventScheduler {
    private final TimeProvider timeProvider = new AdjustableTimeProvider();
    private final EventQueue queue;

    public RealTimeEventScheduler(EventQueue queue) {
        this.queue = queue;
    }

    private void executeEvents() {
        Event nextEvent = queue.getNextEvent();
        while (true) {
            if (nextEvent.getTime() <= timeProvider.getTime()) {
                nextEvent.run();
                nextEvent = queue.getNextEvent();
            }
        }
    }
}

在我们的DiscreteEventScheduler 中,您可以观察timeProvider.setTime(nextEvent.getTime())这一行,它表示上面提到过的时间旅行。

我们的RealTimeEventScheduler是一个无限循环的例子。通常不建议使用这种技术,因为它将CPU时间浪费在无用的事件上。那么,为什么要在控制系统中使用无限循环调度程序呢?我们接下来将对此进行探讨。

优化

每个软件开发人员肯定都熟悉Donald Knuth的名言:

“过早优化乃万恶之源。”

但是,有多少人知道这句话的全文:

“有97%是我们应该忽略的细微优化:过早的优化乃万恶之源。然而,我们不应该放过那3%的关键机会。”

在我们的仓库控制系统中,我们追求的是那3%的机会,让我们的系统尽可能地将性能提升到极致!前面的无限循环调度程序就是其中一个机会。

由于我们系统的软实时特性,我们对事件调度程序有以下要求:

  • 事件需要安排在特定的时间。
  • 个体事件不能被任意推迟。
  • 系统不能允许事件任意备份。

最初,我们选择基于ScheduledThreadPoolExecutor 实现最简单、最惯用的Java解决方案。这个解决方案本质上满足第一个需求。为了确定它是否满足我们的第二个和第三个需求,我们使用我们的模拟能力对此解决方案的性能进行彻底地测试。我们的模拟允许我们在许多天内以满仓容量运行控制系统,以测试应用程序行为——通常在任何仓库实际满仓之前的运行还不错。该测试显示,基于ScheduledThreadPoolExecutor 的解决方案无法支持必需的仓库卷。为了理解为什么这个解决方案还不够,我们转而分析我们的控制系统,它突出了两个要重点关注的地方:

  • 事件被安排的时刻
  • 事件准备执行的时刻

从事件被调度的时间开始,ThreadPoolExecutor 的JavaDoc列出了三种排队策略:

  • 直接传递
  • 无界队列
  • 有界的队列

查看ScheduledThreadPoolExecutor 的JavaDoc内部结构,可以看到正在使用一个自定义的无界队列,从ThreadPoolExecutor  的JavaDoc可以看到:

尽管这种类型的排队在消除瞬时请求暴增方面很有用,但它同时也承认,当命令平均到达速度超过处理速度时,任务队列可能会无限增长。

这告诉我们,我们的第三个需求可能无法满足,因为事件会被备份到无界任务队列中。

我们再次转回这份JavaDoc,以了解线程池在准备执行新事件时的行为。根据您的线程池配置,可能会为将要执行的事件创建一个新线程。以下同样来自ThreadPoolExecutor 的JavaDoc:

如果运行的线程小于corePoolSize,则创建一个新线程来处理请求,即使其他工作线程处于空闲状态。否则,如果运行的线程小于maximumPoolSize,则只会在队列已满时创建一个新线程来处理请求。

线程创建需要时间,这意味着我们的第二个需求也可能无法满足。

对应用程序中可能出现的错误进行理论化是很好的,但是在对其进行彻底地测试之前,您无法知道所选择的解决方案是否具有足够的性能。通过重新运行相同的模拟测试,我们可以观察到一个无限循环为我们提供了对个体事件更低的延迟:从< 5ms到实际上的0ms,它提升了高出3倍的事件吞吐量,而且它符合事件安排的所有三点需求。

架构

我们的最终决定是架构,对不同的人来说它意味着不同的东西。 对一些人来说,架构指的是实现的选择,例如:

  • 整体大系统还是微服务
  • ACID事务还是最终一致性(或者更简单地说,SQL 还是NoSQL)
  • 事件溯源还是CQRS
  • REST 还是GraphQL

在应用程序生命周期开始时所做的实现决策,通常在当时是有效的。但是,随着应用程序的成长,功能的增加和复杂性不可避免地增加,必须一次又一次地重新考虑这些决策。

对其他人来说,架构关心的是如何构造代码和应用程序。 如果您承认这些实现决策将会变更,那么一个好的架构将确保这些变更能够尽可能容易地进行。我们实现这一点的一种方法是遵循洋葱架构,它强调应用程序中关注点的分离。

开发原则常常影响您选择的架构。我们的开发原则在很多方面指导了我们的架构:

  • 离散事件仿真要求我们实现一个基于事件的系统。
  • 强制执行确定性导致我们实现自己的抽象,而不是依赖于标准的Java抽象。
  • 通过避免过早的优化并简单地启动,我们的应用程序作为一个单一的、可部署的工件启动。许多年过去了,应用程序已经成长为一个整体系统,它仍然很好地为我们服务。我们不断评估“现在”是不是到了优化和重构为不同结构的时候。

考虑系统设计中的变更

如果您是负责决定用哪种编程语言实现高性能系统的系统设计师或软件架构师,那么本文向您提供了证据,说明Java是对抗C、c++或Rust等更“明显”的语言的关键竞争者。如果您是Java程序员,本文向您展示了使用Java语言可以实现的功能。

下次设计系统时,请考虑在项目开始时正在做出的原则和决策,这些原则和决策是非常困难或不可能更改的。对我们来说,这些是我们对模拟的使用和对确定性的关注。对于可能发生更改的系统方面,请选择一种架构,例如洋葱架构,以保持变更的可能性是开放和容易的。

关于作者

Matthew Cornford是Ocado Technology公司OSP自动化和嵌入式系统产品的负责人,他帮助开发了支撑Ocado高度自动化仓库的开创性软件,它是世界上同类产品中最先进的。Matthew在牛津大学学习的数学,之前有10年的软件工程和Java开发经验。

原文链接:

Using Java to Orchestrate Robot Swarms

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/XSWCbAH6cjgG5CRVNWNh

扫码关注云+社区

领取腾讯云代金券