• 作为程序员应该如何避免系统过度设计?
  • 发布于 2个月前
  • 266 热度
    0 评论
随着现代技术的发展,如云计算、容器编排和微服务架构的出现,使得创建几乎无限规模和复杂性的分布式系统变得轻而易举。虽然所有这些工具都有其用途,但对于工程师来说,仔细考虑何时以及是否使用它们至关重要,尤其对于规模较小的公司而言。错误的选择可能导致机动性下降、财务状况不佳和业务不成功。本文探讨了一种常见的反模式 —— 不必要的复杂性,探讨了其潜在原因,并提出了一些建议来解决这个问题。

一.问题
从复杂性开始
当我们面试软件工程职位时,我们必须在一个人造的、具有挑战性的、充满压力的面试过程中,证明给潜在雇主看,我们有资格继续做过去几年一直在做的事情。在这个过程中,我们可能会被要求在短时间内解决多个中等或难度较大的算法问题,这更像是观众在看一场体育比赛,而不是 coding。

我们通常需要在一个小时内设计一个新系统,其需求必须先从面试官那里提出。但这些练习很少类似于我们在实际工作中所做的事情,而是设计出一些挑战,以产生不同程度的 “信号”,供招聘委员会使用,以平衡和区分候选人。

特别是在系统设计面试中,候选人被要求展示对大规模系统的广泛而深入的理解,这样的规模可能是在最大的公司中才能找到的。我们使用像微服务、一致性哈希、事件总线、Service Mesh、协议缓冲区、WebSockets、Kubernetes、API 网关、数据管道、数据湖以及其他技术术语来表明我们知道它们是什么,以及如何使用它们。这使潜在雇主相信我们对所有最新的技术和工具都了解,并且对我们能完成可能要求我们做的工作有信心。

复杂性催生复杂性
如果我们得到了工作机会,我们就有机会运用面试过程中展示的先进知识来解决业务问题。我们会尽职地运用这些技术,通常会基于 Google、LinkedIn、Meta、亚马逊、苹果和 Netflix 开创的架构模式,构建出相当复杂的系统。

企业领导往往会对这种复杂性感到满意,因为它表明公司现在已经成熟,工程标准很高,公司的系统将能够与业务一起扩展,无论业务增长多少。这种复杂性还被用作招聘工具,向候选人展示他们将能够使用所有最新技术,并且他们未来的同事都是聪明且了解现代工具和技术的人。

问题何在?
这种方法的问题在于,许多公司永远不会发展到足够大或有足够大规模,以使这种复杂性产生好处。最大的公司开发了这些新颖的技术和模式,以应对随着用户基础和交易量呈指数级增长而遇到的严重规模问题。在许多情况下,没有好的解决方案存在,所以他们为其业务规模构建了自己的解决方案,这通常需要付出巨大的成本。为了造福更广泛的技术生态系统,其中许多公司慷慨地将他们的技术开源,使其 “免费使用” 于其他公司。

但是当未能达到规模时,上述许多模式和技术是危险的,因为它们可能会减缓进度,大大增加成本,并且增加工程人员的认知负担。这对于规模较小的公司来说是一个巨大的问题,因为它使得业务在最重要的方面表现更差 —— 灵活性和盈利能力。这为那些发现自己处于经济下滑或竞争激烈行业的企业,增加了负担,因为在利润率收紧时,复杂性问题变得更加严重。这种不匹配在很大程度上因为工程师的奖励体系通常与业务表现不足相联系。

二.两个例子
我们从一个相对容易理解的简单系统开始,并将其最小形式与过度工程化的系统进行比较。为了讨论方便,让我们假设我们有一个通用的网站,用户可以在上面注册并购买商品。

简单版本
一个简单的电子商务网站的最小架构通常被称为单体架构,意味着整个代码库被封装在一个单一的源代码仓库中,并且通常作为一个单一的单元进行部署。许多初创公司从这是这样的,因为在代码库和工程团队较小的情况下,可以快速迭代。另一个优势是整个系统通常可以在笔记本电脑上运行,方便本地开发。

上图是简单版本的架构示意图。我们可以从单个云提供商(在本例中是亚马逊网络服务或 AWS)选择一些合理的默认值,以保持基础设施工具的一致性:
主要开发语言 / 框架:Ruby+Rails(我们也可以选择 Python+Django、Go+Buffalo 或任何其他合理的语言 / 框架)
前端语言 / 框架:JavaScript/TypeScript+React(几乎所有现代 Web 框架都需要一些 JavaScript 框架,React 非常受欢迎)
批处理 / 后台处理:Ruby+Sidekiq(如果选择了其他主要语言,可以考虑 Python+Celery、Go+Faktory 等)
源代码管理:Git/GitHub
持续集成 / 持续交付:GitHub Actions+Terraform
镜像仓库:AWS ECR
容器编排系统:AWS ECS
密钥:AWS KMS
Secret:AWS Secrets Manager
防火墙 / 分层缓存:Cloudflare
DNS:Cloudflare
负载均衡器:AWS ELB 或 ALB
文件 / 对象存储:AWS S3
缓存 + 缓存节点:AWS ElastiCache for Redis
数据库 + 读取副本:Postgres on AWS RDS
消息传递:Sendgrid
支付处理:Stripe
身份验证 / SSO:Okta
日志和监控:DataDog
我们将假设外部服务通过版本化的 REST API 公开,因为大多数第三方服务都提供这种选项。为了降低认知负荷,我们可以对内部 API 调用做同样的处理,以便我们可以使用像 Postman 这样的工具测试所有 API。

所有内部服务间的调用将以同步方式进行,尽管可以通过 Sidekiq 实现后台处理来实现异步功能,如果需要的话。任何向外部用户公开的 API 都将通过用于一般 Web 浏览器流量的入口进行公开。没有 “事件总线”,也没有任何数据流水线、数据仓库或数据湖。所有的分析都是针对数据库读副本进行的。

这个设计包括 16 个关键组件,其中任何一个出现故障都可能导致用户界面面向用户的全站故障或降级。大多数组件都是关键的,但冗余部署有助于减轻故障造成的风险。

复杂版本
我们可以说上述简单版本实际上并不简单,但是我们可以展示一些更复杂的系统架构。让我们开始将单体拆分为微服务。让我们引入 API 网关、Kubernetes、事件总线(使用 Kafka)、Service Mesh、多个域名系统、多个 CI/CD 流水线、数据流水线(使用单独的 Kafka 集群)和数据湖。

除了 REST API,让我们添加对 gRPC API 的支持(包括协议缓冲区),因为我们知道它比 REST 快得多,我们希望具有高性能。我们将添加第二个 CI/CD 系统(Jenkins),因为一些员工更喜欢它而不是 GitHub Actions。

让我们添加一种额外的语言和框架(Python/Django/Celery),以给开发人员更多选择,并确保将 Go 和 Groovy 加入我们支持的语言列表,因为我们使用 Jenkins(使用 Groovy DSL)和 Kubernetes,因此可能需要 Kubernetes(Go)运算符来管理我们创建的一些自定义资源。

为了获得更好的性能和解耦,我们将完全将前端 UI 部署与后端单体分开。我们还将添加另一种日志和可观察性选项(Sumo Logic),因为我们的第一选择(DataDog)被认为过于昂贵,无法将所有日志发送到其中。

让我们来看看这些架构变化如何改变了我们的系统:
如果我们假设我们的 Python 服务和数据平台并不是关键的,那么现在我们有 33 个关键组件,其中每个组件的故障都可能导致全站问题。根据我们如何计算可用性,这使得故障的可能性更大,因为整体系统的可用性只能与最不可用的依赖组件一样高。我们有更多的依赖组件,因此保持相同的可用性水平需要更严格的标准。在实践中,依赖组件越多,保持一致高标准的难度就越大。

我们用过网络 API 调用取代了可靠的本地库调用。这带来了两个影响:
1.网络增加了延迟,使得每次调用远程服务比本地库调用要慢一点
2.任何网络调用都可能失败或受到速率限制,这意味着我们需要比调用本地库时更小心地处理错误

引入了 3 个新的语言:Python、Groovy 和 Go。尽管这增加了选择的可能性,但也减少了可以自由调配员工到不同团队的能力。在许多情况下,需要学习新的语言,这需要时间,并降低了灵活性。

我们现在有 3 种集成模式的选择:
1.REST API
2.gRPC
3.事件总线

这意味着当 API 发生变化时,我们可能需要在事件总线集成中更新模式注册表,或者在 gRPC 的情况下获得反映变化的新客户端 “存根”。无论如何,现在我们需要了解三种不同的模式,以在分布式生态系统中工作。如果我们共享中央模式注册表,则存在引入破坏性变更的风险,可能会影响其他服务。

新增了 11 个全新组件:
API 网关(Amazon API Gateway)
前端平台 + 边缘计算(Vercel)
内部 DNS(AWS Route53)
用于事件总线的 Kafka(AWS MSK)
用于数据管道的 Kafka(AWS MSK)
多个 Kafka Connect 源和汇聚部署,用于从各种数据库和主题读取 / 写入
数据湖:我们将假设 AWS Lake Formation 与 Amazon Athena 用于查询和 S3+Parquet 用于数据存储
Istio 服务网格,用于 API 网关和我们的 Python 微服务之间的 mTLS 和路由
多个 Kubernetes 集群(AWS EKS)
入口负载均衡器(AWS ALB)
Jenkins CI/CD
每个新增组件都需要新的或增加员工来确保业务的连续性。每个新增组件还需要监控和定期更新,以确保解决漏洞并保持组件的健康。每个组件都带来了配置错误的风险,这可能会通过过度配置增加操作成本,或通过不足配置增加故障风险。

大约有 16 个重复组件:
3 个数据库 + 读取副本
3 个 Redis 集群(一个用于单体架构,每个微服务一个)
2 个 Kafka 集群
2 个 DNS 系统(Cloudflare 外部 + Route53 内部。这不包括 Kubernetes 内部集群 DNS)
2 个 CI/CD 系统
2 个日志和监控解决方案
2 个 Kubernetes 集群

在最好的情况下,这种复制限制了故障的范围,并允许对服务进行逐个调整,而且所有部署都在团队和服务之间保持一致的管理。在最糟糕的情况下,它们都以不同的质量和工具水平进行不同的管理。无论如何,管理这些重复的问题都需要更多的精力。

运营成本增加约 10-15 倍
计算额外的容器、部署、托管服务和所需人员,我们预计运营成本会大大增加。即使亚马逊这样最热衷于微服务和公有云的公司也发现成本对于某些内部用途来说是难以承受的。

对于完全栈本地开发而言太过庞大
即使减小部署配置,也可能有太多的移动部件,使得无法在开发者的 Docker 或 minikube 上完全运行整个系统。可以采取一些方法在云上实现开发,但是开发人员将需要模拟完全功能的系统,因此需要模拟服务的 mocks 或共享开发实例。

三.我们该如何避免这些错误?
从技术上讲,上述简单或复杂的系统示例都没有问题。它们都提供了类似的功能,而复杂系统虽然在建设和运营上成本更高,但对于某些特定规模和范围的公司来说可能是最佳选择。但是,对于大多数公司来说,他们可能不需要复杂的架构。为了避免过度优化,以下方法可能有所帮助:

培养奖励简单性的文化
在面试候选人时,不要鼓励他们通过广泛深入的知识和设计规模类似于 Web 的系统来给你留下深刻印象,而是将架构简化作为评估他们的关键标准之一。可以提问类似:“你能想到哪些方法可以使该系统更简单或更可靠?” 如果他们能够在设计中识别出不必要的部分,应该给予奖励。如果他们在一开始就避免添加不必要的组件,那就更好。

雇佣个人贡献者后,尤其是在较高层级的职位时,将架构简化纳入他们的绩效评估中。奖励那些能够将复杂脆弱的系统简化并使其更加稳健的员工。让技术人员关注效率和简化,将确保系统在需要快速响应时具备敏捷和可靠性。

尽量减少同时进行更改
当公司开始进行复杂的任务,例如将有界上下文从单体应用中分离出来时,很容易陷入 “一锤子买卖” 的陷阱,试图在一个大规模的操作中同时进行提取、转换和重构。如果团队希望在不同的语言或使用不同的工具实现某个功能(同时希望学习这些新技术),通常会将这种转换视为提取过程的合理且必要的部分。

如果现有的语言和框架仍然适合工作负载,通常最好先将代码提取到一个外部服务中,然后在提取完成后再考虑进行重构、移植或其他服务结构调整,如果仍有必要的话。

我见过很多迁移工作因为一次性尝试做太多事情而陷入困境,这样会增加整个工作的风险,或导致进展非常缓慢。如果现有代码是用 Ruby 编写的,通常最好保持代码的稳定性,直到服务真正成为一个独立关注点。通过尽量少地进行改动,你可以从其他工程师那里获得更多帮助,因为他们仍然能够理解你正在转移的代码。

鼓励标准和共识
我见过许多主管、工程师和架构师之间争论,最终大家都决定按照各自的方式行事,放弃共同的约定和标准,而选择采用新的工具和技术进行全新的开发。虽然这可能会让有冒险精神的工程师感到有权力,但它通常会导致定制的解决方案无法与生态系统的其他部分很好地配合,并增加运营成本。

与其采用分离策略,不如鼓励形成共识,找出一种可以广泛采用的解决方案来解决问题。不要只为自己或你的团队开发工具,而是要考虑你构建的每一样东西都是要与整个工程组织共享的。如果问题是许多工程师共同面临的,但你认为你的解决方案不会被采纳,那么就考虑改进已经广泛使用的某个方案,或者与其他工程师讨论这个问题,直到你获得认可和支持,以便你的解决方案能够广泛采用,并且他们愿意帮助你在整个组织中推广采用。

宁可不做也不做错
许多组织过于迷恋速度,使用 Scrum 等敏捷方法来 “最大化流程” 和团队的生产效率。在许多情况下,企业不知道确切需要做什么,所以简单地确保工程师保持忙碌,保持高速执行。

这种方法的问题在于通常会建造一些不需要的东西,但它们不会被扔掉,而是被视为需要支持和持续投资的项目。原因是大多数工程师对自己的工作感到自豪,不愿意做 “一次性” 的软件。

在不清楚需要做什么的情况下,可以利用这段时间让工程师进行实验、更新技能,或与产品经理和其他领导合作,确定接下来应该做什么。这将防止产生 “废物”,并在需要快速执行时增加责任感。

在需要优化之前再优化
在问题影响业务之前,及时解决性能和可用性问题总是更好的选择。采取积极主动的态度,尽早解决潜在的问题。迫不及待地进行 “未雨绸缪”,使解决方案在需要之前支持大规模扩展,通常是一个错误。

相反,将服务水平指标(SLI)的定义、监控和容量规划纳入服务的所有权范围。当团队负责确保服务水平指标明确定义并达到目标时,优化就是合同的一部分。如果服务的使用模式会因为某个新产品发布或合作伙伴关系而发生重大变化,应该咨询服务所有者,允许他们在规划中考虑这一点,并确保他们可以在增加的流量情况下保持指标在适当的范围内。如果团队在承担更多流量的同时保持了服务水平目标的一致性,应该给予奖励(在财务上),因为更多的流量应该意味着更多的收入。

确保工程成功与公司成功密切相关
太多时候,工程师会因为创造技术上令人印象深刻的解决方案而受到奖励,而这也是他们可以为自己在组织内晋升提供理由的方式。这通常被称为 “履历建设”,工程师可以通过展示他们参与过的令人印象深刻的系统,作为获得更高职位的手段。

这是不幸的,因为你的技术专家在赞美了你可能并不需要的某个东西后离开了。现在你必须决定是用额外的人员支持这个项目,还是由于内部知识缺乏而放弃它。

相反,鼓励工程师将企业需求放在技术需求之前,确保他们在公司业务表现更好时也表现得更好。
用户评论