Uber 的Greenlight Hubs (GLH) 在全球拥有700 多个分支机构,为司机合作伙伴提供从账户和支付到车辆检查和车主登记等各个方面的人力支持。为了给合作伙伴司机创造更好的体验并提高客户满意度,Uber 的客户优先工程团队开发了内部客户支持系统,该解决方案可以通过GLH 实现更简化、更快速的支持请求。
客户支持系统由两个主要功能组成:登记队列系统,供我们的服务专家跟踪驾驶员进入GLH;以及一个预订系统,允许司机通过优步司机应用程序安排人工支持预约。自2017 年3 月推出以来,这些工具改善了世界各地司机合作伙伴的支持体验。
过渡到本地解决方案
随着Uber 的发展,我们之前的客户支持技术无法很好地扩展,无法为我们的司机合作伙伴提供最佳体验。通过开发我们自己的GLH 客户支持系统,我们提出了一个适合我们的可扩展性和定制需求的解决方案,同时改进了现有基础设施以支持新功能。
开发我们自己的工具意味着我们可以:
方便地获取客户支持需求的信息:我们的注册系统使客户支持代表可以更轻松地获取解决驾驶员问题所需的相关信息。这种集成有助于减少支持解决时间并改善驾驶员使用GLH 的体验。
驾驶员沟通渠道的融合:Uber 各种支持渠道(包括应用内消息、GLH 本身和电话支持)的集中意味着GLH 专家拥有额外的上下文信息,可以在一处解决驾驶员问题。
减少司机伙伴在GLH 的等待时间:使用我们升级的系统,司机伙伴可以通过安排预约来避免高峰时段不必要的等待时间。
为了实现这些目标,我们为内部客户支持平台开发了两种新工具:登记队列和预订系统。
更顺畅的入住体验
通过在我们的客户支持平台上设计和实施的实时签到系统,为合作伙伴司机提供更加无缝的支持体验。使用该系统,司机可以向礼宾人员办理登记,然后礼宾人员根据与其帐户关联的电话号码或电子邮件地址找到司机的个人资料。
一旦司机注册,GLH 专家就会从站点的队列中选择他们。然后,驾驶员将在手机上收到一条推送消息,并在GLH 内部进行监控,通知他们已与专家配对。一旦司机与支持台通知中指定的专家会面,司机将退出签到队列。
我们的实时登记系统还聚合客户信息,例如过去的旅行和支持信息,使我们的专家能够尽可能有效地解决问题。
图1: 在GLH中,与专家配对时监控会向用户发出警报
提供实时专家队列
创建这个实时签到解决方案时遇到了一些困难。我们面临的一个挑战是在专家宣布驾驶员已得到协助的情况下防止专家冲突。为了实现这一目标,我们的系统提供了一个等待支持的驾驶员队列(称为我们的GLH 站点队列),通过该队列,专家可以与等待的驾驶员配对,并在选择驾驶员时实时通知他。
由于WebSocket 协议支持低延迟的长连接,因此我们利用它通过后端发送队列更新。 Go 是许多Uber 后端服务的首选语言,它允许我们使用管道和协程技术,从而更轻松地向Web 客户端提供实时更新。
尽管如此,我们在使用WebSocket 时遇到了一些有趣的挑战。为了使我们的站点队列实时工作,我们决定维护特定站点到固定主机的所有WebSocket 连接和队列写入。这样,当队列中的注册或预订更新时,所有相关的连接客户端也会更新。使用单个主机来处理这些请求需要在我们编写WebSocket 并将其连接到主机之前在应用程序层进行分片。
我们使用了Ringpop-go,这是我们为Go应用程序提供的开源可扩展且容错的应用程序层分片,它有助于配置分片键,以便具有相同键的所有请求都将被路由到同一主机。对于我们的分片键,我们使用了GLH 站点ID,因此同一GLH 上发生的所有注册都会转到同一主机并更新相关客户端上的所有站点队列。
图2: 我们的面对面支持架构利用前端WebSocket 连接到具有特定GLH 的主机。来自活动数据中心的GLH 专家前端和移动客户端的请求通过Ringpop 进行拆分,并分配给拥有给定GLH 的主机。来自非活动数据中心的请求将被重定向到活动数据中心。与个人支持相关的数据存储在Uber 的内部数据存储Schemaless 中
实现跨数据中心的高可靠性
为了保证我们的GLH软件顺利运行,我们需要保证高可用性。为此,我们的服务在多个数据中心运行,处理来自世界各地的请求。如果数据中心因某些不可预测的原因(例如中断)而宕机,服务将自行恢复并继续从其他数据中心运行。
鉴于我们使用WebSocket,在多个数据中心运行该服务会带来一系列困难。如果数据中心出现故障,我们就必须重新思考如何正确处理WebSocket。虽然Ringpop 分片在跨数据中心运行良好,但它会增加延迟,因为每次主机离开或进入环时都会发送跨数据中心请求。
为了解决WebSocket 降级问题,我们配置了系统,以便每个数据中心都有一个环;这样,如果具有相同唯一GLH ID 的两个请求到达两个不同的数据中心,它只会更新队列数据中心中我们的托管站点站点队列。无论请求来自哪个数据中心,我们都会将所有请求转发到固定的数据中心。如果数据中心出现故障,我们会将请求转发到其他数据中心。我们还将终止与故障数据中心建立的所有WebSocket 连接,并重新建立与新数据中心的连接。
添加预约
为了减少GLH 的等待时间并确保我们在高峰时段提供足够的支持,我们推出了一项新功能,让我们的司机合作伙伴只需在Uber 应用程序上轻松点击几下即可提前安排GLH 预约。
图3: 我们的面对面支持预约安排流程使司机合作伙伴能够
在我们的绿光中心轻松安排预约
图4: 当合作伙伴的应用程序到达Greenlight Hub 时,他们会在Uber 合作伙伴应用程序中收到签到通知。
尽管作为司机安排预约很简单,但为了使整个过程尽可能顺利,幕后还有很多工作要做。例如,GLH 经理可以随时指定在其中心工作的专家数量,以确保其团队不会超额预订;然后,当司机进入应用程序时,他们只会看到基于专家数量的可用预约数量。例如,如果某个GLH 周二上午9 点只有四名专家在工作,该中心的经理可以设置当时的预约容量为4 人,从而限制可用预约的数量。
当司机安排预约时,他们将出现在GLH 的当天预约列表中。当司机到达预定的约会地点时,他们可以通过应用程序轻松登记并通知指定的专家他们已经到达。构建我们的预约系统包括在后端实施调度系统、在移动设备上添加预约功能以及为GLH 经理开发基于浏览器的日历界面。
建立全球调度体系
受到Martin Fowler 关于重复日历事件的论文的启发,我们决定使用核心日历服务来构建我们的调度系统,该服务具体实现可用的时间间隔(简化为日历间隔),系统将其视为处理这些规范的规则。
在Fowler 模型中,这些规则可以由GLH 管理器指定和修改,从而实现更灵活的调度。由于调度系统通常需要考虑许多边缘情况,因此我们逐步构建调度系统以避免范围模糊,并为每个步骤提供一个功能系统:
我们的第一次迭代使用了GLH 管理员最初设定的运行时间以及每个站点指定的三名专家的全球容量,使我们能够慢慢推出该软件的测试版。
我们的第二次迭代使用GLH 管理员设置的日历间隔,允许他们定期设置专家池容量。
我们的第三次迭代结合了现有的日历时间间隔,但也允许GLH 经理设置GLH 关闭时间(即非工作时间和节假日)。
然而,由于Uber 的国际影响力,我们很快就遇到了与时区相关的问题,而系统的各个组件需要协调所使用时区的环境(例如GLH 时区或合作伙伴的时区),从而加剧了这些问题。时区。此外,我们需要考虑夏令时的变化。为了满足这些需求,我们采用了以下规则:
所有与主要后端服务API 交互的客户端均采用其所选GLH 的时区。
所有预约时间将以UTC +0 时区保存在我们的数据库中。
主要后端服务有一个内部层,用于处理持久层和API 层之间的所有时区转换。这使我们能够抽象出日历逻辑并调用内部与日历相关的方法,而不必担心时区。
需要注意的是,时区(即UTC 偏移量)不存储为GLH 对象的属性。如果是这种情况,那么夏令时更改将导致之前安排的预约时间向任一方向移动一小时。为了正确处理这个问题,将根据每个GLH 的物理坐标动态计算UTC 偏移量。
时区边缘的情况
在构建我们的调度系统时,我们遇到了一些有关时区的特殊情况。当我们的系统将日历间隔转换为本地时区时,会出现问题。由于UTC 和当地时间之间的时区变化(取决于相关网站的时区),日期可能不正确。例如,11 月20 日凌晨5:00 UTC(世界标准时间)实际上是11 月19 日太平洋标准时间晚上9:00。因此,重要的是,我们不要对相关时间段的日期做出假设,并测试时区何时跨越多天。
此外,在将GLH 营业时间从UTC 时间转换为当地时间时,我们遇到了类似的时区问题。我们使用当地时间可以节省时间,因为如果没有日期,我们就没有足够的上下文来将其保存为UTC。例如,周一上午9 点到晚上9 点的GLH 可能会导致UTC 工作时间从周一的5:00 开始,到周二的凌晨5 点结束。由于没有日期,因此不清楚这些当地时间指的是一周中的哪一天。因此,每当创建新的日历间隔时,我们都必须将打开时间从存储的本地时间转换为UTC 时间。根据业务逻辑所在的位置,这些场景可能需要在Web 和移动客户端以及服务器端进行广泛的测试。
在移动设备上使用日期时间库
为了让司机合作伙伴真正使用我们的调度系统,我们需要为移动设备构建新的用户体验。这涉及修改支持表单屏幕,为合作伙伴提供除提交按钮之外的其他选项来获取帮助,以及显示他们可能即将进行的任何预约的主帮助屏幕。
还有与特定活动相关的新屏幕:选择附近的GLH 进行预约、根据该地点可用的选项选择预约的具体日期和时间、确认您的选择以创建预约、查看有关预约的详细信息预约和取消,并查看有关网站详细信息的信息,例如地址。
由于我们正在处理日期和时间,并且因为我们希望服务器API 返回结构化数据(例如ISO 8601)而不是预先格式化的本地化字符串(即用户首选语言的日期)以供我们显示,因此我们假设将使用java.util.Date 标准。在此标准中,日期和相应的日历类在处理时区时存在许多已知问题,因此我们想探索其他选项是否可以更好地工作。例如,Joda-Time 标准(Java 8 API)听起来很有趣,但它尚未与Android(驱动程序合作伙伴设备上广泛使用的系统)兼容。
我们终于找到了ThreeTenBP——Joda-Time 的后继者,它将Java 8 的时间和日期API 带到了Java 6 和7。然而,之前在Android 上使用ThreeTenBP 的尝试遇到了启动问题。启动时,这些库从磁盘加载时区数据库信息,解析它并将其注册到库中以供以后使用。该库的Android 特定包装器以更友好的方式加载数据,但仍然存在阻止应用程序启动的重要磁盘操作。在中低端设备上进行测试时,这使得Uber 合作伙伴应用程序的启动速度减慢了200 毫秒以上。
我们尝试以多种方式优化ThreeTenBP,例如,在不同的线程上执行实际的磁盘操作,以便Application.onCreate 的其余部分可以并行发生,并在最后加入线程,以便Uber 合作伙伴应用程序可以安全地使用图书馆。我们还尝试使用其他类似的库,这些库尝试在启动时执行很少或不执行IO,但无法将启动时间降低到合理的延迟。
我们尝试使用方法分析器,令我们惊讶的是,通过解析代码,我们发现启动期间的大量时间都花在了string.split 等常见字符串方法上。根据我们对源代码的阅读,甚至来自Application.onCreate 的步骤调试器,这似乎没有发生。在探查器中,重量级操作被汇总到ZoneRulesProvider 类中的静态初始化程序中,(理论上)在其中注册惰性时区数据库提供程序代码。由于正在加载此类进行注册,因此即使正在注册的对象是完全惰性的并且在注册时不执行任何I/O,也会运行静态初始化块来尝试从ServiceLoader/META 加载时区数据库META-INF。这是Java 服务器而非Android 中的典型模式。它使用与我们由于其在Android 上性能较差而避免使用的资源下载相同的资源。
我们最终修改了ThreeTenBP 本身,以便可以轻松覆盖此静态初始化块的行为。默认实现将保持不变,但将被抽象在新的ZoneRulesInitializer 类后面。 Android 应用程序或库将能够提供自己的实现,以便在首次使用该库时通过Android 资产加载时区数据库。
我们更新了lazytritenbp,另一个适用于Android的ThreeTenBP包装器,以利用这个新接口,而ThreeTenABP的等效项尚未更新。使用该库的启动延迟为零,从而实现低延迟。然而,时区数据库的加载发生在静态模块初始化期间,这意味着在需要时区数据之前不需要执行任何操作,这甚至可能不会在典型的用户会话期间发生。 (Uber 应用程序非常大,很少有功能需要操作使用时区的日期和时间)。
图5: GLH 经理的日历UI 指定在任何给定时间段内特定站点上有多少专家可用。
我们还为GLH 经理构建了一个日历应用程序,以便轻松灵活地配置其站点的运营时间、可用预约时间以及任何给定时间的可用专家库。可用时间只能在工作时间内创建。日历中的休息时间变成灰色。日历还显示当前安排的约会。
在日历的周视图中,站点管理员可以从开始时间拖放到结束时间以创建可用时间。此外,他们还可以在移动应用程序中添加假期和午餐时间等关闭时间,以防止站点管理员在现场关闭期间意外增加可用时间。
为了设计这个界面,我们使用了Node.js、React/Redux、用于内联样式的Styletron、用于JavaScript 的ES2017 (ES8)、用于存储monorepo 的可重用组件的Lerna,以及其他一些Uber 库/框架,如Bedrock 和Superfine。设计可提供出色用户体验的日历功能非常复杂,因此创建日历功能并保持高性能是一项重大挑战。然而,我们不想损害我们简单、可读和可扩展的代码库。此外,我们希望创建一些可重用的React 组件,这些组件可以适用于将使用这些组件的其他前端项目。
在我们软件的测试版中,每次拖动日历时,日历中的许多元素都会重新呈现。因此,小时范围是动态显示的,即使这些元素中的大多数在视觉上不会更新。由于渲染日历中有很多DOM 元素,因此我们利用React 的虚拟DOM,通过调整shouldComponentUpdate 生命周期方法来减少需要渲染的元素数量。
然后,我们使用react-dnd的拖动源检查日历中的元素是否在开始和结束时间的范围内,并仅重新渲染这些元素。此外,我们使闭包和可用时间DOM 元素不可更新,因为它们不允许重叠,从而稍微提高了性能。结果,拖放过程中更新造成的200ms 延迟减少,接近于0。
由于日历应用程序对服务器进行了大量调用并包含许多性能调整,因此自诞生以来代码复杂性显着增加。为了保持代码干净简单,我们将代码提取到可重用的组件和HOC 以及一些环境设置中,并将其转换为前端monorepo。我们使用Lerna 进行monorepo 并发布包。通过使用monorepo,多个包存储在一个存储库中,这可以节省引导新项目的时间,并且可以一次更新多个组件,从而更容易添加跨组件功能或修复错误。此外,为了增强React组件的可重用性,我们使用Styletron代替CSS进行内联样式。这确保其他开发人员不需要自己添加CSS,从而不必担心样式冲突,因为所有样式都直接应用在JavaScript 代码中。
Uber 现场支持工程的未来
该产品的开发有助于改善合作车主在GLH上的体验,从而提高客户满意度。迁移到新系统后,等待时间平均减少了15% 以上,与客户支持专家匹配后,问题解决时间也减少了25%。最重要的是,这些新功能意味着与GLH 预约的司机几乎无需等待。
这只是我们为世界各地的合作伙伴司机和客户支持专家提供的众多产品中的一个示例。我们不断探索新技术,以改善用户的GLH 体验,从改进我们的分析到在合作伙伴提交申请之前主动为其提供支持服务。