本文旨在分析分布式配置系统的必要性、可行性,及其关键要素,并介绍一款基于该系列分析,在微信研发体系下的实践尝试。
对很多的业务开发同学而言,对运营素材的处理不是一件轻松的事,通常需要定制化的进行数据的清理、格式的转换、工具的开发。笔者就曾过这样一段不愉快的回忆,为了导入一次性的近十种类型的配置数据,就耗去了两天的时间。如果说这段经历有何价值的话,那就是促使我思考分布式配置系统,并且在工作中实践,使自己避免再次陷入如此糟糕的过程中。
本文正是旨在分析分布式配置系统的必要性、可行性,及其关键约束,并介绍一款基于该系列分析,在微信研发体系下的实践尝试。
我们清楚软件建模的本质是对现实世界(人、事、物及规则)的映射,映射的产出物即包括编程系统和配置。配置为我们提供了动态修改程序运行时行为的能力,即常说的“系统运行时飞行姿态的动态调整”,究其根源则是“我们人类无法掌控和预知一切,映射到软件领域上,我们总是需要对系统的某些功能特性预留出一些控制的线头,以便我们在未来需要的时候,可以人为的拨弄这些线头从而控制系统的行为特征。”
因此,本文所指的配置特指内部运营人员产生的数据(广义的系统运营人员,包括产品、运营、研发等),并且作为输入参数而作用于编程系统(包括实时系统、批跑程序以及数据任务等)。
归纳而言,配置通常包含如下三种:
a. 环境配置,定义了应用程序运行的环境相关参数,如 IP、Port 等;
b. 应用配置,定义了应用程序自身相关的参数或者信息安全控制等,如初始内存分配大小、数据库连接池大小、日志级别、账号密码等;(密码、证书这类东西肯定不要放在配置系统中,而应当走统一加解密服务)
c. 业务配置,定义了应用程序所执行的业务行为数据,比如最常见的功能开关,参与活动的商户名单等。
配置最基本的数据单元是key=value(即配置项),比如功能开关通常就是最简单的类型,用 boolean 型值来影响程序执行链路(不考虑灰度的情况)。然而只有 key-value 类型是不足的,比如 DB 的连接配置就包含了 ip、port、username、password 等字段,在 ini 文件的实现中即是不同配置项来组成,它们在逻辑上是属于同一个配置对象,因此基于面向对象的设计思路,key=object才是更通用的配置模型,在物理实现中可以为 json 或者 xml,或者 protobuf message。
object 类型的数据即可以是平坦的,也可以是多层次(嵌套)的。在实际的业务应用中,平坦类型的数据有其特殊性,即其通常条目较多,最典型的数据是白名单,可能多达上万条。线下,内部运营人员通过excel进行这类数据的管理,如果我们只是粗暴的将其打包成一个对象,那么过大的数据可能会导致系统效率的下降(不是配置的写入效率下降,就是配置读出效率下降),因此我们会使用array of plain object来表达,即key=table类型的数据。
相别于产品用户产生的数据,配置系统的数据流是单向的,离线系统与实时系统结合而读写分离(异步写、实时读)的。最终我们要搭建的分布式配置系统,它的系统设计,也必然是建立在这类访问模型上的。
显然,内部运营人员作为生产者,所有的配置肯定都是文本类型的(Readable),并且数据量少(相对于用户、系统等生产数据而言),对存储空间需求少,更新频次低。可以这么理解,在整个配置系统架构中,输入方就如同键盘相对于 CPU 而言是超慢速设备,他们对系统的易用性、易操作性、安全性要求更高。
我们思考下用户画像系统,它部分满足配置系统的访问模型,即数据流是单向的,离线系统负责写入画像数据,而实时系统读数据。但是首先它的数据生产者通常是离线任务,而非运营人员;再次,它涉及到的数据量是巨大的,通常需要定制的存储引擎。配置系统与之相比,不可同日而语。
相较而言,配置系统的消费者则是高频的读访问,对系统的吞吐量、延时、网络流量、可用性、一致性、请求单调性都有更高的要求。后续我们逐一展开深入的思考。
配置系统的设计应当充分考虑到上述的数据模型、访问模型以及系统约束。(比较奇怪的是,笔者在查阅相关配置系统实现时,鲜少看到有针对一致性、请求单调性的讨论。这也是促使笔者撰写本文的原因)
正因为配置可以轻易的调整系统运行期行为,因此配置的安全性至关重要。实现安全的必要条件是:让正确的人,以正确的方式,在正确的时机,发布正确的配置。因此,配置系统不但要支持灰度发布的基本能力,还要在权限管理、权限粒度管理、配置变更审核、审计、历史版本等方面都要加强建设。
在单机系统时代,我们基本上都是使用配置文件来存储配置数据(比如 ini 文件、xml 文件等)。配置文件易于理解、便于实现、可用性高,因此进入分布式集群时代,仍在广泛使用。
然而配置文件存在诸多的缺点,包括:
易用性差,主要体现为表达的数据类型单一,比如 ini 智能管理配置项,即 key=value 类型数据;而如果使用 xml 文件来管理 key=table 类型数据,那么文件内容的初始化效率低下,容易出错,难以维护;可操作性差,配置文件基本只能由开发来进行修改并且发布,产品、运营的常规业务素材变更工作就不得不卷入开发执行,对业务的流程效率有严重的影响;正确性、安全性难以保障,正因为配置文件的易于实现,导致很多团队疏忽了运营系统的建设,研发人员随意修改、恶意修改配置文件的情况无法杜绝,细粒度的权限管理、操作的审核、审计无从谈起;发布效率低下,配置文件是单机部署的,在集群规模较大的情况下,配置文件的任意变更都需要经过漫长的灰度发布过程发布到全网,如果配置文件是静态加载的,还需要重启二进制,需要消耗研发、运维人员较多的精力;文件一致性难以保障,在发布配置变更的过程中,如果集群中出现宕机情况,会导致不同机器间的配置出现差异,而没有自动校正的能力,依赖于人员或者运维系统的支持,从而导致业务进入未定义的行为。
如果说易用性、可操作性、正确性、安全性可以通过搭建运营系统来进行改进,而发布效率低下、文件一致性难以保障则是单机配置文件的致命弱点,究其本质,是因为单机配置文件系统是被动的、离散的接受外界的变更,而没有主动的能力。
由此,出现了集中式的配置文件系统,针对性的解决了上述的问题,开发人员将配置文件存储到独立的第三方服务(典型的由 ZooKeeper 进行管理,也有部分团队自行实现微服务管理),然后由 agent 周期性的将配置拉取到本地进行缓存(拉),或者通过事件的订阅通知能力来将变更发布到相应集群(推)。
集中式配置文件系统针对性的解决了发布变更效率问题以及配置文件一致性保障问题。然而在笔者所知的应用案例中,仍然存在如下的问题亟需解决:
一致性粒度粗,集中式配置文件只能确保分布式集群达到最终一致(时间取决于拉、推的频率及速率),却无法保证任一时刻,对任一配置,所有进程、线程、协程看到相同的数据,而这通常会导致出现不预期的业务失败;无法保证请求单调性,在一次业务请求中,我们希望用户看到的配置内容是静态的,如果中间发生变更,可能带来业务失败,严重的导致用户数据状态错乱;而基于集中式配置文件系统的配置通常是动态加载的,配置的变更可能随时的反应到实时系统中,导致一次业务请求先后看到不同的数据状态;安全性仍无法彻底保障,虽然集中式配置文件的修改可以控制权限,但是在消费者机器上,开发者仍然可以手动的修改本地配置文件 cache 来影响程序的运行行为;无法支持灰度能力,配置文件变更的下发是全量的,如果要支持灰度发布的能力,就需要卷入业务方自行实现;
配置文件系统,无论是单机配置文件,还是集中式配置文件,存在的问题,归根结底,是由于配置文件这个载体以及集中式配置文件系统的管道定位决定的,从而导致进行精细化管理的成本高:
配置文件的可视可读能力对生产者而言是重要的,但对消费者却是无关紧要的,因此全链路都由配置文件作为载体反而可能导致加载效率低下(比如应对千万级黑白名单,或者业务方实时请求链路动态加载);配置文件难以安全、便利管理元信息,为了实现一致性、单调性、安全性,配置需要一些元数据信息管理(下文展开详述),但是配置文件系统没有这种能力,除非业务方使用高成本自行实现;配置文件的数目与配置的数量息息相关,随着时间的发展,配置文件数目膨胀,带来新的运营问题;集中式配置文件系统通常只把自己定位成管道(据笔者所知),即不理解也不维护配置文件的内容,agent 功能单一,业务消费方不与系统直接交互,而是只看到配置文件,虽然松耦合可以提高可用性,但也让业务方仍然投入不少的开发成本来处理配置文件。
配置文件只是配置的物理载体,上述缺点并非无法克服,只是在基于配置文件的配置系统下,实现上述能力的成本高,需要更多的使用约束,以及外围配套。
对结构复杂、类型较多的配置,业务研发同学通常也不会直接使用配置文件来承载,而是使用数据库(关系型或非关系型)库表来存储配置,然后再编写工具进行数据的导入。这种存储方案克服了配置文件的部分问题,对配置有更精细化的管理。但是也存在明显的不足,即高度的定制化,不可复用,重复开发高。因此,我们需要对此进行完善,将配置的存储、读、写、管理等过程提炼共性,通用化、平台化。
既然配置文件难以精细化管理,且具备易侵入的物理实体(本地文件),我们需要新的数据结构来承载配置。前文我们讨论过,配置有两种数据模型,分别是key=object以及key=table。对使用者而言,配置必须是可视、可读、易管理的。为了达成这目的,我们只需在内部运营人员与配置系统核心之间搭一套设计良好的运营系统即可。那么在后端呢?对消费者而言,最注重传输、计算的效率,同时为了与微服务框架的对齐,protobuf message无疑是最佳的形式。
然而 protobuf 无法自解释,在没有 message 定义的情况下,我们即没办法将文本性的配置转换成 pb 二进制流,也没办法反序列化。因此必须将古玩交易业务的 message 定义上提到运营系统,然而 protobuf 却对可视化编辑不太友好。因此一个可行的思路是基于JSON 数据进行配置的定义、可视化操作、传输及存储。只有到达业务侧才进行数据类型的转换。
搭建一套配置运营系统,让之成为运营人员管理配置的唯一入口,轻松就可以得到很高的回报。我们可以基于运营系统进行各种配置安全加固,如配置的变更必须具备相应的权限,而且只有通过审核才能应用到系统,所有的操作都要有审计的能力,配置的历史版本快速可查等。
同时灰度、回退等能力也需要基于运营系统进行操作。
上文提及,集中式配置文件系统的管道定位,agent 只负责定期的拉取配置然后缓存到本地的文件系统。业务系统与配置系统松耦合。我们认为配置文件仍然具有较高的开发成本,对业务方而言,最佳的开发形式应当是:
int GetConfig(const std::string& key, ::google::protobuf::Message& msg);
而不需要再去理解文件内容、形式。那么我们就有必要为业务方提供一套配置系统的 SDK,将配置系统的细节、数据结构等信息都屏蔽起来,让业务只看到配置的 Protobuf Message 对象。
在 SDK 的基础上,消费者只需轻度介入(业务插件,见下),我们就可以完成协议转换、配置缓存、进程,线程,协程快速最终一致、请求单调、灰度发布的能力。
配置系统 SDK 是精细化管理的基础,我们可以通过维护配置本身内容之外的配置元数据信息来完成上述能力。
异步化是配置 SDK 的关键。很多本地缓存的更新是周期性的由实时链路请求负责,易于实现,但效率上存在问题,尤其考虑到我们还需要对配置进行配置业务逻辑的处理。因此,最佳方案应当是通过异步过程来进行配置的加载、初始化及其它逻辑处理。
异步带来的问题是异步过程与实时请求的并发问题,即异步过程在进行配置变更过程中,应如何处理实时链路的读请求,这是一个工程问题,我们会另文讨论,一个可行的思路是多版本及引用计数技术。
异步为我们提供的另外一个好处是,业务可以在配置生效的时候进行一些初始化动作,比如进行进行配置正确性校验,以及搭建业务适合的数据结构。比如业务白名单在 pb 中只是一个数组,如果业务进行命中查找,代价比较高。业务最期望的方式肯定还是使用 map 来存储。因此配置 SDK 异步化,就为业务插件能力提供了基础。
我们更倾向于配置 SDK 主动拉取配置的更新。推与拉的辩证在于效率和可用性。推比较高效,不存在无用的网络消耗。但是推又引入了新的系统依赖(即事件中心)。如无必要,勿增实体,基于这样的思想,我们倾向于由SDK 周期性主动拉取。至于效率,完全可以通过各种工程的手段加以优化,达到可以接受的程度。
当然这也取决于系统规模,如果我们要讨论的是公司机的配置系统,而不是部分中心级,那么我们也会认真的思考推或者推拉结合的模式。
无论是单机配置文件系统,还是集中配置文件系统,都存在严重的不一致问题。对一次配置变更,基本上都需要很长的时间才能达到最终一致(即所有并发看到相同的数据状态)。
一个可行的思路是多版本以及定时生效。配置只有在未来的某个时间(该时间内 SDK 已经拉到了最新数据)才对外可见。至于如何确保所有 SDK 都拉到了数据,这涉及到可用性的问题,我们另文讨论。
定时生效没办法解决请求单调性的问题。请求单调性是指,实时服务处理一次请求,在请求的调用栈过程中,读到的配置内容必须是静态、没有变动的,即使中间有待生效数据变成了生效数据。一个思路是我们可以通过线程私有变量(协程私有变量)缓存配置版本即可。
在配置 SDK 多版本能力的基础上,实现灰度发布的能力也是轻而易举的。灰度发布的能力,不过就是选择生效配置版本的能力,如果本机、本角色、本请求业务 key(如用户、商户、订单)等命中灰度范围,则使用新版本,否则使用原版本。
效率提升包括降低网络传输数据量、降低配置存储服务的压力,这些都是具体的工程手段,我们不在本理论篇内讨论。
分布式系统的可用性提升是老生常谈的话题,为了聚焦于配置系统独特的能力,我们本篇不专门进行讨论。
(However,尽量减少系统中的单点,是一个重要的原则。在前节”推与拉“中也有涉及。同时为了业务的可用性,第三方配置系统的运营能力、故障主动发现能力、故障通知能力、再现及定位能力也非常重要。也这是重复造轮子的一个不得已的重要原因,很多团队软件可能做的不错,但服务能力(主要指运营能力)却有点差强人意。)
联系客服