一、项目背景
这件事情发生在几年前,当时我在一家初创的电子商务公司就职,主要负责领导两支团队开发几项核心后台功能。后台的作用是管理在前端当中向全球用户开放的信息,这些信息又分别由不同的团队维护。虽然这家公司历史不长,但已经在全球市场上建立起影响力,坐拥数十万用户群体。
其中一支团队开发了支持大部分后台流程和工具的主要后台产品目录,存放着库存、产品信息管理、订单履行流程等大量内容。这个组件相当关键,大多数后台服务、应用程序和业务流程都会以某种方式进行访问。具体情况可以参考下图:
图一:非规范化读取模型的简化架构示意
该平台采用的是微服务架构,其中产品目录属于读取模型,包含由多个不同领域事件流建立而成的非规范化信息,再由其他微服务加以管理。产品目录本身由一个 ElasticSearch 数据库支持,其中容纳共 1700 万种产品,具体涉及产品元数据、库存、生产信息、可用性、定价等,而且全部都向 REST API 开放。我们之所以使用 ElasticSearch,主要是因为需要配合大量不同种类的过滤器(共有 50 多种不同过滤器,其中一些还带有文本搜索功能)。
二、再谈 ElasticSearch
正常来讲,没人能直接向数据库发起写入(我们在不同用例中使用到了多种技术,包括 SQLServer、MongoDB 和 Cassandra 等),但 ElasticSearch 却是个例外。毕竟在传统上,ElasticSearch 应该是由工程团队,而非基础设施或 DBA 团队进行管理。与其他数据库技术不同,ElasticSearch 是通过 REST 接口访问的。通常,URL 具有以下格式(当时我们使用的是 ElasticSearch 版本 5):{cluster_endpoint}/{index_name}/{type}/{document_id}(例如: elastic.com/productIndex/product/152474145)这种类型在后续版本中被删除了。
其中任何类型的操作都是通过 HTTP 调用或者 SQL 脚本完成的。就是说在 ElasticSearch 当中,我们肯定要用到 HTTP 请求。比如说根据 REST 指南,如果用户拥有一套产品目录索引(ElasticSearch 中的索引基本相当于 SQL 表)并想获取特定产品,则需要执行 GET elastic.com/productIndex/product/152474145。更新的时候,需要使用 PUT 或 PATCH 操作操作这个端点,删除的时候用 DELETE,创建的时候则是用 POST 或 PUT。另外,这些操作还可以指向 URL 中的不同部分,比如对 elastic.com/productIndex/product 执行 GET 可以获取类型信息,创建、删除或者更新等操作也是同理。如果指向的是 elastic.com/productIndex,则代表获取索引信息、更新、删除或创建索引。
三、事件回溯
那是一个普通的礼拜五,一整天大家都在不停地开会,反正上班的日常就那个样子。为了处理临时任务,比如帮助同事解决问题或者根据团队申请帮他们完成无权进行的操作,我抓住了会议之间的一点点小闲暇。这时候,我看到一条请求希望通过 API 中本不可用的过滤器导出一些数据。这操作挺少见的,但考虑到对方团队很着急、理由又充分,我们还是决定出手相助。
于是趁着下场会议还有 15 分钟,我迅速连上另一位高级管理员,想要快速访问实时环境并执行查询。由于对 ElasticSearch 的直接访问在本质上就是对接 REST API,所以我们习惯性地使用 Postman 来执行请求。
这位同事通过远程屏幕共享向我开放了操作平台。其实我的工作习惯还好,一般会对实时操作先进行一番代码审查。所以我想先测试一下连接,确保自己拿到的 URL 正确无误。于是我复制了实时端点和索引名称(类似于我们前文讨论过的 cluster_endpoint/index_name),并提交了一条 GET 请求。如果大家熟悉 Postman 界面,应该会记得在下拉列表中选择 HTTP 操作的过程:
图二:在 Postman 界面中选择 HTTP 操作
很遗憾,我在提交了请求之后,才注意到自己把操作错选成了 DELETE,而不是 GET。操作的结果根本不是检索索引信息,而是直接将其删除。
这条请求要花几秒钟才会确认,所以我立刻按下了取消按钮。取消操作立即提示成功,我的心里又涌起一丝希望,天真地认为事情已经过去、刚刚那些都是幻觉。
图三:Postman 界面似乎可以取消尚未完成的请求
但很遗憾,知道我要取消的就只有 Postman 的客户端;实际操作仍然一路狂奔,抵达了 ElasticSearch 服务器端。我试着用不加过滤条件的常规搜索确认了索引总数,而期待中的 1700 万结果并没有出现,查询返回的结果只有几百条(我们的服务每秒大约发生 70 个事件,剩下的这几百条应该是删除同时发生的产品创建 / 编辑操作)。
情况就是这么个情况,我不小心把产品目录里 1700 万条产品记录、来自整个平台数十项微服务的信息还有自己的职业声誉,全都搞砸了……
四、事情仍有转机
跟老板通话之后,我们很快组织起作战指挥室,处理各个服务区上报的问题。由于这套系统的本质就是个读取模型,而非任何特定信息的真实来源,所以我们“只需要”从其他服务那边获取信息就行。
所以摆在面前的选项就只有:
ElasticSearch 无法在发生重大变更时随之调整 schema,它的基本策略还是将所有信息重新导入新的索引当中。为此,我们设计了一款组件,能够同步 REST API 以从其他微服务处获取数据,重新构建每款产品。在它的帮助下,我们能够解决上游服务发生的错误,应对突发事件引起的一致性冲突。但是,获取全部 1700 万种产品的所有数据大概要花六天时间。管不了那么多了,我们决定马上跑起来。
图四:Catalog Updater 架构——目录重建组件
另外一个选择就是使用事件流。大多数服务都能在必要时重新发布事件,某些关键区域还具备数据重播功能,这些数据可以跟正常使用中的变更顺畅合流、为用户服务。
而最大的希望也在于这里。在此之前的几天,我们刚刚在 schema 当中做了一次重大变更,所以需要创建新的索引版本来重新索引全部信息。因为需要同时在新旧两个版本中接纳新近变更,所以重新索引过程相当漫长。我们此前已经对旧索引做好了分析,而需要进行重大变更的新功能其实不怎么重要,就是说现在我们手头已经有了一套完全可以接受的旧索引版本。虽然数据还是延迟了几天,但毕竟要比空空如也好得多。所以在综合讨论了几种方案之后,我们最终成功解决了这场突发危机。
五、经验教训
1、备份与重建速度
备份的必要性已经无需多言。我们的大多数数据库都有备份,但却没有给 ElasticSearch 数据库做好相应的保护。另外,该数据库本身属于读取模型,所以根据定义并不作为任何真实来源。理论上,读取模型就不该需要备份,因为可以快速重建,确保在发生重大事件时也不会造成太过严重的影响。由于读取模型所容纳的基本都是从其他来源推断出的信息,所以很难确定到底值不值得做定期备份。但在实践中,我们发现要在不影响用户体验的同时重建模型,绝对是个令人头痛的大麻烦。如果是只有几百或几千条记录的小模型还好,但像我们这种覆盖几十个不同来源、承载上千万条信息的读取模型就完全是另一码事了。
我们最终决定把两种选项结合起来,成功将重建流程从六天缩短到了几个小时。但由于这套数据库太过重要,所以这几个小时的宕机还是会给用户造成重大影响,特别是在销售季等特定活动期间。我们也可以想办法进一步缩短重建时长,但具体方案感觉有点过度设计,而且会产生大量额外的基础设施成本。所以我们决定只在风险较高的时段内进行备份,例如在促销季活动或其他关键业务执行期间。
2、横向扩展根本指望不上
大家常说,选择微服务的一大核心优势就是良好的横向扩展能力。但从图四能够看到,这种扩展只能依赖于同步 API,所以横向扩展可以说根本指望不上。负责重建读取模型的组件需要整整六天才能执行完成,虽然理论上可以通过横向扩展把时间大大缩短,但问题是它仍然要靠 REST API 来检索信息。它通过 REST 请求从其他各项微服务处请求数据,构建起非规范化视图和持久化状态。所以直接横向扩展会触发大量指向其他服务的请求,而那些服务并没有做好处理高强度额外负载的准备,所以可能还需要再各自扩展。这必然引发连锁反应,最终令整个平台走向崩溃的边缘。另外,其中大多数微服务还高度依赖数据库,所以微服务的扩展又会引发相应数据库的扩展。
我们确实进行了扩展,只是把扩展量控制在很保守的水平。而即使是这样,其他服务也有点招架不住,出现了可以感知到的影响。现在来看,整个微服务架构并不像我们想象中那样高度解耦,反而很像是当初的单体架构。更要命的是,它没有分布式的优势、却得了分布式的病,管理起来异常麻烦。
所以在重建组件时,我们选择了纯事件流的方法。这种方式虽然也有问题,但至少能让系统真正具有解耦性。就是说组件的扩展只影响对应资源,这才是真正具备横向扩展能力的设计。这里还有另一个设计难题,就是事件应该大一些还是小一些。至少对读取模型来说,事件还是越大越好。我们还用到一项有趣的策略,就是使用了带有 Kafka 压缩主题的文档,借此大大提升速度和扩展能力。这种方法能把重建策略从批处理转化成流处理。与通过 HTTP 请求获取数据不同,事件流上的数据有着更低的获取难度和更快的获取速度,原因就是它的网络延迟更低,而且不用靠中间服务从数据库内获取数据,一切就在事件流上。另外,事件流的真解耦性也让整个过程实现了横向扩展,再不用担心对其他服务产生意外影响。
3、基于角色的访问机制
事件发生之后,我们开始全力推行基于角色的访问控制。之前我们使用的是旧版 ElasticSearch,它只提供非常基础的用户身份验证,而更靠谱的 XPack 在这个版本里是要收费的。不过在后续更新中,XPack 也被加入了免费许可证套餐,真正是好用又不贵了。
所以,我们迁移到了 ElasticSearch 版本 7 并创建了不同的读写角色。最终,只有应用程序能够定期直接写入数据库,用户最多只能直接读取。
4、责任不在人,而在流程
每当出现问题,我总会向技术团队强调,最大的责任不在于人,而在于糟糕的流程。我们需要分析流程中的哪个环节出了问题并找到解决办法,避免任何人——无论是刚刚入职的新员工,还是经验丰富的老伙伴——再犯同样的错误。
我一直秉持这样的管理思路,也时时处处用这样的方式管理工作、处理事件。虽然这事已经过去几年了,虽然我还是会偶尔想起这一切并尴尬地苦笑,但这个契机确实也给我们带来了更合理的操作流程。我们调整了实时数据的访问方式,消除了直接进行写入操作的权限。甚至对于读取访问,我们也开始采取更审慎的态度,毕竟恶意查询很可能对 ElasticSearch 资源产生的可怕的影响,某些极端复杂的查询(例如高深度分页)甚至会引发集群崩溃(例如超过客户端节点的内存上限)。我想再强调一句,这不是要剥夺团队的自主权,而是帮助大家尽量少犯错。
临时请求会被提交给专管这类请求的实时工程团队,所以正常来讲大家根本不需要直接访问数据库。手动重复任务已经被整合进对应服务的功能当中,并通过应用层加以适当验证,这就消除了出现意外删除或大量查询的可能性。总体来讲,我们的调整就是要确保人们能够用适当的工具完成工作、响应业务请求,而且始终保持安全稳定。
六、写在最后
其实在闹出这事之前,我在很多文章里都读到过类似的情景,但从没想过有一天自己会成为故事的主角。那时候我的想法很简单,“我做事是讲套路的,绝对不会轻易下手。”但有时候,难以挽回的错误可能只需要一瞬间的分心、一瞬间的疏忽。这段经历教会了我要永远保持谦卑,我也大大方方把这个故事讲给每位团队成员听,让他们知道技术负责人也一样可能会犯低级错误。所以最重要的还是给自己加上点约束,避免我们“愚蠢的一面有机可乘”。
作者丨Hugo Rocha
译者丨核子可乐
来源丨公众号:AI前线(ID:ai-front)
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
dbaplus社群是围绕Database、BigData、AIOps的企业级专业社群。资深大咖、技术干货,每天精品原创文章推送,每周线上技术分享,每月线下技术沙龙,每季度Gdevops&DAMS行业大会。
关注公众号【dbaplus社群】,获取更多原创技术文章和精选工具下载