我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。
本文作者:飞书商业应用研发部 吴聪,滕承伟,向诗剑
欢迎大家关注飞书技术,每周定期更新飞书技术团队技术干货内容,想看什么内容,欢迎大家评论区留言~
背景介绍
在字节跳动内部,包括飞书(LARK)在内的众多业务,在业务需求的推动下,发展出了各自的文件服务,以满足文件上传、下载、存储、预览等需求。目前在各个业务中,有些业务自己抽象了文件服务,有些则直接使用对象存储基建服务,他们对文件处理的方式也不一样。鉴于此现状,需要一款统一的基建类的文件服务,让业务专注于产品,提高人效。
从架构角度来看,文件 API 通常需要支持相对较大的数据传输,甚至于 GB 级别的数据,如果与业务的 web 服务混合在一起,因为可能占用过多 CPU、内存、带宽等资源,容易对业务 API 造成影响,因此文件 API 也需要从 API 服务中分离出来,进行单独的部署。
设计目标
满足各个业务对于文件服务的基本诉求,构建无业务耦合的文件服务,对外提供基于业务的数据隔离机制,统一的 SPI (Service Provider Interface) 鉴权机制以及用户友好的文件读写接口,对外屏蔽存储层实现细节。同时,在保证可用性和稳定性的基本要求下,尽量提升文件读写性能,并提供流量整形、请求频控、外部文件导入、文件预览、CDN加速访问等多种增值服务。
约束限制
- 业务方(接入通用文件服务的使用方)使用文件服务后,对文件的读写均通过文件服务提供的接口实现,不会直接操作文件服务背后的文件数据存储服务;
- 文件服务提供上传文件、下载文件、删除文件、查询文件元数据、查询文件列表、文件搜索、文件预览、外部文件导入等接口,不提供文件/文件夹重命名、文件追加写、文件修改写、文件目录层级操作等接口;
- 文件服务提供独立的内网域名和公网域名,但不提供独立的租户用户账号体系,而是提供统一的 SPI 鉴权机制,方便与业务方解耦。业务方需要按照 SPI 实现接口,配合文件服务完成鉴权;
- 业务方通过服务名(PSM,即为Product, Subsys, Module,字节跳动中服务的唯一标识)授权信任来自文件服务的 RPC (即 Remote Procedure Call)请求,文件服务通过业务方携带的 Auth Token 判断业务方的身份;
核心设计
总体架构
架构简述:
- 文件服务出口为独立部署的域名+负载均衡,其下分为服务层和存储层;
- 从文件服务负载均衡到文件服务层再到文件存储层全链路都需要支持数据的流式传输,解决服务节点内存资源开销的问题;
- 文件服务为无状态服务,通过集群部署可以很方便地扩容;
- 文件服务存储层分为文件元数据存储引擎、文件数据存储引擎,分别用于文件索引,存储文件内容;
- 文件服务的鉴权机制通过定义 SPI 来实现,需要接入文件服务的业务方需要实现 SPI 鉴权接口;
- 文件服务并非对背后的文件数据存储引擎(如 S3、Azure、OSS,字节跳动的 TOS 等)进行简单的代理转发,而是可以有自己的业务逻辑,可以迭代许多增值服务,包括支持文件夹、支持搜索、流式加解密、垃圾清理机制、文件预览等功能;
- 文件元数据存储引擎可选择 Mysql、Oracle、MongoDB、字节跳动的 Bytedoc 等,主要需要考虑数据量等问题,就能做出合适的数据库选型;
- 文件服务是核心业务,关注文件存储、上传、下载本身;文件中台服务,涵盖搜索、预览等增值功能;
CQRS
CQRS 是“命令查询责任分离”(Command Query Responsibility Segregation)的缩写,命令即写操作,查询即读操作。在基于 CQRS 的系统中,命令和查询所使用的数据模型是不同的,命令模型用于有效地执行写/更新操作,而查询模型用于有效地支持各种读数据需求。通过领域事件同步数据,或者其它机制进行数据同步。
文件服务用到了 CQRS 思想,一方面文件服务需要满足文件上传/下载过程中的数据需求,一方面业务方有多样的文件搜索需求,于是通过不同的数据模型满足多方面的需求。
Multi-Geo
Microsoft 365 在 2017 年、Google Workspace 在 2018 年开始提供 Multi-Geo 能力,以帮助跨国公司应对全球各地的数据驻留要求。
面向跨国公司类的业务需求,文件服务将其在全球各地的不同的员工用户数据,分散存储在不同的地理位置,可以满足“数据驻留位置”的硬性需求。业务方在使用文件上传、下载接口时,无需关心文件具体的“数据驻留位置”,可以直接进行文件的上传、下载,降低业务方使用成本。当“数据驻留位置”需要更改时,文件服务能进行文件的自动迁移。
主 IO 流程
以上传文件和下载文件为例,业务方通过文件服务接口交互的时序图如下
上传文件
下载文件
SPI 鉴权
API 在大多数情况下,都是实现方制定接口并完成对接口的实现,调用方仅仅依赖接口调用,且无权选择不同实现。
SPI (Service Provider Interface) 与 API 不同,SPI 是调用方来制定接口规范,提供给外部来实现,调用方在调用时选择自己需要的外部实现。
服务互信
为了达到无业务耦合的目的,通用文件服务本身不提供租户用户的帐号体系,因此需要通过与业务方约定一套鉴权机制来管理文件的读写权限,同时通用文件服务与业务方需要通过安全机制保证服务间互信,方案如下:
-
【HTTP】 业务方-> 通用文件服务,业务方在调用接口时(上传文件、下载文件 etc) 需要在请求头中携带三个必选参数:
- Authorization: SPl {spi_token} 用于 SPI 鉴权;
- X-Request-From: {supplier} 代表业务方,例如“管理后台”;
- X-Request-Token: {auth_token),其中 {auth_token} 代表业务方的访问凭证 (具有一定的有效期),由业务方通过加密配置管理(如字节跳动的ECM)中的密钥加密生成(使用 JWT 算法生成,即 Json web token),通用文件服务同样通过访问加密配置管理服务获得解密访问凭证的密钥,从而对请求进行验证;
-
【RPC】 通用文件服务 ->业务方,业务方实现通用文件服务定义的 SPI,并提供 RPC 接口,业务方通过对通用文件服务进行 PSM 授权,允许通用文件服务获得调用 SPI 接口的权限。
SPI 接口设计
接口 IDL(Interface Description Language)定义如下
struct CheckSpiTokenReq {
1: required string spi_token
255: optional base.Base Base
}
struct CheckSpiTokenResp {
1: required string level_1 // 业务第1层级,例如租户id
2: optional string level_2 // 业务第2层级,例如用户id
3: optional string level_3 // 业务第3层级,例如业务id
4: optional i64 expire_at // token 过期时间,单位秒
5: optional i64 opt_type // 预留
6: optional string file_id // 文件id,圈定token的有效范围是单文件
7: optional Processor processor // 文件增值处理功能
255: optional base.BaseResp BaseResp
}
service SpiTokenService {
CheckSpiTokenResp CheckSpiToken(1: CheckSpiTokenReq req)
}
SPI Token 自定义鉴权逻辑
常见自定义方式
Upload token
上传时使用该 token,在上传多个文件时,可以复用
Specified file token
下载/获取文件元数据等需要针对某个指定 File ID 的文件进行操作时,需要获取 Specified file token。假如该 token 泄漏,最多只能获取到该文件的数据,不能获取到其它文件的数据,因为 Specified file token 中加密编码了 File ID。
常见非法 HTTP 请求
-
未携带3个必须的请求头的请求会在负载均衡层被拦截,返回 401 错误
- Authorization: SPl ${spi_token}
- x-Request-From: ${supplier}
- X-Request-Token: ${auth_token)
-
业务方在使用通用文件服务之前需要首先注册接入,申请完成后会分配 X-Request-From, X-Request-From 值非法的请求会返回 400 错误
-
X-Request-Token 校验不通过的请求会返回 403 错误
-
X-Request-Token 过期的会返回 403 错误
-
Authorization 不含有“SPl ”前缀的请求会返回 401 错误
-
Authorization 校验不通过的请求会返回 500 错误,即 SPI 鉴权不通过
-
Token 与文件 ID 不匹配的会返回 400 错误
-
鉴权校验通过且接口业务处理异常的返回 4XX、5XX 错误
-
鉴权校验通过且接口业务处理正常的返回 200
文件元数据
存储选型
文件服务可能存储海量文件,因此文件元数据存储也必须支持大数据量场景。同时,文件服务需要提供文件列表查询接口(可能基于部分属性过滤,如文件属主),简单的 KV 存储无法满足需求(即便可以支持也需要通过额外的设计来实现)。MongoDB 天生支持数据分片且可以支持灵活的 Schema 定义(后续增值服务的迭代可能会在文件元数据中新增字段),适合用来作为文件服务的文件元数据存储引擎。
元数据结构
文件元数据主要包含内容包括:文件元信息(文件名、大小、类型、checksum etc)、文件在数据
存储服务的路径、文件属主信息(业务方、level_1、level_2、level_3)等,具体设计如下:
// MongoDB 存储层数据结构
type FileMeta struct {
Id *string // 文件编码
// 文件元信息
FileName string // 文件名
DisplayName string // 展示文件名
FileSize int64 // 文件大小
FileType string // 文件类型
FileChecksum string // 文件校验和
// 文件属主信息
Supplier string // 业务方
Level_1 string // 属主层级1
Level_2 string // 属主层级2
Level_3 string // 属主层级3
// 其他信息
ExternalFileId string // 外部文件ID
GroupIndex string // 分片键
ObjectStorageTag string // 存储标签
ObjectStorageType ObjectStorageType // 存储类型
ObjectStorageEndpoint string // 对象存储端点
ObjectStorageBucket string // 对象存储桶
ObjectStoragePath string // 文件路径
ObjectStorageEncryption value.Encryption // 文件加密类型
ObjectStorageVisibility value.Visibility // 对象存储桶访问级别
LastUpdateTime time.Time // 时间
LastUpdateTimestamp int64 // 时间戳
ExpireAt *int64 // 过期时间
Extra map[string]interface{} // 扩展信息
}
索引设计
-
分片键:MongoDB 通过数据分片支持大数据量的存储,分片键要求必须是唯一索引的前缀且不能有多个字段,使用 {supplier}_{Level_1} 拼接而成,并进行了 md5 计算,Base64Md5({supplier}_{Level_1}),尽量保证数据在分片上存储均匀,业务方 Level_1 数据倾斜的问题暂不考虑;
-
分片索引:使用分片键(group_index);
-
唯一索引:(group_index, level_2, level_3, file_name) ,该唯一索引表明同样属主条件下文件名唯一;
-
排序索引,文件服务提供查询文件列表的接口(按照文件名字典序),业务方可能使用多种查询条件,支持指定目录下的排序列举(更丰富的搜索需求通过 Elasticsearch 实现):
- (group_index, object_storage_path)
- (group_index, level_2, object_storage_path)
- (group_index, level_2, level_3, object_storage_path)
-
GC 索引,在实现 GC 机制时,需要扫描文件数据存储服务中的 [悬挂文件] 并反查文件元数据,因此需要依赖索引(group_index, object_storage_path)
当通过 SPI 接口获取鉴权信息时,level_2 和 level_3 为可选参数,此时对于单个文件操作接口的唯一索引构建规则为 level_3 为空时继承 level_2, level_2 为空时继承 level_1, 示例如下:
假设业务方为:一个名字为“erp”的系统
-
数据为:level_1=abc, level_2=bcd, level_3=ced,
- 构建索引为:(Base64Md5 (erp_abc), bcd, ced, ${file_ name})
-
数据为:level_1=abc, level_2=bcd, level_3 为空,
- 构建索引为:(Base64Md5 (erp_abc), bcd, bcd, ${file_name})
-
数据为:level_1=abc, level_2 和 level_3 为空,
- 构建索引为:(Base 64Md5 (erp_abc), abc, abc, ${file_name})
元数据读写策略
-
上传文件:
- 【UPSERT】先写文件数据成功后再 upsert 文件元数据,当文件元数据写失败时,返回上传文件失败;
- 【INSERT】 先查询文件元数据看是否已存在,如果已存在返回上传文件失败,如果不存在,先写文件数据成功后再 insert 文件元数据,如果文件元数据写失败时(包括唯一键冲突),返回上传文件失败;
- 【RenameOnConflict】先查询文件元数据看是否已存在,如果文件已存在,对文件名进行 rename 操作,然后写文件数据,再 insert 文件元数据,当文件元数据写失败时,返回上传文件失败;
-
下载文件:先读取文件元数据,再读取文件数据,两者中任意失败返回下载文件失败;
功能及优化
大文件上传
当待上传文件较大时,受限于文件数据存储服务(如 TOS 限制为单个文件最大 4 GB),需要将文件分段上传,具体策略为:收到上传文件请求时,将文件按照 20MB 大小分段上传,整个文件上传成功后合并段生成最终文件,再更新文件元数据。客户端可以通过指定 Content-Length 上传文件,也可以通过 Transfer-Chunked 模式上传文件,都可以通过此算法统一实现文件上传。
通常情况下,微服务的业务接口都是以简单的 Json/Xml/Text 等格式来定义数据,如果涉及到进一步地处理,客户端或服务端只需要将数据全部加载到内存后处理即可,例如将数据先加载到内存后再调用加解密库进行加解密,然后写入目标流(如底层的存储层)。
但是文件服务中,需要提供流量接口来传递大数据且同时需要对大数据进一步地处理(如加密),此时如果将数据先加载到内存后进行处理,会导致微服务实例内存资源占用过高,严重情况下导致 OOM 进程退出。
所以采用流式编程,其可以用非常小的内存开销,解决大数据传递并进行实时处理的问题。
- 上传文件时,文件服务需要支持流式读取业务方发送的 HTTP 请求体。这样当服务层接收 HTTP 请求体前可先对通用请求头进行权限校验,当校验失败时拒绝接收 HTTP 请求体,防止造成网络带宽流量浪费以及在文件数据存储服务产生垃圾数据;
- 下载文件时,文件服务需要从文件数据存储服务流式读取数据并流式返回给业务方作为 HTTP 响应体,解决服务层内存资源开销问题;
- Hertz(字节跳动 Golang HTTP 框架) 框架已支持流式文件上传和文件下载,可以作为文件服务层的 HTTP 框架。
流式加解密
业务方通过通用文件服务上传的数据可能是一些敏感数据,例如用户信息的 Excel 表格、候选人简历附件等。需要在上传到文件数据存储服务时进行加密,由于通用文件服务整个 IO 流程都是流式传输的,因此需要实现流式加密。同样的,当从文件数据存储服务下载时,需要进行流式解密。使用 AES 加密算法:
AES With Padding 算法的特点是数据长度不足16的倍数时,会在加密时补齐为16的倍数,利用这个特点我们就可以把分段的 chunk 大小设置为:16<<x-1,然后再进行分段加密;解密时以 16<<× 读取数据进行分段解密最后聚合数据还原。
数据一致性
上传文件
现在一共支持三种上传策略,数据一致
-
【INSERT】:
- 如果文件元数据存在直接返回上传失败,业务方查询到的是原有的文件元数据;
- 如果文件数据写入对象存储失败,业务方仍然查询到的是原有的文件元数据或者查询不到文件元数据;
- 如果文件数据写入对象存储成功,但写入文件元数据时遭遇唯一键冲突失败,业务方仍然查询到的是原有的文件元数据或者查询不到文件元数据;
-
【UPSERT】:
- 如果文件数据写入对象存储失败,业务方仍然查询到的是原有的文件元数据或者查询不到文件元数据;
- 如果文件数据写入对象存储成功,但写入文件元数据时失败,业务方仍然查询到的是原有的文件元数据或者查询不到文件元数据;
-
【RenameOnConflict】,当希望始终创建新文件时使用该策略:
- 如果文件数据写入对象存储失败,业务方查询不到文件元数据;
- 如果文件数据写入对象存储成功,但写入文件元数据时失败,业务方查询不到文件元数据;
下载文件/查询文件 元数据 /查询文件列表
始终从数据库主库读取文件元数据,数据强一致;
删除文件
- 如果删除文件元数据失败,业务方查询到的还是原有的文件元数据;
- 如果删除文件元数据成功,虽然文件数据仍然存在,但业务方已无法通过接口关联到,数据强一致;
搜索文件
作为一个扩展功能,预期是支持丰富多样的搜索需求,不保证数据强一致,因为 MongoDB 异步同步 Elasticsearch 本身是有延时的。
数据完整性
通过通用文件服务接口上传/下载文件需要有数据完整性保障机制,确保业务方数据上传到下载整个链路数据可靠,这里从上传和下载分别讨论。
- 上传小文件:上传小文件完成时,文件数据存储服务(如 TOS )会返回加密后文件的 ETag(计算方式Hex(md5(文件内容))。同样地,通用文件服务可以在加密上传时以同样的算法计算出文件加密前和加密后的 ETag,将文件数据存储服务返回的 ETag 与加密后计算出的 ETag 做比对,如果不一致则返回上传失败,否则表明数据传输正常并将计算出的整个文件加密前的 ETag 值写入文件元数据 file_checksum。
- 上传大文件:当使用分段上传机制时,每上传一段加密数据,文件存储服务会返回加密后分段的 ETag,通用文件服务可以在加密上传每段时以同样的算法计算出文件加密前和加密后的 ETag,然后对每个分段的 ETag 做比对,如果不一致则返回上传失败,否则继续上传下一分段,当所有分段上传完成后将计算出的整个文件加密前的 ETag 值写入文件元数据 file_checksum。
- 下载文件:依赖客户端配合,当下载整个文件完成时,通用文件服务会返回文件元数据中的 file_checksum,客户端可以通过计算接收到数据的校验和与文件元数据中的 file_checksum 做比对,如果一致则表明下载成功,否则下载失败。
文件夹
在文件服务中,文件夹(Folder)用于对象的分组和文件的整理。文件服务支持将文件夹概念作为对象分组的一种方式。文件夹是介于两个 "/" 字符之间的值。例如,
- 如果文件路径为 Project/Files/1.txt,则该文件路径表示存在一个文件夹 "Project" 和子文件夹 "Files" ,"Project" 和 "Files" 都属于文件夹。
- 如果文件路径为 123.txt,则表示未使用文件夹来存储该文件。
权限缓存
为了避免对同一个 {spi_token} 做加速缓存(SPI 接口定义了 ${spi_token} 的有效期),缓存的数据结构如下:
// for example
{
${supplier}_${spi_token}: {
"level_1": "张三",
"level_2": "自定义表情",
"level_3": "自定义表情",
"expire_timestamp": 1623612121223,
}
}
GC 机制
当更新/写入文件元数据失败、更新文件成功生成新的元数据、调用删除文件接口删除文件、分段上传大文件时只上传了部分数据等场景,会在文件数据存储服务中留下「悬挂文件」,这类文件虽然物理上存在但通过通用文件服务无法索引到,因此需要有清理机制。清理机制如下:
- 清理「悬挂文件」:以业务方维度定时扫描文件数据存储服务上的文件(文件路径 object_storage_path 均为{file_name}/${uuid}),通过 (group_index, object_storage_path)索引查询文件元数据服务,如果未查询到文件元数据且文件数据存储服务上的创建时间超过一定阈值(例如超过3天的悬挂文件),则代表该文件可以安全删除,加入删除队列异步删除;
- 清理「无效分段」:以业务方维度定时扫描分段上传任务,再根据分段上传任务的初始化时间判断是否是无效分段任务(例如超过了3天的分段上传任务,可配置),如果是无效分段任务代表该分段任务可以安全删除,加入删除队列异步删除。
文件预览
用户上传文件时,有时会有文件预览的需求,对于 PNG、JPG 等文件,客户端可以通过组件直接支持文件的预览,但是对于 word 文件,如果预览通常会有乱码的问题,而 PDF 的预览不会有乱码问题,所以将 word 文件转码为一个 PDF 文件,然后进行预览。下载大文件时,可以利用 Range 特性实现大文件分段下载。
总结
通过文件服务的业务抽象,提供了一个通用的文件服务:
- 满足各个业务对于文件服务的公共诉求,构建无业务耦合的文件服务,接入成本低
- 将文件服务的核心能力沉淀下来,提供用户友好的文件读写接口,对外屏蔽实现细节,使得各个接入的业务团队可以聚焦于业务本身
- 业务方的服务端、客户端均可以上传/下载文件,满足业务方各种场景下的文件操作需求
- 提供安全的接口鉴权机制、数据隔离机制、数据加密机制
- 提供流量整形、请求频控、文件预览、CDN 加速访问等多种增值服务
- 支持 Multi-Geo 等
展望未来
围绕文件的话题,还有很多的点值得探讨,未来也会找准重要的方向演进,包括但不限于进一步抽象引擎,优化性能,提供更丰富的 API 等等。
断点续传
客户端在上传/下载时,将文件人为地划分为多个分片,每一个部分采用一个线程进行上传/下载,多个分片的操作可以并发地执行,如果碰到网络故障,可以从已经上传/下载的部分开始继续上传/下载未完成的部分。断点续传也一定程度上解决了弱网场景下的文件传输稳定性问题。
构建生态
将文件服务搭建成一个文件平台,围绕文件平台搭建生态,叠加产品功能。例如:
- 附加内容安全审核功能,通过自动化图片识别,识别非法图片,也可增加人工审核的步骤,拦截非法文件;
- 增加插件配置能力,业务方可以增加 Session 校验插件,以防止越权访问的发生。
加入我们
扫码发现职位 & 投递简历: