作者 | 阿里云存储
来源 | 凌云时刻
背景
又一届亿万买家和卖家参与的“双 11”落下帷幕,“双 11”这场消费盛宴离不开阿里云产品技术团队的大力支撑,阿里云存储团队的表格存储就是其中之一。
把时间向前拨动 10 个月,突如其来的疫情导致的线上办公的刚性需求,让钉钉对现有存储系统在成本、可运维性、稳定性方面提出了极大的挑战。经过一系列调研,钉钉最终选择阿里云存储团队的表格存储 Tablestore 作为统一的存储平台。经过钉钉团队和表格存储团队的紧密合作,钉钉 IM 系统、IMPaaS 多租户平台的所有消息同步数据和全量消息数据已经基本完成迁移工作。在刚刚过去的 2020 双 11 购物节,Tablestore 第一次全面支持集团 IM(钉钉、手淘&天猫&千牛客服聊天、饿了么等)并平稳度过,保障亿万买家和卖家之间更为顺畅的交流。
本文将介绍钉钉 IM 存储架构、表格存储 Tablestore 为了满足迁移在稳定性、功能、性能上做的一系列工作。
关于表格存储 Tablestore 与钉钉消息系统
阿里云表格存储 Tablestore 是一款面向海量结构化数据存储、搜索和分析一体的在线数据平台,可扩展至单表 PB 级存储规模,同时提供每秒亿次访问服务能力的产品。作为阿里云智能自研产品,Tablestore 具有高性能、低成本、易扩展、全托管、高可靠、高可用、冷热数据分层和灵活计算分析能力等特性,尤其是在元数据、大数据、监控、消息、物联网、轨迹溯源等类型应用上有丰富的实践经验和积累。下图展示了 Tablestore 的产品架构。
钉钉消息系统
钉钉消息系统包括:钉钉 IM 系统和 IMPaaS 系统,其中钉钉 IM 系统承载了钉钉 App 的 IM 业务,IMPaaS 作为多租户 IM 平台支持集团各团队的接入,目前已经承载了钉钉、手淘&天猫&千牛客服聊天、饿了么等、高德等集团主要 IM 业务。两套系统在架构方面是统一的,所以本文后续中不会明显区分两套系统(为了方便,统一描述为 IM 系统)。该系统负责个人和群组消息的接收,存储和转发环节,同时也包括会话管理,已读通知等状态同步。一句话概括:负责端与端的沟通。
存储架构升级
IM 系统已有架构使用的存储系统比较多,主要包括 DB(InnoDB、X-Engine)和 Tablestore。其中会话和消息的存储使用 DB,同步协议(负责消息同步推送)使用 Tablestore。在疫情期间,钉钉的消息量增长非常迅速,让钉钉团队意识到使用传统的关系数据库引擎来存储流水型的消息数据局限性较大,不管是在写入性能、可扩展性、还是存储成本上,基于 LSM 的分布式 NoSQL 都是更佳的选择。
上面是 IM 系统原来的架构图。在这套系统中,一条消息发送后,会有两次存储,包括:
1. 全量消息存储:如图中第 4 步,将消息存储到 DB 中持久化。该消息主要用于用户主动拉和系统展现 App 的首屏信息。全量消息会永久保留。
2. 同步协议存储:如图第 5 步,将消息存储到 Tablestore 中,然后读取合并推送到用户。同步协议存储的数据只会保留若干天。
而在架构升级后,全量消息存储去掉了对 DB 的依赖,把数据全部写入 Tablestore 中,改造之后 IM 系统存储只依赖 Tablestore,并且带来如下收益:
1. 成本低:Tablestore 基于 LSM 存储引擎的架构在成本上比分库分表的架构有优势,数据存储打开 EC 功能,冷热分层存储、云服务的按量收费,都能使得钉钉的成本更低。按照目前计费情况,存储迁移到表格存储后,钉钉存储成本节约 60% 以上。
2. 系统弹性能力强,扩展性好:可以按需扩容任意数量机器,扩容速度快且对业务无影响,在机器准备完成(包括克隆系统)的情况下,分钟级完成扩容。在扛过集群流量高峰之后,也可以快速缩容,节约资源。
3. 零运维:Tablestore 是全托管的云服务产品,不需要用户承担任何运维工作,并且 Tablestore 也能做到运维期间业务无感知。另外,Tablestore是 schema free 的架构,用户也不必为业务需求变化带来的表结构调整而烦恼。
稳定性保障
稳定性是一切的根本,也是业务选型最大的顾虑。在复杂的分布式系统中,每一个组件都有出问题的可能,大到机房挖断光缆,小到网卡 bit 翻转,每一个被漏掉的问题都可能导致灾难性故障,甚至引发严重的舆情风险。稳定性工作的核心就是充分考虑这些可能性并尽可能的提供容错能力。作为阿里云智能的核心基础产品,Tablestore 被部署在阿里云全球所有的服务区,稳定性得到了很好的锤炼,同时在服务钉钉的过程中,又专门做了稳定性的加强,具体包括:
主备双集群容灾
为了避免单机房故障引起的服务不可访问,Tablestore 具备主备双集群容灾的能力。主备集群是两个独立的集群,数据通过后台异步的方式来复制,因此这种容灾方式并不能保证数据强一致,一般会有几秒的延迟。在主集群单机房故障时,会将所有流量切换到备集群,利用备集群来提供服务,保证了服务的可用性。
3AZ 强一致容灾
主备容灾虽然能解决单机房的容灾问题,但是在主集群非预期宕机情况下不能做到数据强一致,切换过程需要和业务配合(容忍一段时间内数据不一致,后续配合补数据等)等缺点。3AZ(Available Zone)的容灾模式就能解决这些问题。
3AZ 的容灾部署方式是将一套系统部署在 3 个物理机房之上,写数据的时候,盘古会将数据的 3 个副本均匀分布在 3 个机房,保证任何一个机房故障不可用,另外两个机房都有全部数据,从而做到不丢数据。Tablestore 每次写数据返回成功后,3 个副本都已经在 3 个机房落盘。所以 3AZ 的架构是保证数据强一致的。当其中一个机房故障时,Tablestore 就会将分区都调度到另外 2 个机房继续对外提供服务。3AZ 的部署方式相比主备集群有如下优势:
1. 成本低,底层盘古的 3 份副本均匀分布在 3 个机房,而主备集群需要多一份数据复制;后续还可以采用 EC 继续降低成本。
2. 机房间数据强一致(RPO = 0)。
3. 切换更顺滑,由于数据是强一致,所以业务不需要在切换期间做额外的处理逻辑。
负载均衡系统
在表格存储 Tablestore 内部,为了使表有高扩展能力,表的存储在逻辑上分为很多分区,服务会根据分区键范围对表进行分区(range 分区),并将这些分区调度到不同的机器上对外服务。为了让整个系统的吞吐和性能达到最优,应该让系统内各个机器负载均衡,也就是需要分布在各个机器上的访问量和数据量实现负载均衡。
但实际上,在系统运行过程中,常常会出现由于设计问题或者业务问题带来数据访问和分布不均衡的问题,常见的如数据倾斜、局部热点分区等。这里面上有两个大的问题需要解决:1. 如何自动发现;2. 发现后如何自动处理。这里强调自动主要是因为表格存储 Tablestore 作为一个多租户系统,上面运行的实例、表数量巨大,如果全靠人来反馈和处理,不仅成本巨大,实际也没有可操作性。表格存储 Tablestore 为了解决这个问题,设计了自己的负载均衡系统,其中要点:1. 收集分区级别详细的信息统计,便于后续进行分析适用;2. 基于访问量的分裂点计算,也就是发现热点;3. 多维度精细的分组功能,主要是为了管理分区的分布。在此基础之上,负载均衡系统还开发了数十种策略分别来解决各种类型的热点问题。
这套负载均衡系统能够自动的发现并处理 99% 的集群中常出现的热点问题,也提供了分钟级自动化处理问题,分钟级白屏化定位以及处理问题的能力,目前已经全域运行。
完善的流控体系
Tablestore 在流控方面提供了一整套完善的流控体系,大致如下:
1. 实例和表级别的全局流控:主要解决业务流量超过期望,防止打满集群资源。如下图,左侧是流控架构,右侧是流控模型,可以从实例以及表级别分别控制访问流量。如果实例级的流量超限的话,那么该实例的所有请求都会受到影响;也在表级别可以单独设置表的流量上限,避免单表的流量超限对其他表产生影响。依赖这套系统,Tablestore 能够精确的对指定实例、表、操作进行定向流控,提供多维度的个性化控制手段,保障服务的 SLA。在疫情期间,钉钉多次出现流量打满集群的情况,全局流控就能比较好地解决该问题。
2. 分区主动流控:Tablestore 按照 range 对数据进行分区,在业务数据有倾斜的时候容易发生分区热点读写。分区主动流控主要解决单分区热点问题,防止单分区热点影响其他分区。在进程中会统计每个分区的访问信息,包括但不限于:访问的流量、行数、cell 数等。
在上述流控体系的保障下,做到了多级的防御,保障了服务的稳定。
极致性能优化
Tablestore 是典型的存储计算分离架构,其依赖组件多,请求链路复杂,因此性能优化不能只局限在某一个模块,需要有一个全局视角,对请求链路上的各个组件协同优化,才能获得比较好的全链路性能优化效果。
在钉钉场景中,经过一系列优化,在相同 SLA 以及同等硬件资源情况下,读写吞吐提升 3 倍以上,读写延迟下降 85%。读写性能的提升,也节省了更多机器资源,降低了业务成本。下面介绍一下主要的性能工作。
存储/网络优化
在 Tablestore 的核心数据链路上,包含 3 个主要的系统组件:
1. Tablestore Proxy:作为用户请求的统一接入层,做请求鉴权、合法性检验、转发;当所有前置的检查操作完成后,会将请求转发到 Table Engine 层。
2. Table Engine:表格存储引擎以 LSM tree 为处理模型,提供分布式表格能力。
3. 盘古:分布式文件系统,用来承接表格存储引擎在数据处理过程中的数据持久化工作。
在请求链路中,第一跳网络(Tablestore Proxy 到 Table Engine)和第二跳网络(Table Engine 到盘古)在原来的架构中,使用的是基于 kernel tcp 网络框架,会有多次的数据拷贝以及 sys cpu 的消耗。因此在存储网络优化上,将第一跳使用 Luna(新一代的用户态 RPC 框架)作为数据传输,降低 kernel 的数据拷贝以及 sys 的 cpu 消耗;在数据持久化的第二跳中,使用阿里云分布式云存储平台——盘古提供的 RDMA 网络库,进一步优化数据传输延迟。
盘古作为存储底座,新一代的盘古 2.0,面向新一代网络和存储软硬件进行架构设计和工程优化,释放软硬件技术发展的红利,在数据存储链路上,获得极大的性能提升,读写延迟进入 100 微秒级时代。基于存储底座的架构优化,Table Engine 也升级了数据存储通路,接入盘古 2.0,打通极致的 IO 通路。
迭代器优化
Tablestore 的表格模型支持非常丰富数据存储语义,包括:
1. 数据列多版本
2. 列的 schema-free,宽表模型
3. 灵活的删除语义:如行删除,列多版本删除,指定列版本删除
4. TTL 数据过期
每一种策略在实现的过程中,为了快速迭代,都以火山模型方式实现迭代器体系(如下图左半部)。这个方式带来的一个问题就是随着策略的复杂,迭代层次越来越深(即使不用到策略,也会被加到这个体系中);这个过程的动态多态造成的函数调用开销大,并且从研发的角度看,很难形成统一视角,为后续的优化也带来了阻碍。
为了优化数据迭代性能,将每种策略需要的上下文信息组装成一个策略类,以一个全局的视角将策略相关的迭代器拍平,进而降低嵌套的迭代器层次在读取链路上的性能损耗;在多路归并上,使用列存提供的多列 PK 跳转能力,降低多列归并排序时的比较开销,提升数据读取性能。
锁优化
除了存储格式上的改进之外,数据在内存处理过程中,面临的一个比较大的问题就是锁竞争造成的性能损失。而在系统各个模块中无处不在,除了显式的用于同步或并发临界区的锁外,也有容易忽略的原子操作的乐观锁性能问题(如:全局的 metric 计数器,CAS 指令冲突等),另外像小对象的内存分配,也可能造成底层内存分配器的锁冲突严重。在优化锁结构上,主要在以下方面做了优化:
1. 将隐式事务中,互斥锁转换成读写锁;只有显示需要事务保证的请求,才通过写锁做串行化,对于常规路径的无事务需求的请求,通过最大程度的读锁进行并行化。
2. 在读路径上,cache 的 lru 功能也引入了锁做临界区保护,在高并发读取时,lru 的策略很容易造成锁竞争,因此实现了一个无锁的 lru cache 策略,提升读性能。
基于实践的功能创新
作为阿里自研 NoSQL 产品,Tablestore 在和钉钉合作过程中,充分发挥了自研的优势,结合钉钉业务开发了多个新功能,进一步减少了 CPU 消耗,并且实现性能极致优化和业务层的无感知升级。
PK 自增+ 1 功能
钉钉同步协议使用了 Tablestore 的 PK 自增功能,PK 自增的功能是在用户成功写入一行数据后返回一个自增 ID,首先简单介绍下钉钉利用自增 ID 实现消息同步推送的场景:
1. 用户发送消息,应用服务器收到消息后,将消息写入 Tablestore 中,写入成功后,返回消息的自增 ID。
2. 应用服务器,根据上次已经推送的消息 ID,查询两次 ID 之间的所有消息。然后将查询到的消息推送给用户。
在上述场景中,有一种特殊的场景,就是该用户只有一条未推送消息,如果保障返回消息的 ID 在保证自增的同时,还能保证自增+1,那么应用服务器再写入消息后,如果发现返回的 ID 和上次推送的 ID 只相差 1,那么就不需要从 Tablestore 中查询,直接将该消息推送给用户即可。
PK 自增+1 功能上线后,减少了 40% 以上的应用端读,减少了 Tablestore 服务器和业务自身服务器的 CPU 消耗,进一步节约了钉钉业务成本。
局部索引
在此之前,Tablestore 已经具备了异步的全局二级索引功能。但是在钉钉场景下,绝大部分查询都是查询某个用户之下的数据,即索引只需在某个用户自己的数据下生效即可。为此,我们开发了强一致的局部索引功能,并且支持存量数据的动态索引构建。
应用场景
如下图中的场景,用户可以直接找到所有@自己的消息。
对应到业务侧,会有一张用户消息表存储该用户的所有消息,然后在这张表上,建立一张最近@我的消息的局部索引表,那么直接扫描该局部索引表,便可以得到 AT 该用户的所有消息,两张表结构分别如下图:
消息主表
消息局部索引表
索引增量构建
对于增量索引构建,通过考察整个链路上的各个模块,我们做了比较极致的性能优化,使一行索引的构建时间可以做到 10us 左右:
1. 通过分析表的 Schema,与当前用户写入的数据进行对比,来节省掉不必要的写前读。
2. 通过记录数据更新之前的原始值到日志,成倍的降低了索引构建成功后写日志的 IO 数据量。
3. 通过批量的索引构建,在一次构建过程中,便计算出所有索引表的索引行。
4. 通过在进入日志之前便进行计算,充分利用了多线程并行的能力。
存量索引构建
对于存量数据的索引,在业界也是一个比较难处理的问题:局部二级索引要求强一致,即写入成功,就可以从索引表中读到,另外一方面,存量索引构建过程中,希望尽量的减少对在线写入的影响(不能停写)。
如下图所示,传统数据库没有很好的解决这个问题,在存量到增量的转换过程中,需要锁表,禁止用户的写入,造成停服,甚至 DynamoDB 直接不支持在表上进行存量局部二级索引的构建。
传统数据库的存量构建方式
Tablestore 存量索引的构建方式
Tablestore 利用自身引擎的特性,巧妙的解决了这个问题,同时进行存量数据和增量数据的索引构建,做到了存量数据构建局部二级索引完全不需要停止在线写入流量,而且存量构建结束后,整个索引构建过程即结束,业务层完全无感知。
后记
数字化转型过程不是“开着飞机换引擎”,而是“飞机加速时换引擎”。在业务快速发展过程中,如何保障存储系统乃至整个业务系统实现平稳高效的升级将是每一位 CIO 需要解答的问题。
最后,欢迎加入钉钉公开群(钉钉号:11789671),一起探讨技术,交流技术。