MoreRSS

site iconRay Xiong | 四火修改

熊燚,平台软件工程师,华为 - Amazon - Oracle,从漂泊南京、北京,到目前定居西雅图。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Ray Xiong | 四火的 RSS 预览

初涉 ML Workflow 系统:Kubeflow Pipelines、Flyte 和 Metaflow

2025-05-19 07:18:12

入职 Coupang 两个月了,第一个月主要上手和开发 BOS(Business Operating System)系统,第二个月开始调研选型 ML Workflow 平台。前者目前来说相对比较简单,后者对我来说是一个新坑,也比较有意思,随便写写技术上的体会。

先扯点题外话,其实这次求职有几个比较符合我预期的机会,可在思考之后,我基本上毫不犹豫就选择了 Coupang 这一家。最主要的原因,并非因为雇主,而是因为要做的事情。一个相当规模的团队,在大干一场的早期阶段,要在搭建起属于自己相当规模的 AI infra 来。

我觉得软件行业的巨大的变革,新世纪以来就三次,第一次是互联网应用的崛起,我太小没能做啥;一次是十几年前的 cloud,看着它从爆发式增长到如同水和电一样进入我们的生活,可我算是错过了它比较早期的阶段,即便相当长的时间内我在 Amazon,但是我却并不在 AWS;而这一次,当 AI 的浪潮再来的时候,我就很想行动起来,真正投身其中。程序员的一生能有几个赶这样大潮的机会呢,我不想再错过了。虽说我没有 AI 的技术背景,但我知道 ML infra 到 AI infra 却是个我可以切入的角度——从我最初接触软件开始,尤其是学习全栈技术的时期开始,我就认定,技术是相通的,这十几年来我一直在如此实践。因此在调查和思考之后,我觉得这是一个我不想错过,并且更重要的是自认为能够抓住的机会。

当然,就此打住,我目前只是这个领域的初学者,因此理解并不深入。

Why ML Workflow?

接着说正题,在这一个月之前,虽然我经历过不少关于 workflow 的团队,虽然我参与过从零写完整的 workflow 引擎,但这些都是针对于通用 workflow 而言的,我对于机器学习的工作流,也就是 ML workflow 可以说一无所知。于是在问题和需求调查的过程中,第一个关于它的问题就自然而然出现了,我们是否真的需要 ML workflow,而不是通用的 workflow 系统?

其实,这主要还是由于 ML 的生态所决定的。通用 workflow 可以完成很多的事情,但是在机器学习到 AI 的领域内,这个过程中最主要的目的就是把 raw data 给转换成经过训练和验证的 model,其中有很多部分都是有固定模式,因而自成体系的。举例来说:

  • ML workflow 关注数据处理和 ML 或者 AI model 的生命周期,但是通用的 workflow 往往关注将业务流程自动化;
  • ML workflow 需要将 artifact 管理、model registry、model insights 和 experiment tracking 等工具集成起来,但是通用的 workflow 往往是业务 application 层面的集成;
  • ML workflow 执行的 task 往往需要高 GPU 使用和高内存,这和通常我们讨论的 workflow 的 task 对于 CPU 的使用完全不同。

总之,ML workflow 更像是一个 workflow 中的重要分支,它的特异性显著,因而从架构上它有很多在我们谈论通常 workflow 的时候不太涉及的特点,并且它们具有明显的共性。

ML Workflow 的固定套路

Workflow 这样的系统,和很多 infra 系统不同的地方在于,它具有全栈的特性,需要从端到端从用户完整的 use case 去思考。回想起通用的 workflow,我们会想,用户会去怎样定义一个 Workflow,怎样运行和测试它,并且怎样部署到线上跑起来。这其中的前半部分就是 development experience,而后半部分则是 deployment experience。

首先,对于 development experience 这个角度,ML workflow 有它独特的地方,其中最主要的就是 Python SDK。

通用 workflow 我们讲定义一个新的 workflow 的时候,我们通常都需要写一个 DSL,里面定义了一大堆 task 和依赖关系,而对于做得比较好的 workflow 系统来说,可能还需要一个可视化的 drag-and-drop 界面来方便地创建 workflow。

但是对于 ML workflow 来说,它最特殊之处是对于 Python code 的无缝集成。因为 Python 之于 ML 的地位就像是 Java 之于企业架构的地位,任何一个 ML workflow 客户端首先要考虑支持的编程语言就是 Python,用户通过往大了说是 SDK,而往小了说则是简单的 Python decorators,就可以定义 task 和 workflow。比方说,一个简单的 Flyte 的 hello world:

from flytekit import task, workflow

@task
def say_hello(name: str) -> str:
    return f"Hello, {name}!"


@workflow
def hello_workflow(name: str = "World") -> str:
    return say_hello(name=name)

在 ML workflow 的世界中,这是除了 DSL 和视图化之外的第三种定义 workflow 和 task 的方式,也是必须具备的方式。

第二个,对于 deployment experience 的角度,大致上是基于 Kubernetes 从 control plane 到 data plane 固定的交互机制。

我不知道这是不是一种关于 ML workflow 的约定俗成,但是通过调研 Kubeflow Pipelines、Flyte 和 Metaflow,我发现这三种对于 control plane 到 data plane 的交互模式是出乎意料地一致。

  • KubeFlow Pipelines: client [KFP SDK] -> control plane [API Server -> K8s APIs (CRD changes) -> Workflow Controller / K8s Operator] -> data plane [K8s API -> creating Task Pods -> blob storage]
  • Flyte: client [Flyte SDK] -> control plane [Flyte Admin -> K8s APIs (CRD changes) -> Flyte Propeller / K8s Operator] -> data plane [K8s API -> creating Task Pods -> blob storage]
  • Metaflow: client [Metaflow SDK] -> control plane [Metaflow Service -> K8s APIs (CRD changes) -> Metaflow Scheduler / K8s Operator] -> data plane [K8s API -> creating Task Pods -> blob storage]

注:也有把 Operator 那一层归为 data plane 的,我觉得都说得过去。

其中 Metaflow 说的是使用 Kubernetes 集成的情况,因为它并不是非得依赖于 Kubernetes。

但大多数使用都是基于 Kubernetes 的,而且基本上都是这个套路,control plane 的 service 收到请求以后,通过创建 K8s CRD objects 的方式告知 workflow controller(scheduler)来执行 workflow,对于 task 的执行通过调用 data plane 的 K8s API 来创建 task pods 执行。

对于特殊的 task,需要交由特殊的 K8s operator 来执行,那么这个 “交由” 的过程,也是通过 K8s 这一层的 CRD change 来实现——Propeller 负责创建 CRD,而对应的 operator 负责 monitor 相应的 CRD 改变并相应地执行任务。Propeller 和 operator 二者互相并不知道对方的存在。这种方式对于保证 operator 的重用性和跨 workflow 系统的统一性简直是太棒的设计了,我们在 try out 的时候,就让 Kubeflow Pipelines 系统中的 operator,去执行 Flyte 给创建的 PTJob 和 TFJob。

关于架构,我觉得 Flyte 的这张架构图对于 components 层次的划分说得非常清楚,下面的 control plane 和 data plane 是可以有属于自己的 cluster 的,不过值得说明的是,真正最终执行的 task pods,也就是图中的最下面的 K8s Pod,也是可以放在另外的 cluster 上,由远程的 K8s API 调用触发的,这样就可以带来更多一层的灵活性:

[Update on 5/31] 后来看到这篇非常好的分享 《Flyte School: Flyte Architecture Deep Dive》,对于初步了解的工程师来说,推荐观看。下图也来自于它。

ML Workflow 的特性比较

再来比较这三个 workflow 的优劣,我并不打算列全,而是简单说说自己印象最深的几点:

  • Kubeflow Pipelines 基本上有着最大的社区,因此它相对比较成熟,有自带的基于 CRD 的 K8s-native 的集成,因此可以直接跑 TensorFlow job 和 PyTorch job 之类的;UI 功能也比较强大,可以通过 drag-and-drop 来定制 workflow,也支持 yaml 文件创建 workflow。
  • Flyte 最吸引人的是它的 Strong Typing,很多错误能够在编译期本地就能够发现(Kubeflow pipelines 和 Metaflow 都只是 hints);开发过程中,本地直接就能跑,而不需要什么 container;对于 multi-tenancy 支持得最好(比如 RBAC 和 tenant 的 Quota 机制)。
  • Metaflow 的 setup 特别简单,而且本地可以直接调试;它对于 AWS 的一些 service 直接可以集成使用,特别方便(比如 Step Functions);Kubernetes 并不是一个依赖,也可以跑在 VM 上等等。

在我把这三者全部在 EKS 上搭了一遍并使用了一圈,也仔仔细细对别了特种特性和优劣之后,我对于 Flyte 的特性比较感兴趣,我觉得它们对我们团队也比较有用。

具体来说,很多区别但最重要的是两个:一个是 strong typing,其它两个都只支持 Python 类型的 hints,就这一点上,和一些 ML engineer 也讨论过,把问题发现在本地,是非常吸引人的;再一个是 multi-tenancy,对其 Flyte 有很多原生的特性支持,在平台完成之后,我们希望把平台上 ML 的能力开放出去,因此这是很重要的一个特性。此外,我也在考虑对于一个 control plane + 多个 data plane 这种 use case 的情况,这部分的需求还比较模糊,但是 Flyte 依然是这方面支持特性相对比较多的一个。

无论最后的结论为何,我希望我们能够比较灵活地部署选中的这个 ML workflow system,比方说,在 CLI 上,我们考虑在更高维度建立出一层,用户使用同样的命令,无论下面执行的 workflow 系统是什么,都不需要改变,这样一来,等到未来如果我们需要支持第二个,应该能够比较容易地整合进去。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

关于近期求职的近况和思考

2025-02-21 05:23:32

自去年秋天裸辞之后,一直在考虑职业生涯的问题。之后加入求职大军,目前进展还算顺利,作为软件工程师的下一站也将很快确定下来。但是这一次的 career break,虽说时间不算长,却给了我莫大的启发,我也有了一些思考。

从 fullstack engineer 到 platform engineer

其实在去年年初的时候就简要叙述过这个事情。熟悉我的朋友都知道,我的职业生涯有点奇怪,从 Huawei 开始,我是一个全栈工程师(fullstack engineer),从网页设计、前端开发到后端开发都是一锅端的,当时也非常喜欢这个方向,这也是我后来在极客时间上写 《全栈工程师修炼指南》这门课的原因之一。

不过后来这个兴趣点也在慢慢迁移,在加入 Amazon 之后,我陆续经历了两个大的 data platform 团队,一个是做销量预测(demand forecasting)的,一个是为 retail 一侧计算成本和利润的。在这两个 team 中,都要和大数据打交道,和 scientists 和 analysists 一起合作,而我作为一个 engineer 的基础工作,就是把 infra 维护好,提供好用的工具让他们的问题观测和分析更简单。也是从 Amazon 开始,我开始更关注一个模糊的目标,一个可以持续建设的 platform,关注一个 solution stack,而不是具体某个 service,或者某个具体技术。

差不多六年之后,在 Oracle,我带领的 team 则是侧重于 infra 了,依然是作为 engineer,主要为 cloud 管 datacenter 的两个东西,一个是 process automation,一个是 matadata storage。在这个比较大的 team 我获得了比较大的职业生涯成长,我们 own 一个非常完整的 solution stack,也越来越确定我关注的重点,以及未来发展的方向。虽然从一定意义上来说,做的事情依然是 full stack 的,但我开始更多地称呼自己 platform engineer,而不再是 fullstack engineer 了。

之后在 2022 年加入了 Doordash,从巨头转向更加敏捷的中型互联网公司,一开始在一个偏向于 infra 的团队,做 gateway platform,我还是比较享受这一年多的时间的。当时 team 里面有一个非常有经验和见解的工程师,我从他身上学到不少。后来因为 org 调整的原因,我选择抓住机会去做了很短一段时间的产品,回头看这个决定有些鲁莽,但至少也确认了一件事情,单纯做产品并不是我最喜欢和擅长的。

对于下一站,我的几个在考虑的选项中,无疑都是偏向于 platform 和 infra 的 team,其中有两个机会我尤其感兴趣,其中一个是维护开源的高并发 library 的,还有一个是做 AI infra 的。现在我正在努力做的功课,就是把它们前前后后都了解清楚,然后做出自己的选择。

AI 将替代软件工程师吗

这是个很好的问题。只不过,这个 “将” 可以斟酌,因为它已经替代一些初级的工程师工作了。但放眼未来,它到底能替代多少工程师的工作,我不知道。现在,很显然的有两件事:

  1. 软件工程师的很多工作确实是可以替代的,它们也正在被替代,包括某些设计和编码。
  2. 软件工程师这样相对有门槛的工作都可以被 AI 替代,更何况那些门槛相对低的工作呢?

但是关于上面这第 1 点,这样的 “替代” 到底能达到多深的地步,我不知道。我隐约觉得,能被替代的工作往往是非常具体,逻辑比较确定和简单,而且不需要处理人际交流和关系的工作。以前有人觉得,AI 不能替代艺术家的工作,因为他们的工作是创造性的。可是你现在看看呢,写作、谱曲、绘画,都变得可能了,可是我并不想反驳这条观点,而是想说,这从一定的角度上来看,我们是不是可以说,艺术家们的工作,其实也并不全是创造性的呢?

而关于上面这第 2 点,有更多岗位要远比软件工程师更值得担忧,而软件工程师们,只不过是因为现在站得和 AI 更近,替代后的成本节约更多,因而更焦虑。就如同软件行业是经济的风向标一样,当工程师们开始焦虑,不久的将来整个社会都会焦虑。从好的一面看,当工业革命开始,无数人担心机器代替人类工作,但最终机器却为人类创造了更多的工作,我想这一次机器替换成了 AI,道理也一样。无论如何,不要逃避,而要尝试改变和拥抱这样的变化,因为这个趋势是不以人的意志为转移的,该来的总会来。

我觉得,总体来看,AI 将很快替代的,未必是工作,而是特定领域的技能。我觉得这句话里面,有两个重点,一个是 “技能”,一个是 “特定领域”。同一份工作,也许需要能力和技术将大不相同。对于一个需要做出复杂判断的工作,并且这个工作还需要许多不同领域视野和经验积累的,AI 相对会更难替代。

对于一些传统行业而言,那里有更多的固化、低效、不愿革新和进取的工作。我有个朋友在保险行业,做的事情就是要用科技(不仅仅是 AI)来变革,把保险公司从传统上认为人力资本巨大的企业变成一个靠软件来横向扩张的 SaaS(软件即服务)公司。趁这个 job hunting 的机会,我也去了解了一番。我觉得,这些看似红海的传统行业实则是使用软件革新的蓝海,未来会有更多的 SaaS 公司。有很多这样的传统领域,成长缓慢,或者利润率低,资本不太看得上,但是从这个角度思考,或许有大的机会。

如今的就业市场怎样

在刚离职的时候,我曾经提到过对于就业市场的理解。大致来说,就是比我 2022 年下半年那会略好,但是想要回到疫情前那种 “无比风光” 的状态是不可能了。现在回头看,在经过了一番求职的折腾后,我可以说,这种观点还是大致正确的,不过就业市场比我最初想的,还是要好不少。简单说来,我觉得近期软件工程师的机会,比 2022 年下半年要多不少。

其次,一个萝卜一个坑。我记得 2017 年那会找工作的时候,我可以先把 phone screen 搞定,然后排一堆 onsite 在同一周并行,这样的话一旦我拿到 offer,如果需要选择的话比较容易操作,因为它们的时限都比较接近。但是这次好几家公司都是过了 phone screen,然后告诉我坑已经被填了。所以之前并行的策略没有那么有效了,看到心仪的职位,不仅需要面试得好,还需要尽快完成。

再次,bar 还是很高。有时候看到很多软件工程师朋友还在谈论刷题的话题,其实刷题是必要条件没错,但是离实际需要差太远了。从分配时间的角度,还是需要更多时间分配到其它环节去。总体来说,就算两轮 ps 加上 5 轮 onsite 的话,ps 全都要 positive,onsite 全都要 positive,也许最多一轮 on boundary,否则基本就挂了;有些情况下,就算全是 positive,如果不够 strong,还是会 downlevel。所以,总体来看 bar 还是比较高的。行业发展就是这样的,软件业也不是例外,求职门槛提高,这是行业成熟的一个标志。

最后,回头看,去年的这个裸辞还是果断(或者武断)的,但是回想起来,如果再给我一次机会,我估计还是会做出同样的选择。没有什么对错,就是做出自己的选择而已。这段 break 的时间我还是比较享受的,而且除去 career 发展的目的以外,由于再在 job market 上面走一遭,起码从面试的角度来说,有了比较新鲜的认识,哪一天如果被裁员,我相信也不会过度慌乱。这也算是一个额外的收获吧。

我知道有很多朋友和我一样,近期在求职。这个过程很辛苦,也可能有磕磕绊绊,希望大家都能保持自信,或长或短的时间,找到自己理想的职业生涯下一站。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

聊一聊分布式系统中的时间

2024-10-21 08:41:56

今天聊一下时间的话题。在分布式系统中,“时间” 是一个挺有趣,但是很难处理的东西。我把自己的理解简单整理下来。

不可靠的物理时钟

首先,单一节点的物理时钟是不可靠的。

物理时钟本身就有偏差,可是除此之外,可以引起节点物理时钟不准确的原因太多了,比如 clock jump。考虑到 NTP 协议,它基于 UDP 通信,可以从权威的时钟源获取信息,进行自动的时间同步,这就可能会发生 clock jump,它就是说,时钟始终会不断进行同步,而同步回来的时间,是有可能不等于当前时间的,那么系统就会设置当前时间到这个新同步回来的时间。即便没有这个原因,考虑到数据从网络传输的延迟,处理数据的延迟等等,物理时钟是非常不可靠的。

如果一个分布式系统,多个节点想要仅仅依赖于物理时钟来完成什么操作,那么只能祈祷运气足够好了。在 《从物理时钟到逻辑时钟》这篇文中,我已经介绍了对于物理时钟不可靠的问题,我们有一个解决的办法,就是引入 Lamport 逻辑时钟,或者使用向量时钟,这里就不赘述了。

超时

分布式系统中什么样的执行结果最难处理,成功还是失败?其实都不是,最难处理的结果是超时,因为执行超时了,但是系统却并不知道它:

  • 是没执行,
  • 是执行成功了,
  • 还是执行失败了。

所谓超时,一个显然的问题是,超过多少时间才算超时?往往没有一个公式,更没有一个标准答案,我觉得《Designing Data-Intensive Applications》这本书里面对这一点总结得很好——对于超时时间的定义,其实就是一个 tradeoff,一个基于 failure detection delay 和 risk of premature timeouts 之间抉择的平衡。如果超时时间设置长了,就会减少超时判定的误杀,但是对于系统失败的识别就会延迟;反之,如果超时时间设置过短,那就会触发更多看起来是超时的 case,但是它们本身其实并没有真正超时。

通常来说,对于超时的处理,其实办法也不多。一种是放弃,一种是重试。就像消息投递,如果要保证 “至多投递一次”,那在投递超时后,就直接放弃;如果要保证 “至少投递一次”,那在投递超时后,就重试。如果要重试,那就需要引入保证幂等性的机制。

分布式事务 SAGA 对于超时的处理,其实也是遵照上面的原则,在系统内单步都成为事务的基础上,把流程视作一个状态机,无论单步操作是成功还是失败,都会根据清晰的预定义逻辑,触发相应的正向流程或者反向流程,可是唯独超时,多数情况下最有意义的事情就是重试,也只能重试,因为谁也不知道它究竟实际是成功了还是失败了。

说完操作超时,再来说一下节点超时。很多分布式系统中都会使用一种 lease(租约)的机制,比如一个集群中的 leader,作为 leader 会扮演不同的角色,但是必须要 renew 这个 lease,否则超过一定的时间,无论它给不给响应,它都会被开除出 leader 的角色,而 follower 会重新选举(或者其他方式)一个 leader。

比较难处理的是,如果这个节点本来是被 hang 住了,导致了超时,它也已经被踢下 leader 的角色,但是之后它 “活” 过来了(比如经过了一个超长时间的 GC),它还以为自己是 leader,继续去干 leader 干的事,变成了一个假 leader。这其实就是出现了脑裂,本质上是一个一致性的问题。这种情况比较难处理,因为即便有 heartbeat 不断检测,在每两个 heartbeat 的间隙,可能这种重要的变动就发生了。

要解决这种问题,需要使用 token fence 的方法,即让每次最关键的状态数据的更改,携带一个单调递增的 token,这种情况下这个假 leader 发起更改的 token,已经小于系统中最新的 token 了,接收这个数据更改的子系统应该拒掉这个请求。上面说的节点超时的情况我在《谈谈分布式锁》里面有详细说明。

计算机的两种时钟

有两种时钟是计算机普遍支持的,一种叫做 time-of-day clock,就是我们一般意义上的时钟,代表着相对于 1970 年 1 月 1 日的 epoch 时间,也就是 Java 里面 System.currentTimeMillis() 返回的。网络时间协议 NTP 就是用来同步计算机这个时间的。

不过,其实还有一种时钟,叫做 monotonic clock(单调时钟),在 Java 里面相应的接口是 System.nanoTime()。这个时钟有一个特点,就是它永不回头。对于 time-of-day clock 来说,时间是可能 “回头” 的,对于很多应用来说,时间回头是要命的。不过这个时钟给出的具体时间却是毫无意义,如果在不同的机器上调用 System.nanoTime(),会得到完全随机的结果。API 的名字是纳秒,可是这个时钟并不给出到纳秒的时间精度,它的作用是用来帮助计算间隔时间的:在同一个节点,第二次调用的时间减掉第一次调用的时间,这个结果(时间间隔)是严格递增(不回头)的。从这个意义上说,除去时间这个视角本身,这个时钟更像是一个单调的计数器。既然是单调的计数器,就可以用来帮助产生系统严格自增的 ID。

下面是 System.nanoTime() Javadoc 上面的解释:

Returns the current value of the most precise available system timer, in nanoseconds.
This method can only be used to measure elapsed time and is not related to any other notion of system or wall-clock time. The value returned represents nanoseconds since some fixed but arbitrary time (perhaps in the future, so values may be negative). This method provides nanosecond precision, but not necessarily nanosecond accuracy. No guarantees are made about how frequently values change. Differences in successive calls that span greater than approximately 292 years (263 nanoseconds) will not accurately compute elapsed time due to numerical overflow.

TrueTime

一般来说,我们都知道计算机的时钟有误差,可是这个误差是多少,差 1 毫秒还是 1 分钟,并没有任何严格保证。绝大多数接触到的时间 API 也是如此,可是,Google 数据库 Spanner 的 TrueTime API 却。它使用了 GPS 时钟和原子钟两种完全独立的机制来冗余某一个机制失败导致的时钟问题,增加 reliability。此外,它还有和 System.nanoTime() 一样的严格递增的特点。

它有三个核心 API,很有意思:

  • TT.now() 它返回 [earliest, latest] 这样一个范围,表示当前时间就在这个范围内;
  • TT.after(t) 它返回当前时间是不是肯定在 t 之后
  • TT.before(t) 它返回当前时间是不是肯定在 t 之前

有了 TrueTime,这让分布式系统中,本来无法通过物理时钟解决的问题也变得可解决了。比如对于操作冲突的问题,现在的新办法就是等一个 buffer 时间,TrueTime 认为已经前一个操作的结束时间肯定已经过去了,再来启动后一个操作。当然,这个方法的缺点是 throughput,因为整个操作周期因为 buffer 时间的关系变长了。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

几个有意思的分布式系统设计模式

2024-10-02 08:46:16

分布式系统有它特有的设计模式,无论意识到还是没有意识到,我们都会接触很多,网上这方面的材料不少,比如 《Catalog of Patterns of Distributed Systems》,还有 《Cloud Design Patterns》等等。这里简单谈谈几个我接触过的,也觉得比较有意思的模式。

LSM Tree

对于这个话题,基本上第一个在我脑海里蹦出来的就是 LSM 树(Log Structured Merge Tree)。其实,LSM 树本来只是指一种数据结构,这种数据结构对于大吞吐量的写入做了性能上的优化(比如日志写入),同时对于根据 key 的读取也有不错的性能。换言之,对于读写性能的平衡,大幅优化了写入,而小幅牺牲了读取,现在它也不再被局限于数据结构本身,而是泛化为能够提供这样特性的一种机制。

整个写入过程分为两个部分,为了追求极致的写入速度,写入方式都被设计成追加的:

  1. C0:这一层其实就是内存中写入的 buffer,它的实现可以用 RBTree,或者是 SkipList,这种树状有序的结构,它的写入和范围查询都很快。在 Bigtable 的设计中,它被称为 Memtable。
  2. C1:这一层则主要待在磁盘上了,它可以由若干个文件组成,每一个文件都是有序的数据,这样拿着一个 key 去文件查询对应的数据,根据 indexed keys 可以用二分法来查找。在 Bigtable 的设计中,它就是 SSTable。

所以,为了追求写性能,数据写入会直接插入到 C0 中,一旦 C0 达到一定大小,就会建立一个新的 C0’ 来替代旧的,而原有的 C0 会被异步持久化成 C1 中的一个新文件(其实就是做 snapshot);C1 中的文件全都是有序的,它们会不断地被异步 merge,小文件不断被合并成大文件(下图来自维基百科)。极端情况下,同一个 key 可以有若干次更新,并且更新能同时存在于 C0 和 C1 所有的文件中。

对于根据 key 的查询,需要先去 C0 中找,如果找到了最好,没找到的话需要去 C1 中找,最坏的情况下需要找每个文件。如果数据存在于多个地方,数据采用的优先级是,C0> 新的 C1 文件> 旧的 C1 文件。

对于不存在 C0 中的数据查询,为了尽量避免去每一个 C1 的文件中查询,Bigtable 会使用 bloom filter 来做第一步的存在性判断(校验用的数据全量加载在内存中),根据结果,如果这一步判断通过,这意味着数据可能存在于目标文件;如果没通过,这意味着数据肯定不存在于目标文件。

Write-Ahead Log

顺着 LSM Tree 的话题,说到 WAL。WAL 适用于解决这样一个问题:一个系统对于写请求有较苛刻的延迟或者吞吐量的要求,同时又要严格保证 durability(数据不丢)。

因此直观上,WAL 包含三步:

  1. 首先要求 append 日志,日志是持久化的,并且可以根据一致性的要求持久化到不止一个存储中
  2. 接着就是把数据更新到内存的某个数据结构中(比如上面的 LSM Tree 在内存中的结构 C0),这时候同步的请求响应就可以发回客户端了
  3. C0 的数据会异步 merge 压缩到持久性存储 C1 中(LSM Tree 部分的操作)

基本上思路就是把能延迟的操作全延迟了,如果服务端挂掉了,根据持久性存储+日志就可以完全恢复到挂掉之前的状态,因此数据不会丢。

于此,有一系列相关的 pattern,比如:

  • Low-Water Mark,低水位线:指的是这条线以前的数据全部都以常规方式持久化了(C1),因此如果节点挂掉的话,只需要根据现有的文件+低水位线以上的日志就可以完全还原挂掉之前的状态,也就是说,理论上低水位线以前的日志可以删掉了;
  • High-Water Mark,高水位线:日志是持久化的,但是这个持久化需要在多个节点上发生,这条线就指向了最新一条已经成功持久化到 “大多数” 节点上的日志(这个大多数其实就是 Majority Quorum 模式)。
  • Hinted Handoff,提示性移交,它和 WAL 有一定类似性,因此经常被放在一起比较:如果一个节点挂掉了,那么更新会跑到另外一个(随机的)节点上面去,等到挂掉的节点恢复的时候,这些数据会被重新写入那个恢复的节点,执行 replay 从而恢复一致性。

Clock Bound Request Batch

Request Batch 太常见不过了,请求可以批量发送,减少 overhead,从而减少资源(网络带宽、序列化开销等等)的消耗。通常的 batch 是根据大小或者数量来划分批次的,但是修饰词 Clock Bound 指的是,这样的分批还要依据时间,就是说,系统可以等待一段时间,这一段时间内的请求都可能打包成一个 batch,但是这样的打包还要有时间限制,到了这个时间,无论当前的 batch 有多小,都必须要发送出去了。

Kafka 客户端就有这样的一个机制,message 可以被 group,但是:

  • batch.size 这个参数指定 batch 最大有多少;
  • linger.ms 这个参数指定了最多等多久。

还有一些流处理系统也采用这种方式,比如 Spark 的 micro batching。

Singular Update Queue

Singular Update Queue 非常有用,queue 本身就是用来处理异步的事件,可以有若干个 producer 产生消息到队列里面,有若干个 consumer 来处理它们。这种场景下这个 queue 为核心的机制扮演了至少这样几个角色:

  1. 缓冲,平滑请求的波峰;
  2. 流控,保护下游系统不被负载冲垮;
  3. 校验,能写入 queue 的数据一定是符合格式的,从 queue 读取的数据也一定是符合格式的;
  4. 解耦,对于 MxN 的调用关联关系,所有的 producer 只和 queue 打交道(写入),而所有的 consumer 也之和 queue 打交道(读取),这样,复杂度就变成了 M+N。

对于写请求,我们需要保证这些事件处理不会有并发的问题,通过采用 Singlar Update Queue,对特定的 topic,我们可以设计一个良好的 sharding 规则,加上对于每一个 sharding(在 Kafka 等系统里面我们叫做 partition),设置为只有一个 consumer 线程,这样的话就保证了不会有并发问题,因为只有一个线程来处理所有这个 sharding 的消息,这种方式可以简化系统,不需要引入第三方锁系统就可以处理同一个 sharding 之间存在并发冲突的消息。

我想起另外一个相关的话题,monolith(单体应用)还是 microservices(微服务),一直是一个争论。早些时候,在微服务概念刚提出的时候,它受到了追捧,但是现在出现了越来越多批评的声音。一个突出的微服务的问题就是各个微服务之间像蜘蛛网一样复杂调用依赖的问题。而这样的问题,其中一个解决办法就是引入这样的 queue 在中间解耦。

有些时候,queue 里面未必存放全部完成 update 所需的数据,而是只放很少的内容,比如只有一个 key,consumer 拿到这个 key 以后去别的 service 获取完成任务需要的信息,因此这个 queue 就起到一个通知的作用。这其实就是 Claim Check 模式了。

Asynchronous Request-Reply

Asynchronous Request-Reply 本身是一个简单而且常用的机制,就是请求发起以后,服务端响应说,请求任务正在处理中,并返回给 client 一个 token。后续 client 拿着这个 token 就可以来(可以是另外一个单独的用于状态查询和结果获取的服务)查询请求的处理状态(poll),同时,服务端也可能会通知(push)客户端情况。

不过,既然上面谈到了 Singular Update Queue,它们俩有时是有关联的。

在使用 Singular Update Queue 的时候,如果 consumer 处理一个消息需要花很长的时间,那么它就可能成为整个系统吞吐量的瓶颈。很多时候,这个 consumer 花很长时间来处理往往不是因为有复杂的 CPU 计算,而是等待,比如等待一个远程调用结束,等待一个文件写入结束等等。

对于这样的问题,有两种解决思路:

  1. 一种是划分出更多的 partition,这样就可以设置更多的 consumer 线程来处理,但是这种方法带来的代价是更高的 overhead。
  2. 第二种是把处理流程变成异步执行的,而把它变成异步的就意味着系统没法直接妥善处理异步处理结果(它可能成功,也可能失败),因此对于这样一个子问题,有两个进一步解决的思路:
    1. 触发处理流程之后,预约下一个 “回访”,也就是采用这里提到的 Asynchronous Request-Reply 这样的机制,但是这个回访需要在一定时间后发生,并且一定要发生,因此这需要一个高可用的定时任务的服务(需要支持重试策略和失败处理),或者是封装一个 delay queue,即里面的消息需要过一段时间才能被 poll 到,因此这个机制有可能会比较复杂。
    2. 还有一个思路则是让 consumer 来集成 Coroutine,这样一个 consumer 线程可以被分享同时处理若干个消息,这些消息都来自同一个 partition(有一些开源库就专干这个事儿),于是这样多个消息就可以被同一个线程以非阻塞的方式并行处理。这个方法的一个局限性是,这些消息在 Coroutine 框架内处理时,不能存在并发冲突的问题。

Rate Limiting & Throttling

最后比较一下 Rate Limiting 和 Throttling。我也是不久前才区分清楚,以前我基本是把它们混在一起使用的。它们都是用来限流的,并且有多重不同的方式,可以是基于 fixed window,sliding window 等等。

但是它们的区别,本质上是它们工作的角度不同。Rate Limiting 是从 client 的角度来管理资源的,比如说,规定某一个/每个用户对于资源的访问不能超过一定的限度,因为资源不能让一个客户端全占了,这样其他人才可以有访问资源的权利;而 Throttling 则是从 service 的角度来管理资源的,比如说,规定某个/每一个 API 的访问 throughput 上限是多少,一道超过这个限度,请求就会被拒掉,从而保护服务。

和这两个可以放在一起的,其实还有一个 Circuit Breaker,不过 Circuit Breaker 的功能和这两个有较大区别,不太容易弄混,因此就不赘述了。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

本地部署 Minikube + Docker 记录

2024-09-27 06:52:08

我有 Mac 和 Windows,这些年折腾软件方面的环境 Linux 用得比较多,最近想安装一个 Kubernetes 的本地环境,本着 “生命不息,折腾不止” 的精神,打算在 Windows 上动手。了解到可以尝试 Minikube,在此简单记录一下。

首先得要安装 Docker,但是在 Windows 下跑 Docker 有两种方式,WSL(Windows Subsystem for Linux)或者 Hyper-V。我首先把这些 Windows 组件都勾上:

我两条路都去走了一下,为了使用 Hyper-V,我还去 BIOS 里面打开虚拟化支持的选项。不过,后来才知道,因为操作系统版本是 Windows 10 Home,虽说 Windows 上面跑 docker 有两种方式:

  • 一种是使用 WSL
  • 另一种是使用 Hyper-V

但在 Windows 10 Home 版本上只支持第一种。由于 Hyper-V 本质上是额外的虚拟机,而 WSL 更新,是虚拟化的 Linux 环境,是 Windows 操作系统原生支持的,性能要好一些。

其实,在 Docker 的设置里面也有说了:

可以列出所有 WSL(我使用的 WSL 2)目前支持的 Linux Distributions:

wsl --list --online

可以选一两个自己熟悉的安装了体验一下:

wsl --install Ubuntu
wsl --install Debian

整个 Windows 的文件系统都可以以 Linux 的方式访问。以往我一般在 Windows 上运行 Linux 命令都是使用 Cygwin 的,但是现在我了解到两者很不相同,WSL 是真正的虚拟化 Linux 环境,而 Cygwin 只不过把一些 Linux 命令编译成 Windows 的二进制版本。

安装 Minikube 和相关工具,配置环境变量。这次学到了可以用 Chocolatey,它是 Windows 下的软件安装工具。比如:

choco install kubernetes-cli

Minikube 可以以 VM、container,甚至 bare metal(Windows 下不支持)的方式来运行,通过 driver 参数指定。

我们使用 docker,这也是官方认定 preferred 的一种方式。这种方式下,Minikube 应用本身会作为 Docker container 跑在 Docker 里面(driver 的含义),同时,Minikube 也会使用 Docker 来跑其它的应用 container。

minikube config set driver docker
minikube delete
minikube start --driver=docker

看一下状态:

Done! kubectl is now configured to use “minikube” cluster and “default” namespace by default

看一下状态:

minikube status
type: Control Plane
host: Running
kubelet: Running
apiserver: Running
kubeconfig: Configured

此外,检查一下 WSL 已经安装的 Linux subsystem,能看到:

wsl -l
Windows Subsystem for Linux Distributions:
Ubuntu (Default)
docker-desktop
Debian

在 Docker 的 UI 上,也能看到:

跑起来以后,用 kubectl 验证一下:

kubectl cluster-info
Kubernetes control plane is running at https://127.0.0.1:57514

查看所有 namespace:

kubectl get pods –all-namespaces

启动 dashboard:

minikube dashboard

接着,创建和部署一个 hello minikube 的 service:

kubectl create deployment hello-minikube --image=kicbase/echo-server:1.0
kubectl expose deployment hello-minikube --type=NodePort --port=8080
kubectl get services hello-minikube

通过访问:

minikube addons list

可以列出一堆可以立即安装的 addon,有一些是 K8s 的,有一些是 minikube 的,比如:

minikube addons enable auto-pause

这个可以在一段时间没有使用的情况下,暂停 K8s。

还有一个 addon 能让 dashboard 的 metrics 显示更多信息:

minikube addons enable metrics-server

后来,一通折腾以后发现,原来 Docker 的 settings 里面已经有了一个 Kubernetes 选项:

原来新版本的 Docker 里面自带了一套 K8s,它是完全跑在 Docker instance 里面,并且无法配置的,主要用于本地测试。它的运行也是基于 WSL。

现在就试一下,停掉 Minikube:

minikube stop

UI 确认确实停了(或者 docker ps):

然后把 Docker 的 Kubernetes tab 上面的两个选项都勾上,apply & restart。

不过等了好久,似乎卡在这一步了,我 reset 并且更新 Docker 以后,问题解决。打开以后我看到 Docker 启动了一堆 container。确认跑起来也没问题:

kubectl get nodes
NAME STATUS ROLES AGE VERSION
docker-desktop Ready control-plane 10m v1.30.2

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》

谈谈分布式锁

2024-09-19 07:33:44

不要使用分布式锁

就像 Martin Fowler 说的那样,“分布式调用的第一原则就是不要分布式”,谈分布式锁也要先说,不要使用分布式锁。原因很简单,分布式系统是软件系统中复杂的一种形式,而分布式锁是分布式系统中复杂的一种形式,没有必要的复杂性就不要引入。

有的逻辑是没有副作用的(纯函数代码),那就可以无锁执行;有的数据经过合理的 sharding 之后,可以使用单线程(单节点)执行,那就单线程执行。

比如一种常见的模式就是使用 queue(比如 Kafka),任务全部放到队列中,然后根据 sharding 的逻辑,不同的 consumer 来处理不同的任务,互相之间不会干扰冲突。

还有一个例子是 Kotlin Coroutine,通过指定一个单线程的 dispatcher,也可以保证它执行的操作之间互相不会有多线程的冲突问题。

有了这样的原则以后,再来谈谈几种分布式锁。

数据库锁

分布式系统中,我觉得我们最常见的锁就是使用一个中心数据库来做的。

一种是悲观锁,就是 “select xxx … for update” 这样的,相应的数据行会被锁上,直到 commit/rollback 操作发生。如果被别人锁了,当前线程没得到锁的话就会等着。

还有一种是乐观锁,就是使用版本号,“update … where … version=A” 这样的。如果 update 成功,表示获取锁成功,并且操作也成功;否则就是 update 失败,需要重新获取状态再来操作一遍。

大多数情况下,后者要更高效一些,因为阻塞的时间通常更短,不过在锁竞争比较激烈的情况下,反而效率会反过来。另外一个,悲观锁代码写起来会容易一些,因为 select 语句执行和 commit/rollback 是两步操作,因此二者之间可以放置任意逻辑;而乐观锁则是需要把数据的写操作和 version 的比较放在一条语句里面。

这两种都很常见,基本上我接触过的一半以上的项目都用过两者。这个数据库不一定非得是关系数据库,但是强一致性必须是保证的。

S3

使用 S3 来创建文件,让创建成功的节点得到锁,文件里面也可以放自定义的内容。我们去年的项目用到这个机制。这种方式是建立在 S3 2020 年 12 月 1 日,上线的 strong consistency 的 feature

大致上,有这样两种思路:

  1. 使用 S3 versioning,就是说,在 versioning 打开的情况下,文件的写入不会有 “覆盖” 的情况发生,所有内容都会保留。在创建文件的时候,response 种会有一个 x-amz-version-id header。节点写入文件后,再 list 一下所有的 version,默认这些 version 会根据创建的时间顺序递减排列,后创建的在前,因此比较其中最早的那个 version 和自己创建文件后得到的 version,如果两者相等,说明自己得到了锁。
  2. 使用 S3 Object Lock,这个可以控制让第一次写成功,后面的操作全部失败,所以第一次写入成功的节点得到锁。

使用这种方式,对于那些本来就需要使用 S3 文件系统来共享任意信息的情况很方便,但是需要自己处理超时的问题,还有 retention 策略(该不该/什么时候删掉文件)。

Redlock

Redlock 就是 Redis 的锁机制。Martin Kleppmann(就是那个写《Design Data-Intensive Applications》的作者)几年前写过一篇文章,来吐槽 Redlock 在几种情况下是有问题的:

  1. Clock jump:Redlock 依赖于物理时钟,而物理时钟有可能会跳(jump),并且这种状况是无法预测的。Clock jump 就是说,始终会不断进行同步,而同步回来的时间,是有可能不等于当前时间的,那么系统就会设置当前时间到这个新同步回来的时间。在这种情况下,依赖于物理时间的锁逻辑(比如超时的判断等等)就是不正确的。
  2. Process pause:得到锁的节点,它的运行是有可能被阻塞的。比如 GC,下面这个图说的就是这个情况——client 1 一开始得到锁了,执行过程中有一个超长时间的 pause,这个 pause 导致锁超时并被强制释放,client 2 就得到锁了,之后 client 1 GC 结束,缓过来后恢复执行,它却并没有意识到,它的锁已经被剥夺了,于是 client 1 和 client 2 都得到了锁,对于数据的修改就会发生冲突。
  3. Network delay:其实原理和上面差不多,网络延迟+锁超时被强制剥夺和重分配的逻辑,在特定情况下就是不正确的。

问题可以理解,可是仔细想想这个问题的本质是什么?它的本质其实就是消息延迟+重排序的问题,或者更本质地说,就是分布式系统不同节点保持 consistency 的问题,因为 lock service 和 client 就是不同的节点,lock service 认为之前的锁过期了,并重分配锁给了 client 2,并且 client 2 也是这样认为的,可是 client 1 却不是,它在 GC 之后认为它还持有者锁呢。

如果我们把数据的写操作和锁管理的操作彻底分开,这个问题就很难解决,因为两个节点不可能 “一直” 在通信,在不通信的时间段内,就可能会发生这种理解不一致的情况。但是如果我们把写操作和锁管理以某种方式联系上,那么这个问题还是可以被解决的。简单说,就是物理时钟不可靠,逻辑时钟可以解决这个问题

之后 Martin Kleppmann 提出了解决方案,他的解决方案也就是按照这个思路进行的。他的方法很简单,就是在获取锁的时候,得到一个永远递增的 token(可以被称作 “fencing token”),在执行写操作的时候,必须带上这个 token。如果 storage 看到了比当前 token 更小的 token,那么那个写操作就要被丢弃掉。

Chubby

Chubby 是 Google 的分布式锁系统,论文在这里可以找到,还有这个胶片,对于进一步理解论文很有帮助。从时间上看,它是比较早的。

Chubby 被设计成用于粗粒度的(coarse-grained)锁需求,而非细粒度(fine-grained,比如几秒钟以内的)的锁需求。对于这样一个系统,文中开始就提到 consistency 和 availablity 重要性要大过 performance,后面再次提到首要目标包括 reliability,能够应对较多数量的 clients,和易于理解的语义,而吞吐量和存储容量被列在了第二位。

Chubby 暴露一个文件系统接口,每一个文件或者文件夹都可以视作一个读写锁,文件系统和 Unix 的设计思路一致,包括命名、权限等等的设计都是基于它。这是一个很有意思的设计。

对于一致性的达成,它使用 Paxos,客户端寻找 master 和写数据都使用 quorum 的机制,保证写的时候大部分节点成功,而读的时候则是至少成功读取大部分节点(R+W>N,这个思路最早我记得是 Dynamo 的论文里面有写);如果 lock 有了变化,它还提供有通知机制,因为 poll 的成本太高。

内部实现上面,每一个 Chubby 的 cell 都是由作为 replica 的 5 个服务节点组成,它们使用 Paxos 来选举 master 和达成一致,读写都只在 master 上进行(这个看起来还是挺奢侈的,一个干活,四个看戏)。如果 master 挂掉了,在 master lease 过了以后,会重新选举。Client 根据 DNS 的解析,会访问到该 cell 里面的某一个节点,它不一定是 master,但是它会告知谁是 master。

分布式锁里面比较难处理的问题不是失败,而是无响应或者响应慢的超时问题。Chubby 采用一种租约的机制,在租约期内,不会轻易变动当前的 master 节点决定。在响应超时的时期,客户端的策略就是 “不轻举妄动”,耐心等待一段时间等服务端恢复,再不行才宣告失败:

这个图的大致意思是,第一次租约 C1 续订没有问题;第二次租约续订 C2 了之后,原来的 master 挂了,心跳请求无响应,这种情况客户端不清楚服务端的状况,就比较难处理,于是它只能暂时先阻塞所有的操作,等到 C2 过期了之后,有一个 grace period;接着再 grace period 之内,新的 master 被选举出来了,心跳就恢复了,之后租约续订的 C3 顺利进行。

这显然是一个异常情形,但是一旦这种情况发生,系统是被 block 住的,会有非常大的延迟问题。思考一下,这种情况其实就是从原来的 master 到新的 master 转换的选举和交接期间,锁服务是 “暂停” 的。再进一步,这个事情的本质,其实就是在分布式系统中,CAP 原理告诉我们,为了保证 Consistency 和 Partition Tolerance,这里的情形下牺牲掉了 Availability;同时,为了保证 consistency,很难去兼顾 performance(latency 和 throughput)。

此外,有一个有点反直觉的设计是,Chubby 客户端是设计有缓存的。通常来讲,我们设计一个锁机制,第一印象就是使用缓存会带来复杂性,因为缓存会带来一致性的难题。不过它的解决办法是,使用租约。在租约期内,服务端的锁数据不可以被修改,如果要修改,那么就要同步阻塞操作通知所有的客户端,以让缓存全部失效(如果联系不上客户端那就要等过期了)。很多分布式系统都是采用 poll 的方案——一堆 client 去 poll 一个核心服务(资源),但是 Chubby 彻底反过来了,其中一个原因也是低 throughput 的考虑,毕竟只有一个 master 在干活。

对于前面提到的 Martin Kleppmann 谈到的那个问题,Chubby 给了两个解决方法:

  1. 一个是锁延迟,就是说,如果一切正常,那么持有锁的客户端在释放掉锁之后,另外的客户端可以立即获取锁。但是如果出现超时等等异常,这个锁必须被空置一段时间才可以被分配。这个方法可以降低这个问题出现的概率,但是不能彻底规避问题。
  2. 第二个就是使用序列号了,对于写入操作来说,如果请求携带的序列号要小于前一次写入的序列号,那就丢弃请求,返回失败。

回过头思考 Chubby 的实现机制,我觉得有这样几个启发:

  1. 不要相信任何 “人”(节点),必须询问到多数人(quorum),多数人的结论才可以认为是正确的。这个方式发生在很多操作上,比如寻找 master,比如选举 master,比如写数据。
  2. 超时是很难处理的,它采用了租约的机制保证节点丢失时间的上限,使用 grace period 来容忍 master 选举发生的时延,使用序列号来保证正确性。

文章未经特殊标明皆为本人原创,未经许可不得用于任何商业用途,转载请保持完整性并注明来源链接 《四火的唠叨》