跳到主要内容

45 篇博文 含有标签「系统设计」

查看所有标签

快速介绍 Optimism 架构

· 阅读需 6 分钟

什么是 Optimism?

Optimism 是一种 EVM 等效的乐观汇总协议,旨在扩展以太坊。

  • 扩展以太坊意味着增加以太坊网络可以处理的有用交易数量。
  • 乐观汇总 是一种第二层可扩展性技术,它在不牺牲安全性或去中心化的情况下,增加以太坊的计算和存储能力。
  • EVM 等效性 是与以太坊黄皮书中描述的状态转移函数的完全合规,后者是该协议的正式定义。

乐观汇总通过将多个交易打包成一个单一交易来工作,然后由以太坊网络上的智能合约进行验证。这个过程被称为“汇总”,因为单个交易被组合成一个更大的交易,并提交到以太坊网络。术语“乐观”指的是系统假设交易是有效的,除非有证据证明相反,这使得交易的处理更快、更高效。

整体架构

Optimism 架构

op-node + op-geth

汇总节点可以以验证者或排序者模式运行:

  1. 验证者(即验证器):类似于运行以太坊节点,它在本地模拟 L2 交易,而不限制速率。它还允许 验证者 验证 排序者 的工作,通过重新推导 输出根 并将其与 排序者 提交的进行比较。如果不匹配, 验证者 可以执行 故障证明
  2. 排序者: 排序者 是一个特权角色,它接收来自 L2 用户的 L2 交易,使用这些交易创建 L2 块,然后将其提交给 数据可用性提供者(通过 批处理器)。它还将 输出根 提交给 L1。目前整个堆栈中只有一个排序者,这也是人们批评 OP 堆栈不去中心化的地方。

op-batcher

批量提交者,也称为 批处理器,是将 L2 排序者 数据提交到 L1 的实体,以使其对验证者可用。

op-proposer

提议者生成并提交 L2 输出检查点到以太坊上的 L2 输出预言机合约。在最终确认期结束后,这些数据使提款成为可能。

批处理器和提议者都将状态提交到 L1。为什么它们被分开?

批处理器收集并将交易数据批量提交到 L1,而提议者将承诺(输出根)提交到 L2 的状态,从而最终确定 L2 账户状态的视图。它们是解耦的,以便可以并行工作以提高效率。

contracts-bedrock

各种合约用于 L2 与 L1 进行交互:

  • OptimismPortal:一个 L2 交易的馈送,这些交易源自 L1 状态中的智能合约调用。
  • 批量收件箱:一个 L1 地址,批量提交者将交易批量提交到该地址。
  • L2 输出预言机:一个智能合约,存储 L2 输出根 以用于提款和故障证明。

Optimism 组件

如何存款?

如何提款?

对 Optimism 文档的反馈

理解 OP 堆栈可能会很具挑战性,原因有很多。其中一个因素是许多组件在代码和文档中多次以略微不同的名称被提及。例如,术语“op-batcher”和“批量提交者” / “验证者”和“验证器”可能可以互换使用,这导致混淆和理解每个组件的确切功能的困难。

理解 OP 堆栈的另一个挑战是不断发展的架构,这可能导致某些设计元素随着时间的推移而被弃用。不幸的是,文档可能并不总是更新以反映这些变化。这可能导致进一步的混淆和理解系统的困难,因为用户可能正在使用过时或不准确的信息。

为了克服这些挑战,重要的是仔细审查所有可用文档,保持概念在各个地方的一致性,并随时了解 OP 堆栈的任何变化或更新。这可能需要额外的研究和与其他用户或开发者的合作,但这是完全理解和有效利用这个复杂系统的必要条件。

微服务中的身份验证和授权

· 阅读需 12 分钟

需求

  • 设计一个从简单开始但可以随着业务扩展的身份验证解决方案
  • 考虑安全性和用户体验
  • 讨论该领域的未来趋势

大局观:身份验证(AuthN)、授权(AuthZ)和身份管理

首先,回归基础

  • 身份验证:确定你是谁
  • 授权:确定你可以做什么

在开始时……让我们有一个简单的服务……

  • 分层架构
  • 客户端存储一个cookie或token作为登录状态的证明。(代客钥匙模式)

  • 服务器持久化相应的会话
  • token通常采用JWT格式,由从安全位置(环境变量、AWS KMS、HarshiCorp Vault等)获取的密钥签名

  • 流行的Web框架通常提供开箱即用的身份验证解决方案

然后,随着业务的增长,我们用AKF规模立方体来扩展系统:

  • X轴:水平克隆
  • Y轴:功能分解
  • Z轴:分片

加上康威定律:组织设计的系统反映其沟通结构。我们通常将架构演变为微服务(参见为什么选择微服务?了解更多)

  • 顺便说一句,“微服务与单体”和“多仓库与单仓库”是不同的概念。
  • 对于企业,有员工身份验证和客户身份验证。我们更关注客户身份验证。

在微服务世界中,让我们抽取身份验证和授权服务的功能切片,并有一个身份和访问管理(IAM)团队在进行相关工作。

  • 身份感知代理 是一个反向代理,允许公共端点或检查受保护端点的凭证。如果凭证未提供但被要求,则将用户重定向到身份提供者。例如:k8s ingress controller、nginx、envoy、Pomeriumory.sh/oathkeeper等。
  • 身份提供者和管理者 是一个或几个通过某些工作流(如登录、忘记密码等)管理用户身份的服务。例如:ory.sh/kratoskeycloak
  • OAuth2和OpenID Connect提供者 使第三方开发者能够与您的服务集成。
  • 授权 服务控制谁可以做什么。

身份验证

身份提供者

  • 最简单的解决方案是提交用户的身份证明并发放服务凭证。
    • bcrypt、scrypt用于密码哈希
  • 然而,现代应用程序通常处理复杂的工作流,如条件注册、多步骤登录、忘记密码等。 这些工作流本质上是状态机中的状态转换图。

工作流:用户设置和个人资料更新

Ory.sh/Kratos作为示例架构

2. 第三方OAuth2

OAuth2让用户或客户端经历四个主要工作流(不确定使用哪个?请查看这个),如

  1. Web的授权码授权
  2. 移动的隐式授权
  3. 传统应用的资源拥有者密码凭证授权
  4. 后端应用流的客户端凭证授权

然后最终获得访问令牌和刷新令牌

  1. 访问令牌是短期有效的,因此如果被泄露,攻击窗口很短
  2. 刷新令牌仅在与客户端ID和密钥结合使用时有效

假设在这个工作流中涉及许多实体 - 客户端、资源拥有者、授权服务器、资源服务器、网络等。更多的实体会增加被攻击的暴露。一个全面的协议应该考虑所有边缘情况。例如,如果网络不是HTTPs /不能完全信任怎么办?

OpenID Connect是基于OAuth2的身份协议,它定义了可定制的RESTful API,供产品实现单点登录(SSO)。

在这些工作流和令牌处理过程中有很多棘手的细节。不要重新发明轮子。

3. 多因素身份验证

问题:凭证填充攻击

用户倾向于在多个网站上重复使用相同的用户名和密码。当其中一个网站遭遇数据泄露时,黑客会使用这些泄露的凭证对其他网站进行暴力攻击。

  • 多因素身份验证:短信、电子邮件、电话语音一次性密码、身份验证器一次性密码
  • 速率限制器、失败禁止和异常检测

挑战:电子邮件或短信的送达率差

  • 不要将营销电子邮件渠道与事务性渠道共享。
  • 语音一次性密码通常具有更好的送达率。

5. 无密码

  1. 生物识别:指纹、面部识别、声音识别
  1. 二维码
  • SQRL标准
  • 另一种实现方式:

  1. 推送通知

客户端如何订阅服务器的状态?短轮询、长轮询、WebSocket或服务器推送事件。

4. 市场上的供应商

不要重新发明轮子。

6. 优化

挑战1:Web登录非常慢或根本无法提交登录表单。

  • JS包对于移动Web来说太大
    • 构建一个轻量级的PWA版本的SPA(单页面Web应用)。无论如何使包小 - 例如,preact或inferno
    • 或者根本不使用SPA。简单的MPA(多页面Web应用)与原始HTML表单提交效果很好
  • 浏览器兼容性
    • 使用BrowserStack或其他工具在不同浏览器上进行测试
  • 数据中心距离太远
    • 将静态资源放到边缘/CDN,并通过Google骨干网中继API请求
    • 建立一个本地数据中心 😄

请参见Web应用交付优化以获取更多信息

挑战2:账户接管

挑战3:账户创建耗时过长

当后端系统变得过于庞大时,用户创建可能会分散到许多服务,并在不同数据源中创建许多条目。在注册结束时等待15秒感觉很糟糕,对吧?

  1. 收集并逐步注册
  2. 异步

授权

isAuthorized(subject, action, resource)

1. 基于角色的访问控制(RBAC)

2. 基于策略的访问控制(PBAC)

{
"subjects": ["alice"],
"resources": ["blog_posts:my-first-blog-post"],
"actions": ["delete"],
"effect": "allow"
}

挑战:单点故障和级联故障

  • 预处理和缓存权限
  • 利用请求上下文
    • 假设:数据中心内部的请求是可信的,而外部请求则不可信
  • 失败开放与失败关闭

隐私

1. 个人可识别信息(PII)、受保护的健康信息(PHI)、支付卡行业(PCI)

西方文化有尊重隐私的传统,尤其是在纳粹杀害数百万人之后。 以下是一些典型的敏感数据类型:个人可识别信息(PII)、受保护的健康信息(PHI,受HIPAA监管)和信用卡或支付卡行业(PCI)信息。

2. 差分隐私

仅仅删除敏感信息可能不足以防止与其他数据集相关的数据泄露。

差分隐私帮助分析师从包含个人信息的数据库中提取数据,但仍然保护个人隐私。

3. 去中心化身份

为了将身份与集中式身份提供者及其相关的敏感数据解耦,我们可以使用去中心化身份(DID)

  • 它本质上采用URN格式:did:example:123456789abcdefghijk
  • 它可以从非对称密钥及其目标业务领域派生。
    • 与传统方式不同,它不涉及您的个人信息
    • 请参见DID方法了解其如何与区块链一起工作。
  • 它通过以下方式保护隐私
    • 为不同目的使用不同的DID
    • 选择性披露/可验证声明

想象一下,艾丽斯有一个州政府颁发的DID,想在不透露真实姓名和确切年龄的情况下购买一些酒。

饮酒

一个DID解决方案:

  • 艾丽斯有一个身份档案,包含did:ebfeb1f712ebc6f1c276e12ec21、姓名、头像URL、生日和其他敏感数据。
  • 创建一个声明,表明did:ebfeb1f712ebc6f1c276e12ec21超过21岁
  • 一个受信任的第三方签署该声明,使其成为可验证声明
  • 使用可验证声明作为年龄的证明

总结

本文概述了微服务中的身份验证和授权,您不必记住所有内容才能成为专家。以下是一些要点:

  1. 遵循标准协议,不要重新发明轮子
  2. 不要低估安全研究人员/黑客的力量
  3. 完美是很难的,也不必完美。全面优先考虑您的开发工作

设计在线评测系统或 Leetcode

· 阅读需 4 分钟

需求

在线评测系统主要是一个可以远程执行代码的地方,用于教育或招聘目的。在这个设计中,我们专注于设计一个用于面试准备的 OJ,类似于 Leetcode,具有以下需求:

  • 它应该具有核心的 OJ 功能,如获取问题、提交解决方案、必要时编译和执行。
  • 它应该具有高可用性,采用 异步 设计,因为运行代码可能需要时间。
  • 它应该具备水平扩展性,简单易扩展。
  • 它应该稳健且安全,以执行不受信任的源代码。

架构

下面的架构以异步执行的队列和安全执行的沙箱为特色。每个组件都是可单独部署和扩展的。

设计在线评测系统

组件

表现层

用户代理通常是一个像 coderoma.com 这样的网页或移动应用。它显示问题描述,并为用户提供一个代码编辑器来编写和提交代码。

当用户提交代码时,客户端将获得一个令牌,因为这是一个异步调用。然后客户端轮询服务器以获取提交状态。

API

请参见 公共 API 选择 以了解我们可以选择的协议。让我们在这里设计接口本身,并以 GraphQL 为例:

type Query {
problems(id: String): [Problem]
languageSetup(id: String!, languageId: LanguageId!): LanguageSetup
submission(token: String!) Submission
}

type Mutation {
createSubmission(
problemId: String!
code: String!
languageId: LanguageId!
): CreatedSubmission!
}

enum LanguageId {
JAVA
JS
ELIXIR
# ...
}


type Problem {
id: String!
title: String!
description: String!
supportedLanguages: [Float!]!
}

type LanguageSetup {
languageId: LanguageId!
template: String!
solutions: [String!]!
}

type Status {
id: Float!
description: String!
}

type Submission {
compileOutput: String
memory: Float
message: String
status: Status
stderr: String
stdout: String
time: String
token: String
}

type CreatedSubmission {
token: String!
}

API 层将提交记录在数据库中,将其发布到队列中,并返回一个令牌供客户端将来参考。

代码执行引擎

代码执行引擎 (CEE) 轮询队列以获取代码,使用沙箱来编译和运行代码,并解析编译和执行的元数据。

沙箱可以是 LXC 容器、Docker、虚拟机等。我们可以选择 Docker,因为它易于部署。

Coderoma.com

我最近在学习 Elixir 并创建一个在线评测系统 coderoma.com 以进行日常练习。它现在支持 Elixir 和 JavaScript。我正在添加更多语言(如 Java)和问题。

我们可能会举办未来的活动来提高您的编码技能。请加入我们,访问 https://t.me/coderoma 以获取英语社区,或使用您的微信扫描以下二维码 onetptp 并回复 刷题 以加入中文社区。

onetptp

信用卡处理系统

· 阅读需 2 分钟

信用卡支付处理

5 个参与方

  1. 持卡人:卡的授权用户。例如,任何拥有信用卡的人。
  2. 发卡机构:向其用户发放信用卡的机构。例如,Chase、BOA、Discover 等。
  3. 商户:提供产品或服务并接受信用卡支付的实体。例如,Uber、Doordash 等。
  4. 收单机构:向商户提供卡服务的机构。例如,BOA 商户服务、Chase Paymentech。
  5. 电子支付网络:收单机构和发卡机构的清算中心。例如,VisaNet、MasterCard、Discover、American Express 等。

Square 和 Stripe 是收单机构吗?不,它们是支付聚合商。

2 个工作流程

  1. 授权:持卡人向商户提供卡片/卡片信息,商户处理后将(卡号、金额、商户 ID)发送给收单机构。收单机构通过电子支付网络联系发卡机构。发卡机构检查信用额度和防欺诈系统,然后授权或拒绝交易。

  2. 清算/结算

    1. 批处理:在营业结束时,商户向收单机构请求一批支付信息进行清算。
    2. 清算和结算:收单机构通过电子支付网络与发卡机构协调清算,发卡机构结算资金并为持卡人记账交易。

设计负载均衡器或 Dropbox 修补程序

· 阅读需 5 分钟

需求

互联网规模的网络服务处理来自全球的高流量。然而,单个服务器在同一时间只能处理有限数量的请求。因此,通常会有一个服务器集群或大型服务器集群来共同承担流量。问题来了:如何路由这些请求,以便每个主机能够均匀地接收和处理请求?

由于从用户到服务器之间有许多跳数和负载均衡器层,因此这次我们的设计要求是

注意:如果服务 A 依赖于(或消费)服务 B,则 A 是 B 的下游服务,而 B 是 A 的上游服务。

挑战

为什么负载均衡很难?答案是很难收集准确的负载分布统计数据并相应地采取行动。

按请求分配 ≠ 按负载分配

随机和轮询通过请求分配流量。然而,实际负载并不是每个请求 - 有些在 CPU 或线程利用率上很重,而有些则很轻。

为了更准确地评估负载,负载均衡器必须维护每个后端服务器的观察到的活动请求数量、连接数量或请求处理延迟的本地状态。基于这些信息,我们可以使用诸如最少连接、最少时间和随机 N 选择等分配算法:

最少连接:请求被传递给活动连接数最少的服务器。

基于延迟(最少时间):请求被传递给平均响应时间最少和活动连接数最少的服务器,同时考虑服务器的权重。

然而,这两种算法仅在只有一个负载均衡器的情况下效果良好。如果有多个负载均衡器,可能会出现 羊群效应。也就是说,所有负载均衡器都注意到某个服务瞬时更快,然后都向该服务发送请求。

随机 N 选择(在大多数情况下 N=2 / 也称为 二选一的力量):随机选择两个并选择两个中的更好选项,避免选择更差的选项

分布式环境

本地负载均衡器对全局下游和上游状态并不知情,包括

  • 上游服务负载
  • 上游服务可能非常庞大,因此很难选择正确的子集来覆盖负载均衡器
  • 下游服务负载
  • 各种请求的处理时间很难预测

解决方案

有三种选项可以准确收集负载统计数据,然后采取相应的行动:

  • 集中式和动态控制器
  • 分布式但具有共享状态
  • 在响应消息或主动探测中附加服务器端信息

Dropbox Bandaid 团队选择了第三种选项,因为它很好地适应了他们现有的 随机 N 选择 方法。

然而,他们并没有像原始的 随机 N 选择 那样使用本地状态,而是通过响应头使用来自后端服务器的实时全局信息。

服务器利用率:后端服务器配置了最大容量并计算正在进行的请求,然后计算利用率百分比,范围从 0.0 到 1.0。

需要考虑两个问题:

  1. 处理 HTTP 错误:如果服务器快速失败请求,它会吸引更多流量并导致更多失败。
  2. 统计衰减:如果服务器的负载过高,则不会将请求分配到该服务器,因此服务器会被卡住。他们使用反向 sigmoid 曲线的衰减函数来解决此问题。

结果:请求更加均衡

并发模型

· 阅读需 1 分钟

  • 单线程 - 回调、承诺、可观察对象和 async/await:原生 JS
  • 线程/多处理,基于锁的并发
    • 保护临界区与性能
  • 通信顺序进程 (CSP)
    • Golang 或 Clojure 的 core.async
    • 进程/线程通过通道传递数据。
  • 演员模型 (AM):Elixir、Erlang、Scala
    • 本质上是异步的,并且具有跨运行时和机器的位置信息透明性 - 如果您有演员的引用 (Akka) 或 PID (Erlang),您可以通过邮箱向其发送消息。
    • 通过将演员组织成监督层次结构来实现强大的容错能力,您可以在其确切的层次结构级别处理故障。
  • 软件事务内存 (STM):Clojure、Haskell
    • 类似于 MVCC 或纯函数:提交 / 中止 / 重试

设计实时联想搜索或自动完成功能

· 阅读需 3 分钟

需求

  • 为社交网络(如 Linkedin 或 Facebook)提供实时 / 低延迟的联想和自动完成功能
  • 使用前缀搜索社交资料
  • 新添加的账户在搜索范围内即时出现
  • 不是用于“查询自动完成”(如 Google 搜索框下拉),而是用于显示实际的搜索结果,包括
    • 通用联想:来自全球排名方案(如人气)的网络无关结果。
    • 网络联想:来自用户的第一和第二度网络连接的结果,以及“你可能认识的人”评分。

Linkedin 搜索

架构

多层架构

  • 浏览器缓存
  • 网络层
  • 结果聚合器
  • 各种联想后端

Cleo 架构

结果聚合器

这个问题的抽象是通过前缀和术语在大量元素中查找文档。解决方案利用这四种主要数据结构:

  1. InvertedIndex<前缀或术语, 文档>:给定任何前缀,找到所有包含该前缀的文档 ID。
  2. 为每个文档准备一个 BloomFilter<前缀或术语>:随着用户输入的增加,我们可以通过检查它们的布隆过滤器快速过滤掉不包含最新前缀或术语的文档。
  3. ForwardIndex<文档, 前缀或术语>:之前的布隆过滤器可能会返回假阳性,现在我们查询实际文档以拒绝它们。
  4. scorer(文档):相关性:每个分区返回所有真实命中及其评分。然后我们进行聚合和排名。

性能

  • 通用联想:延迟 < = 1 毫秒在一个集群内
  • 网络联想(第一和第二度网络的超大数据集):延迟 < = 15 毫秒
  • 聚合器:延迟 < = 25 毫秒

设计 Airbnb 或酒店预订系统

· 阅读需 4 分钟

需求

  • 对于客人
    • 按位置、日期、房间数量和客人数量搜索房间
    • 获取房间详情(如图片、名称、评论、地址等)和价格
    • 按日期和房间 ID 从库存中支付并预订房间
      • 作为访客结账
      • 用户已登录
    • 通过电子邮件和移动推送通知进行通知
  • 对于酒店或租赁管理员(供应商/房东)
    • 管理员(接待员/经理/租赁所有者):管理房间库存并帮助客人办理入住和退房
    • 清洁工:定期清理房间

架构

组件

库存 <> 预订 <> 用户(客人和房东)

供应商在库存中提供他们的房间详情。用户可以相应地搜索、获取和预订房间。在预订房间后,用户的付款也会更改 reserved_roomstatus。您可以在 这篇文章 中查看数据模型。

如何查找可用房间?

  • 按位置:使用 空间索引 进行地理搜索,例如 geo-hash 或四叉树。
  • 按房间元数据:在查询数据库时应用过滤器或搜索条件。
  • 按入住和退房日期及可用性。两种选择:
    • 选项 1:对于给定的 room_id,检查今天或更晚的所有 occupied_room,将数据结构转换为按天的占用数组,最后在数组中找到可用的时间段。这个过程可能会耗时,因此我们可以建立可用性索引。
    • 选项 2:对于给定的 room_id,始终为占用的日期创建一个条目。这样更容易按日期查询不可用的时间段。

对于酒店,同步数据

如果这是一个酒店预订系统,那么它可能会发布到 GDS、聚合器和批发商等预订渠道。

酒店预订生态系统

为了在这些地方同步数据,我们可以

  1. 使用幂等性重试来提高外部调用的成功率,并确保没有重复订单
  2. 向外部供应商提供 webhook 回调 API,以在内部系统中更新状态。

支付与记账

数据模型:复式记账

为了执行支付,由于我们调用外部支付网关,如银行或 Stripe、Braintree 等,保持不同地方的数据同步至关重要。我们需要在交易表和外部银行及供应商之间同步数据。

提醒/警报的通知者

通知系统本质上是一个延迟调度器(优先队列 + 订阅者)加上 API 集成。

例如,每日定时任务将查询数据库以获取今天要发送的通知,并按日期将其放入优先队列。订阅者将从优先队列中获取最早的通知,并在达到预期时间戳时发送。如果没有,则将任务放回队列,并让 CPU 空闲以进行其他工作,如果今天有新的警报添加,可以中断此过程。

设计 Memcached 或内存中的 KV 存储

· 阅读需 2 分钟

需求

  1. 高性能,分布式键值存储
  • 为什么分布式?
    • 答:为了存储更大规模的数据
  1. 用于小数据对象的内存存储
  2. 简单的服务器(将复杂性推给客户端),因此可靠且易于部署

架构

大局:客户端-服务器

  • 客户端
  • 给定一组 Memcached 服务器
  • 根据键选择服务器
  • 服务器
  • 将 KV 存储到内部哈希表中
  • LRU 驱逐

键值服务器由固定大小的哈希表 + 单线程处理程序 + 粗粒度锁组成

哈希表

如何处理冲突?主要有三种解决方法:

  1. 分离链:发生冲突的桶链表中包含相同索引的多个条目,您可以始终将新发生冲突的键值对附加到列表中。
  2. 开放寻址:如果发生冲突,转到下一个索引,直到找到可用的桶。
  3. 动态调整大小:调整哈希表的大小并分配更多空间;因此,冲突发生的频率会降低。

客户端如何确定查询哪个服务器?

请参见 数据分区与路由

如何使用缓存?

请参见 键值缓存

如何进一步优化?

请参见 Facebook 如何扩展其社交图存储?TAO

如何编写稳健的代码?

· 阅读需 2 分钟

他喜欢它

  1. 同理心 / 视角转换是最重要的。

    1. 意识到代码首先是为人类阅读而编写的,然后才是为机器执行。
    2. 软件是如此“柔软”,有很多方法可以实现同一目标。关键在于做出适当的权衡,以满足需求。
    3. 发明与简化:Apple Pay RFID 与 微信扫码二维码。
  2. 选择可持续的架构,以减少每个功能的人力资源成本。

  1. 采用模式和最佳实践。

  2. 避免反模式

    • 缺少错误处理
    • 回调地狱 = 意大利面条代码 + 不可预测的错误处理
    • 过长的继承链
    • 循环依赖
    • 过于复杂的代码
      • 嵌套的三元操作
      • 注释掉未使用的代码
    • 缺少国际化,特别是 RTL 问题
    • 不要重复自己
      • 简单的复制粘贴
      • 不合理的注释
  3. 有效的重构

    • 语义版本
    • 永远不要对非主要版本引入破坏性更改
      • 两腿变更