面临的问题
在过去的几年里,Airbnb 爱彼迎的工程团队从单体 Ruby on Rails 架构转变成为了 SOA 面向服务的架构。在我们的 Rails 架构中,每个资源(resource)都有一个单独的 API 来访问底层数据, 这些 API 都有授权检查来保护敏感数据。当时由于访问资源数据的方式单一,因此管理这些授权检查很容易。在向 SOA 过渡的过程中,我们转向了分层架构,其中有封装数据库的数据服务(data services),也有从多个数据服务提取的展示层服务(presentation services)。将权限检查从单体转移到 SOA 的最初方法是将这些检查转移到展示层服务。但这导致了许多问题:
重复且难以管理的授权检查:通常多个展示层服务对相同底层数据提供访问,会使用重复的授权检查代码。有时候这些检查是不同步的,难以管理。
扇出依赖到多个服务:这些授权检查大多数都需要调用其他服务,慢且难以维护,还会影响整体性能和可靠性。
早期的授权检查
解决方案
基于 Himeji 的授权检查
我们做了两个更改来解决上述问题:
我们将授权检查移到数据服务中,而不是仅在展示层服务中执行授权检查。这有助于我们缓解重复且不一致的检查问题。
我们创建了 Himeji,一个基于 Zanzibar 的集中授权系统。它可以被数据层调用、储存权限数据并作为集中数据源来执行检查。考虑到读取工作的负载繁重,我们把扇出依赖从较为繁重的读路径转移到了写路径。
API
Himeji 为数据服务开放了一个 API 来执行授权检查。 API 的签名如下:
一个权限检查如下所示,表明“用户 123 可以对房源 10 的描述进行写操作吗?”:
Himeji 将其解释为“用户 123 是否在可以对房源 10 的描述进行写操作的用户组里?”。
存储
类似于 Zanzibar,Himeji 的基本存储单位是三元组entity # relation @ principal的形式。
实体(entity)是三元组 entity type : entity id : entity part; 源自于自然语言对房源 10 的描述:LISTING : 10 : DESCRIPTION。
实体 id (entity id)是真实数据源中对应的 id。
实体类型(entity type)定义了权限所应用的数据。例如:房源 LISTING, 订单 RESERVATION, 用户 USER。
实体部件(entity part)是实体中的组成部分(可选的)。例如: 描述 DESCRIPTION,价格 PRICING,无线网络的信息 WIFI_INFO。
关系(relation)描述实体和主体之间的关联性,像所有者 OWNER,读取 READ,或者写入 WRITE , 但可以详细到用例;如用 HOST 表 示一个订单的房东,用 DENY_VIEW 表示不能访问房源。
主体(principal)是经过身份验证的用户如 User(123),或是另一个实体,如 Reference(LISTING:15)。
配置
如果我们为每个检查的确切权限都编写一个元组,那么会带来数据体量和去规范化请求的指数级增长。例如,我们必须同时编写 LISTING : 10 # WRITE @ User(123)和 LISTING : 10 # READ @ User(123) 才能使房源所有者能够读取和写入。
基于 Zanzibar 的配置,我们使用基于 YAML 的配置语言,允许通过集合代数来解析权限检查,即允许开发人员将检查映射到集合操作:
LISTING:
'#WRITE':
union:
- '#WRITE'
- '#OWNER'
'#READ':
union:
- '#READ'
- '#WRITE'
假设用户 123 是房源 10 的所有者。那么数据库将存储元组LISTING : 10 # OWNER @ User(123)。
当我们请求check(entity: "LISTING : 10", relation: WRITE, userId: 123)时,Himeji 将 LISTING # READ 理解为 READ & WRITE 的并集,并相应的将 LISTING # WRITE 理解为 WRITE & OWNER 的并集。因此,它将从数据库中获取以下内容,以及任何符合 LISTING # WRITE 的匹配项:
Query LISTING : 10 # WRITE @ User(123) => Empty
Query LISTING : 10 # OWNER @ User(123) => Match User(123)
那么我们来看示例,用户 123 只需要有 LISTING : 10 # OWNER @ User(123) 就可以在 LISTING : 10 # WRITE 的集合中.
参考
我们观察到 Airbnb 爱彼迎平台上的实体经常因为其他实体的存在而需要授予他们访问权限。例如,订单的房客可以访问房源的位置以及房源的其他信息。我们用一个元组来表示这个用例,其中主体是对实体的引用,即 LISTING : $id # RESERVATION @ Reference(RESERVATION : $reservationId)。这使我们能够表达这样一个概念,即用户在 LISTING : LOCATION # READ 集中,这个用户属于一个订单的“房客”集合,而这个订单属于一个房源的“订单”集合,从而最大限度地减少需要存储的数据量:
LISTING:
LOCATION:
'#READ':
union:
- #OWNER
- LISTING : $id # RESERVATION @
Reference(RESERVATION : $reservationId # GUEST)
这种方法与 Zanzibar 的不同之处在于,这种元组不包含主体内的关系(即 Reference(RESERVATION:$id # GUEST)。引用实体后的关系是静态的,从配置中检索的。以房源为例来检查其他用例,我们发现通常一个引用将被多个关系遵守。在我们的产品中,两种实体类型之间使用的关系集没有差异;集合中的更改意味着产品更改并适用于所有实体类型。如果两个实体类型之间的关系集(如Reference(RESERVATION:$id#GUEST), Reference(RESERVATION:$id #COTRAVELLER), Reference(RESERVATION:$id#BOOKER), ... )的大小为 M,则为每个写一个元组将产生 N*M 个元组。通过将关系存入配置,我们将存储数据的大小减少到 N。
在读操作时,假设以下元组存储在数据库中:
LISTING : 10 # OWNER @ User(123)
LISTING : 10 # RESERVATION @ Reference(RESERVATION : 500)
RESERVATION : 500 # GUEST @ User(456)
现在,如果客户端发送如下请求:
check(LISTING : 10 : LOCATION # READ, User(456))
那么根据配置,Himeji 会依据请求中的信息和上述配置来生成第一个数据库查询:
Query LISTING : 10 # RESERVATION => Match Reference(RESERVATION:500)
Query LISTING : 10 # OWNER @ User(456) => Empty
之后 Himeji 会再生成第二次数据库查询,替换找到的订单 id,如果匹配则表明用户 456 在允许读取房源 10 位置的用户集合中:
Query RESERVATION : 500 # GUEST @ User(456) => Match User(456)
架构和性能
Himeji 架构
Himeji 可分为三层:
编排层(orchestration layer)接收来自客户端的请求,负责根据配置逻辑,生成数据获取请求,并解析结果。编排层通过一致性哈希算法路由到缓存层。
缓存层(caching layer)是分片的和复制的(每个可用区(az)、每个分片会有一个实例),负责在内存中筛选、去重未命中的请求,再访问数据库。每个分片都通过一致性哈希算法分配拥有了一组数据。我们的目标是缓存命中率约为 98%。
数据层(data layer)由逻辑分片数据库组成。
在 Zanzibar 的设置上,我们对 Himeji 做的最显著的改变是:
将请求编排层与缓存层分开,这样编排层可以更轻松地更新,而无需重新启动缓存。
依据数据库中已发布的变更,来使缓存分片失效。
作为我们云上之旅的一部分,使用 Amazon Aurora 数据库进行存储,这与 Zanzibar 使用 Spanner 有所不同。
可用性方面,我们使用了与 Zanzibar 相同的可靠性(对冲、分层缓存)和减载功能。
Himeji 已在生产环境中稳定运行,提供了一年多的检查服务,其吞吐量已从 2020 年 3 月的 0 个实体/秒提升到 2021 年 3 月的 85 万个实体/秒,同时维持了可用性和延迟目标:
Availability 99.9990%
P50 Latency 1.8 ms
P95 Latency 7 ms
P99 Latency 12 ms
工具
为了减少集成时间、推动开发人员采用,我们构建了一些工具,例如:
基于配置的数据回填:将现有的权限检查迁移到 Himeji 需要我们回填现有实体的权限元组。我们没有让每个数据服务所有者构建自己的回填流,而是构建了一个基于 Apache Airflow 和 Apache Spark 的通用解决方案。服务所有者只需提供一个小的配置,指定他们的元组如何从导出的数据中生成即可。
自动代码生成:为了使上手更容易,我们提供了脚本来自动生成 Java 和 Scala 代码。
胖客户端:我们提供了一个胖 http 客户端,其中包含日志记录、指标和迁移推出控制。
用于调试和一次性任务的 UI 工具:调查一次性权限问题会很繁琐,并且需要检查系统中的权限数据,因此我们构建了一个 UI 来分析数据和修复权限问题。
结论
基于 Zanzibar 的 Himeji 授权系统将爱彼迎的授权数据和逻辑进行统一管理。在引入它之前,不相关的逻辑之间要保持一致和性能相互兼容是很困难的。Himeji 利用简单的数据模型和灵活的逻辑配置来集中所有产品和数据授权。Himeji 扩展了 Zanzibar 的可伸缩性和性能属性,并通过其高命中率分层分布式缓存降低了延迟。所有这些加在一起,使得 Himeji 存储了数百亿个关系,每秒服务近 100 万个实体授权,同时保持低延迟和高可用性。
爱彼迎一直以来都有许多像 Hemiji 这样令人期待的项目。技术大拿们,你是否已经跃跃欲试想要加入我们,加入到这个项目,欢迎查看我们的招聘首页。
商标归属
1. “Rails”和“Ruby on Rails”是 David Heinemeier Hansson 在中国使用的商标。
2. Apache Kafka、Apache Airflow、Apache Spark 和 Apache 是 Apache 软件基金会在中国使用的商标。
3. AWS 和 Amazon Aurora 是 Amazon.com, Inc. 或其附属公司在中国注册并使用的商标。
4. Java 是 Oracle 和/或其附属公司在中国注册并使用的商标。
所有上述商标均为其各自所有者的财产。对这些的任何使用仅用于描述和说明目的,并不意味着赞助或认可。
作者:Alan Yao, Dipak Pawar, Blair Wu, Abhishek Parmar,译者:Ting Jiao,校对:Jun Hu, Betty Xi, Jiayu Liu。
Himeji 的实现和顺利应用需要感谢爱彼迎不同团队成员的贡献,感谢所有参与到项目中的成员。
如果你想了解关于爱彼迎技术的更多进展,欢迎关注我们的 Github 账号(https://github.com/airbnb/) 以及微信公众号(爱彼迎技术团队)。