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