架构之路
经历
- 华耀面临的难题是非常具体的技术问题,面向协议,语言层面。需要对tls,密码学基础知识,计算机体系结构比较熟悉。一个难题的例子是移植ZX5580CPU遇到的汇编无符号证书除法稳定出错的问题,表现形式为证书生成失败,难度在于从私钥/公钥计算,证书签发中等一系列流程怎么精确找到出错的地点,毕竟出错的地方是一个特定条件才会触发的div汇编除法错误。
- 技术实现
- 标记用户可用的行为,规定哪些行为是安全的,哪些行为是不安全的,并且将各种行为记录日志。这种行为是不能过于破坏安全特性的。
- 从实现者的角度,保证安全准则/逻辑没有漏洞,不会泄露客户的敏感信息。实际上我们自己规定了一些准则,按照这些准则执行开发。
- 从产品的角度,来提供一个完整系统,保证端到端加密,它的逻辑/设计理念必须是连续的。(比方说我们对0-RTT的争吵)
- 针对单独产品,怎么实现稳定性和可靠性的考虑
- 硬件方面
- 磁盘RAID,双路电源。
- 软件层面
- 针对错误的输入,比方说一些奇特的mbuf数据包(网络数据包)会对数据的格式做校验(我们系统出过几次coredump)。此外会限制ICMP PING RESPONSE的数量为200/s
- 针对失控程序占用一些CPU资源,内存,磁盘,网络空间和网络的问题,我们的进程在每个核上每隔一段时间就回去喂狗,软的watchdog。如果一定时间watchdog没有喂到,就会出发NMI,从而系统PANIC进而重启。
- 针对系统依赖的服务变慢,实际上不单纯是依赖的服务,对ATCP而言,还包括后面的服务。我们会有一个其他的进程定期去检查后面服务的状态,如果采用最短有效,那么就回去访问当前最高可用的机器。除此之外还有特定的健康检查进程去更新后台RS的状态,如果后台RS无效,那么我们会直接拒绝让rs的连接走这个服务,提供可靠性。
- 会有一个守护进程去检查进程的活跃状态,如果某个工作进程挂掉了,那么会被守护进程拉起来。
- 针对异常问题,我们统一了错误码,整体都是RST报文里面的WIN字段
- 针对安全1,无论是客户端证书还是后面的rs(尤其是双向,)复用的时候我们会比较客户端TICKET IDENTITY LIFETIME和我们的TICKET IDENTITY LIFETIME的差别,如果过大我们就拒绝复用(复用不需要证书),让它走完整握手。
- 针对安全2,TLS1.3下,我们拒绝0-RTT的时候会检查收到的错误的0-RTT报文数量,太多的话就直接拒绝连接。
- 针对安全3,对于证书链的校验执行最严格的审查,任何证书链必须里面的一环必须可验证/有效/,而且不能是全局的ROOT CA(为了可靠,我们和MOZILLA ROOT CA开源的保持一致),必须是针对per vhost导入的CA,从而执行最严格校验。
- 针对安全4,OCSP/CRL证书的校验,正常情况下如果OCSP服务器连接不上,没办法验证证书有效无效的时候都是允许证书通过验证的。我们采用了最严格校验,如果没连上直接拒绝。
- 硬件方面
- 技术实现
- 美团面对的问题一部分集中在语言层面,另一部分集中在是内部C/C++基础组件混乱情况下的工程适配。比方说不同组GCC版本不同,有4.8或者5.6,高版本用我们低版本编出来的包稳定crash,因为一些数据结构变了,所以数据结构的锁位置变了,但是代码还是老的地方改变触发异常
- 职业规划实现:实现功能的时候,能够筛选产品,评估缺陷和不足(针对特定技术)。也就是说一方面提出技术的评估,另一方面提出结果的评估。这个是对业界的理解。
- 轻舟面对的问题就从具体语言层面,变为结构设计,组件的划分,还有一些是分布式系统性通用问题。难题是复杂环境下问题排查和功能取舍,举个功能取舍简单的例子,仿真测试是轻舟CI最后一环,它使用cpfscache对象存储自动热更新,但更新线程有限,遇到大量数据需要会拖崩cpfs,无法挂载。功能取舍就是舍弃掉对全部数据热更新,只维护热点数据,更新采用定期手动更新的方式。另一个比方说 轻舟从微型公司到小中型还有很多问题,比方说快速重试,编译服务用户限流
- 技术实现
- 分布式的快速失败,快速重试具体实践,限流,功能取舍
- 职业规划实现
- 针对一个研发的体系,(实际上是抄的)能够制定一套安全的开发准则,够制定一套安全的设计准则,能够制定一套安全的审计准则
- 给出足够简单并且有效(逻辑可推理)的解决方案。比方说安全,在有KMS的参与下做授权,直接就是授权关键密钥
- 技术实现
- 理想
- 攻击检测样本怎么搭建?请求包含哪些信息?
0 架构的基本思路
做事做人的原则
- 拥抱现实,应对现实
- 真相(或者更精确地说,对现实的准确理解)是任何良好结果的根本依据,要做到头脑极度开放、极度透明,不要担心其他人的看法,使之成为你的障碍
- 观察自然,学习现实规律,不要固守你对事物“应该”是什么样的看法,这将使你无法了解真实的情况,一个东西要“好”,就必须符合现实的规律,并促进整体的进化,这能带来最大的回报。
- 在你不擅长的领域请教擅长的其他人,依赖“全部证据”,这是一个你无论如何都应该培养的出色技能,这将帮助你建立起安全护栏,避免自己做错事。
- 做到头脑极度开放,谨记,你是在寻找最好的答案,而不是你自己能得出的最好答案。
- 一个系统有要素,关联和功能三个东西构成。因此,系统具备层次性,自组织和适应性(弹性),因此要努力培养系统的弹性
做事的方法和原则
- 原则,要清楚,单一因素直接引起后果是程序员的线性,或者说理性思维。系统往往是复杂系统,多方面糅合。除了从上向下的方式去追求通用原则,从下往上排查实际,具体的流程才是认清楚系统多样性的最好方法。在非线性的世界,不要用线性的思维模式
- 原则:系统分为三个要素,要素,联系和功能。我们平时重点应该是关注联系,因为是联系提供了系统的三个特征(适应性,层次性,自组织)里面的适应性。所以关注系统不应该看存量,而应该看适应性
- 方法:用四步流程实现你的人生愿望:
- 有明确的目标,排列优先顺序,不要混淆目标和欲望
- 找出问题,要精准地找到问题所在,不要把问题的某个原因误认为问题本身;区分大问题和小问题;诊断问题,找到问题的根源;先把问题是什么弄明白,再决定怎么做;区分直接原因和根本原因
- 通过快速试错以适应现实是无价的,拥有灵活性并自我归责,那么几乎没有什么能阻止你成功
- 建立清晰的衡量标准来确保你在严格执行方案,很多时候如果不能建立衡量标准,那么可能就不应该定做什么的目标
- 关键是要做一个清晰的“产品定义‘,即要做什么出来
- 学习如何有效决策,要认识到
- 影响好决策的最大威胁是有害的情绪
- 决策是一个两步流程(先了解后决定)
- a. 综合分析眼前的形势你能做的最重要的决定之一是决定问谁。
- b.不要听到什么信什么。
- c.所有东西都是放在眼前看更大。
- d.不要夸大新东西的好处。
- e.不要过度分析细节
- f.不要过于着急做决定
- 始终记住改善事物的速度和水平,以及两者的关系
- 谨记“80/20法则”,并明白关键性的“20%”是什么。
- 不必过于精确,不要做完美主义者,对于决策的后果而言,如果收益比付出要大,那么这个事情就是可以做的
- 高效地综合考虑各个层次a.用“基线以上”和“基线以下”来确定谈话位于哪一层。b.谨记,决策需要在合理的层次做出,但也应在各层次之间保持一致。
- 不要把可能性当作概率
- 从上往下学习
- 这里有一个批判性思维
- 问“五个为什么”,
- 谁从中获益?
- 有什么背景
- 为什么这是个问题?是否存在一个基础模型,这个模型这么工作?
- 什么时候可以从哪里工作起来,不要停留在一阶思维,要进行二阶思维:当它结束后还会发生什么?
- 如何找到机会
- 如何找到机会
- 首先你得知道什么是好的
- 然后思考为什么目前好的东西为啥还没普及
- 另外一个方面,寻找附加价值。比方说充电桩
- 因为充电速度太慢,所以不能直接从充电翻桌率盈利
- 但是可以从补贴上面拿到赚的钱
- 降维打击
- 多样性对抗
- 如何找到机会
- 如何找到研究的idea?
-
下面的内容为:《How to Look for Ideas in Computer Science Research》
-
阅读其它的论文或者关键点,学习如何理解识别research idea的形成模式:
- 填表法:
- 拓展:
-
目前企业的根本实际上并不是做善事,或者出发时是善良的,但是附加是邪恶的。对企业而言,付费的才是真正的客户,如果你是免费用户,那么“从实际”的角度来说,你是产品。
通用工具库:
- 分治思想
- 分库分表
- 读写分离
- 冷热分离
- 系统的八大陷阱
- 政策阻力:治标不治本。这种问题往往是因为不同参与者有不同的目标,将系统存量往
- 解决办法,重新制定更大的总体目标,让参与者突破各自的有限理性,追求共同目标
- 政策阻力:治标不治本。这种问题往往是因为不同参与者有不同的目标,将系统存量往
-
系统的12大变革方式
- 基础知识:网络分布式八宗罪
- 程序员主要涉及到两个价值观:行为(behavior)和结构(structor),一半需求要的是行为,而我们关注的是结构,实际上我们应该讲机制和实现相分离,我们需要的是机制,而不是具体的实现
- 更精确地说让高层策略与实现细节脱钩,使其策略部分完全不需要关心底层细节,当然也不会对这些细节有任何形式的依赖
- 转变观念,将问题从“是或否”的二元属性,转变为可以按照不同强度分开讨论的多元属性,在确保代价的情况下获得一定的收益。总之从具体的操作上面的变成问题,上升为一个全局权衡的架构问题。
- 通用手段:先将满足不同需求的代码分组(即 SRP),然后再来调整这些分组之间的依赖关系(即 DIP)
进步的手段:
每年学习一门新的语言
每月阅读一本技术书
每月阅读一本非技术书
与时俱进
0.5 Clean-Architecture-zh学习
2 两个价值观
程序员主要涉及到两个价值观:行为(behavior)和结构(structor),一般需求要的是行为,而程序员需要关注结构。只关注行为不关注结构会最终导致失序
- 行为:行为是其最直观的价值维度。程序员的工作就是让机器按照某种指定方式运转,给系统的使用者创造或者提高利润。简单来说就是需求
- 结构(架构):当需求方改变需求的时候,随之所需的软件变更必须可以简单而方便地实现。变更实施的难度应该和变更的范畴(scope)成等比关系,而与变更的具体形状(shape)无关。因此程序员需要关心结构( The difficulty in making such a change should be proportional only to the scope of the change, and not to the shape of the change.)
行为和结构哪个重要呢?按照艾森豪威尔的紧急/重要矩阵
I have two kinds of problems, the urgent and the important. The urgent are not important, and the important are never urgent.
一般来说,事情可以为分为四种
- 重要且紧急
- 重要不紧急
- 不重要但紧急
- 不重要且不紧急
软件的系统架构——那些重要的事情——占据了该列表的前两位,而系统行为——那些紧急的事情——只占据了第一和第三位。功能从来都是不重要但是紧急,但是业务部门原本就是没有能力评估系统架构的重要程度的,这本来就应该是研发人员自己的工作职责!所以,平衡系统架构的重要性与功能的紧急程度这件事,是软件研发人员自己的职责。
3 编程范式
- 结构化编程
- 面向对象编程:很多OO编程语言声称面向对象就是封装,继承和多态,然后封装本质是无需了解本质,比方说.h泄露了内部信息。目前并不符合,而多态才是关心的重点,因此什么是OO?业界在这个问题上存在着很多不同的说法和意见。然而对一个软件架构师来说,其含义应该是非常明确的:面向对象编程就是以对象为手段来对源代码中的依赖关系进行控制的能力,这种能力让软件架构师可以构建出某种插件式架构,让高层策略性组件与底层实现性组件相分离,底层组件可必编译成插件,实现独立于高层组件的开发和部署。
- 函数式编程:
4 设计准则
SOLID准则:我自己加一个准则
-
SRP:A module should be responsible to one, and only one, actor。应当只服务于一种角色?举个例子:
- 某个工资管理程序中的 Employee 类有三个函数 calculatePay()、reportHours() 和 save();calculatePay() 函数是由财务部门制定的,他们负责向 CFO 汇报。;reportHours() 函数是由人力资源部门制定并使用的,他们负责向 COO 汇报。;save() 函数是由 DBA 制定的,他们负责向 CTO 汇报。;这三个函数被放在同一个源代码文件,即同一个 Employee 类中,程序员这样 做实际上就等于使三类行为者的行为耦合在了一起,这有可能会导致 CFO 团队的命令影响到 C 00 团队所依赖的功能。这就违背了SRP原则,因为任何改动可能带来给其他团队的副作用
- 解决方法非常简单,直接拆分为角色为三种不同的类型,而不是对基类Employee
- 在组件层面,我们可以将其称为共同闭包原则(Common Closure Principle),在软件架构层面,它则是用于奠定架构边界的变更轴心(Axis of Change)。
-
OCP准则:A software artifact should be open for extension but closed for modification.该怎么做呢?
- 先将满足不同需求的代码分组(即 SRP),然后再来调整这些分组之间的依赖关系(即 DIP)。
- 接下来,我们就该修改其源代码之间的依赖关系了。这样做的目的是保证其中一个操作被修改之后不会影响到另外一个操作。同时,我们所构建的新的组织形式应该保证该程序后续在行为上的扩展都无须修改现有代码。换句话说保证所有组件之间的关系都是单向依赖的
- 让我们再来复述一下这里的设计原则:如果 A 组件不想被 B 组件上发生的修改所影响,那么就应该让 B 组件依赖于 A 组件。
- 总之:OCP 是我们进行系统架构设计的主导原则,其主要目标是让系统易于扩展,同时限制其每次被修改所影响的范围。实现方式是通过将系统划分为一系列组件,并且将这些组件间的依赖关系按层次结构进行组织,使得高阶组件不会因低阶组件被修改而受到影响。
-
LSP准则:What is wanted here is something like the following substitution property: If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.1
-
举一个反例来表示这个,正方形如果是长方形的子类,那么就会出现违背这种设定的情况,比方说下面的代码就会出现问题
Rectangle r = … r.setW(5); r.setH(2); assert(r.area() == 10);
-
LSP准则不单纯适用于继承,它是一种更广泛的、指导接口与其实现方式的设计原则。
-
LSP 可以且应该被应用于软件架构层面,因为一旦违背了可替换也该系统架构就不得不为此增添大量复杂的应对机制。
-
我怎么感觉LSP有点像是一个pot,got一样的东西,函数蹦床;简单来说就是保证接口是实现无关,直接通用的
-
-
ISP准则:在一般情况下,任何层次的软件设计如果依赖于不需要的东西,都会是有害的。从源代码层次来说,这样的依赖关系会导致不必要的重新编译和重新部署,对更高层次的软件架构设计来说,问题也是类似的。
- 这个我觉得可以类比cpfs,它提供了缓存,更新缓存的功能,功能过于复杂。任何层次的软件设计如果依赖了它并不需要的东西,就会带来意料之外的麻烦。
-
DIP准则:
- 应在代码中多使用抽象接口,尽量避免使用那些多变的具体实现类。这条守则适用于所有编程语言,无论静态类型语言还是动态类型语言。同时,对象的创建过程也应该受到严格限制,对此,我们通常会选择用抽象工厂(abstract factory)这个设计模式。
- 不要在具体实现类上创建衍生类。上一条守则虽然也隐含了这层意思,但它还是值得被单独拿出来做一次详细声明。在静态类型的编程语言中,继承关系是所有一切源代码依赖关系中最强的、最难被修改的,所以我们对继承的使用应该格外小心。即使是在稍微便于修改的动态类型语言中,这条守则也应该被认真考虑。
- 不要覆盖(override)包含具体实现的函数。调用包含具体实现的函数通常 就意味着引入了源代码级别的依赖。即使覆盖了这些函数,我们也无法消除这其中的依赖——这些函数继承了那些依赖关系。在这里,控制依赖关系的唯一办法,就是创建一个抽象函数,然后再为该函数提供多种具体实现。
- 应避免在代码中写入与任何具体实现相关的名字,或者是其他容易变动的事物的名字。这基本上是 DIP 原则的另外一个表达方式。
-
redundancy准则:
- 任务组件应该在支持的情况下能够存储一些方便检索的冗余信息,尤其是管理功能
-
一部分具体的通用设计准则
- 系统的关键数据(这里我推荐使用DDD的实体的概念)要有元信息管理,举个简单的例子,对于仿真平台而言,仿真场景是关键数据,这部分关键数据是需要提供元信息管理的
- 不同功能的组件或者文件的存储和检索,不应该放在一个地方,否则会造成管理问题
- 系统的输入和输出,包括生成的报告,存储的流程文件,都需要有相应的元信息管理。或者至少能够方便的检索和管理
5 设计方法
常见的设计方法有DDD(领域驱动设计)和ADD(属性驱动设计)。初次之外,针对复杂系统,可以采用状态/事件驱动设计
5.1 DDD
领域驱动设计抽象名词一大堆,专家不说人话,说白了DDD是业务架构——根据业务需求设计业务模块及其关系,将业务架构映射到系统架构上,在响应业务变化调整业务架构时,也随之变化系统架构。建议直接阅读https://tech.meituan.com/2017/12/22/ddd-in-practice.html,作为一个直观的参考
简单总结流程如下。
领域驱动设计的一般化流程如下
- 根据需求划分出初步的领域和限界上下文,以及上下文之间的关系;
- 划分领域和限界上下文指的是在特定的前提下,组织各种概念。为什么不用模块等概念,因为这是个业务架构,早于实现架构或者系统架构
- 考虑产品所讲的通用语言,;我们将紧耦合的各自圈在一起,观察他们内在的联系,从而形成对应的界限上下文。形成之后,我们可以尝试用语言来描述下界限上下文的职责,看它是否清晰、准确、简洁和完整(划分出来的限界上下文满足自治的架构单元具备4个要素,即最小完备、自我履行、稳定空间和独立进化)。简言之,限界上下文应该从需求出发,按领域划分。
- 总结来说:
- 怎样识别限界啥下文?从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系。四个步骤来拆分很验证限界上下文的合理性
- 步骤1业务维度拆分
- 使用语义相关性意味着存在相同或相似的领域概念,对应于业务服务描述的名词,如果不同的业务服务操作了相同或相似的对象,即可认为它们存在语义相关性。
- 使用功能相关性体现为领域行为的相关性,但它并非设计意义上领域行为之间的功能依赖,而是指业务服务是否服务于同一个业务目标。
- 步骤2验证拆分合理
- 正交:保证每个限界上下文对外提供的业务能力不能出现雷同,这就需要保证为完成该业务能力需要的领域知识不能出现交叉
- 单一抽象原则:保证一个方法中的所有操作都在同一个抽象层次,违背了单一抽象层次原则的限界上下文会导致概念层次的混乱。一个高抽象层次的概念由于内涵更小,使得它的外延更大,就有可能包含低抽象层次的概念,使得位于不同抽象层次的限界上下文存在概念上的包含关系,这实际上也违背了正交原则。例如,在一个集装箱多式联运系统中,商务上下文与合同上下文就不在一个抽象层次上
- 奥卡姆剃刀原则:如果对识别出来的限界上下文的准确性依然心存疑虑,比较务实的做法是保证限界上下文具备一定的粗粒度。
- 步骤3管理维度
- 步骤4技术维度考校
- 步骤1业务维度拆分
- 怎样识别限界啥下文?从中提取一些术语称之为概念对象,寻找对象之间的联系;或者从需求里提取一些动词,观察动词和对象之间的关系。四个步骤来拆分很验证限界上下文的合理性
- 进一步分析每个上下文内部,识别出哪些是实体,哪些是值对象;
- 实体,实体必然有下面三个东西
- 身份标识:身份标识(identity,简称为ID)是实体对象的必要标志,实体的身份标识就好像每个公民的身份号码,除了帮助我们识别实体的同一性,身份标识的主要目的还是管理实体的生命周期
- 属性:实体的属性用来说明主体的静态特征,并持有数据与状态。通常,我们会依据粒度的粗细将属性分为原子属性与组合属性
- 领域行为:实体拥有领域行为,可以更好地说明其作为主体的动态特征
- 值对象,可以理解为
- 实体,实体必然有下面三个东西
- 对实体、值对象进行关联和聚合,划分出聚合的范畴和聚合根;只能由实体、值对象、领域服务和领域事件表示模型
- 聚合实际上就是类的聚合(引入了实现),控制类的关系具体做法
- 去除不必要的关系:一部分依赖的关系是可以去除的,比方说配送单需要订单的信息,但是实际上配送单真正依赖的是包裹存单,而不是配送单。因此原本的依赖是去除的。
- 降低耦合的强度:
- 一种策略是引入泛化提取通用特征,形成更弱的依赖或关联关系,如Car对汽车的泛化使得Driver可以驾驶各种汽车
- 另一种是降低耦合的细度,比方说订单Order与订单项OrderItem,依赖orderitem,而不是order
- 避免双向耦合:
- 聚合实际上就是类的聚合(引入了实现),控制类的关系具体做法
- 为聚合根设计仓储,并思考实体或值对象的创建方式;
- 在工程中实践领域模型,并在实践中检验模型的合理性,倒推模型中不足的地方并重构。
以DDD为方法,我划分CI试试
5.2 ADD
属性驱动设计
1 远程服务
1.1 RPC的可靠性问题
远程服务也就是RPC,早先的目的实际上是为了让计算机能够与本地方法一样调用远程方法。当时使用socket接口,透明的socket给程序员带来了通信无成本的假象,因此在当时Andrew Tanenbaum发表论文对RPC发起质疑:将本地调用和RPC远程调用当做同样的调用来处理,犯了方向性的错误,将系统间调用透明化,反而会增加程序员工作的复杂度。 简单来说就是使用网络进行分布式运算的八宗罪:
- 网络是可靠的
- 延迟是不存在的
- 带宽是无限的
- 网络是安全的
- 拓扑结构是一成不变的
- 总会有一个管理员
- 不必考虑传输成本
- 网络是同质化的
最终RPC被定性为:RPC应该是一种高层次的或者说语言层次的特征,而不是像IPC那样子低层次或者系统层次的特征。
1.2 RPC要解决的问题
在对RPC的本质有了清醒的认识之后,20世纪80年代中后期,RPC的主要问题被思考出来即
- 如何表示数据:这里的数据包括传递给方法的参数和方法执行之后的返回值,举个简单的例子grpc的protobuf,轻量级rpc支持的json序列化
- 如何传递数据:如何通过网络,在两个网络的endpoint之间相互操作。两个服务交互并不只扔个序列化数据流就完事,比方说异常,超时,安全,认证,授权,事务都得处理。在计算机科学里面有个名词叫做Wire Protocol,比方说java RMI的远程消息交换,JRMP
- 如何表示方法:不同的语言怎么表示一个函数或者方法呢?怎么跨语言定位函数和方法,比方说统一序号啥的。
1.3 REST
提到RPC,不得不提到REST了,REST本质可以理解为对资源的CRUD,实际上去看kubernetes的实现就会发现它实际上就是定义各种资源,然后对资源执行CRUD。如果用RPC的方式考虑问题就是典型的对着函数(方法)编程,而使用REST一般抽象程度比较高。
为了方便理解REST,我们先看看REST的基础概念
- 资源:
- 表征
- 状态
- 转移
- 统一接口
- 超文本驱动
- 自描述信息
REST的编程思想
- CS架构:客户端和服务端分离,提高用户界面的可移植性
- 无状态(核心原则):每个请求都是独立的、自成一个个体,与前后请求无关。如此好处有很多,包含可靠(容易从错误中复原)、高效能与可扩充性(可以将请求交给不同伺服器处理),而元件可以被修改、更动而不会影响到系统整体的运作。
- 可缓存:希望能够缓存一些关键数据,减少多次请求,提高性能
- 分层:这里的分层指的是客户端不需要清楚是不是直接连接到了服务器,中间服务器提供的请求也可用
- 在发出请求的Client 与送出回应的Server 之间可以有好几个Server 中间人(称作Connectors,下面介绍),彼此独立并且不会影响到Request 与Response。
- 统一接口(核心原则):将操作细节抽象出来,降低耦合并提高独立性。这里指的是将软件系统设计的重点放在抽象系统有哪些资源,而不是服务有哪些行为上。这个原则可以类比计算机中对文件管理的操作来理解,管理文件可能会设计删除,修改,移动等操作,这些操作时可数的,且对所有文件是固定统一的。
- 按需代码:这个原则是可选的,简单来说就是指可执行的程序可以从服务端发送到客户端。
最后我们来看看REST的模型评测,也就是RMM,直接参考https://martinfowler.com/articles/richardsonMaturityModel.html的医生的例子
- 0级:完全不rest:接口的功能非常单一,就是申请和返回。得开发多个接口
- 1级:引入了统一资源的概念:开始考虑资源的概念,将请求都包含资源的实体
- 2级:引入统一接口:依赖各种http的状态码来统一的表示是否可以成功的表达接口的状态
- 3级:超媒体控制:完全不依赖任何已知的信息,整个服务自举,比方说输入查询指令之后,返回的接口包含如何了解医生信息,如何预约等接口
REST的不足:
- 只适合做CRUD:因为是面向资源,所以只适合做CRUD,面向过程,面向对象的逻辑更复杂。当然也不是不能用REST表示方法,只不过就得加上一些行为的表示
- REST没有事务的概念
- REST没有传输可靠性的支持,毕竟本来就不是做这个事情的
- REST缺乏对资源做部分和批量处理的能力,
2 事务处理
事务分为多种
-
本地事务:本地事务是指仅操作单一事务资源的、不需要全局事务管理器进行协调的事务。ACID里面的AIC要拆开实现
-
Atomic + Durability
- 实现上
- 常用“Commit Logging”(提交日志),拆分每一步不原子的操作为一系列的日志并记录。
- 细节记录修改数据这个操作所需的全部信息,包括修改什么数据、数据物理上位于哪个内存页和磁盘块中、从什么值改成什么值,等等,以日志的形式——即仅进行顺序追加的文件写入的形式(这是最高效的写入方式)先记录到磁盘中。在日志记录全部都安全落盘,数据库在日志中看到代表事务成功提交的“提交记录”(Commit Record)后,才会根据日志上的信息对真正的数据进行修改,修改完成后,再在日志中加入一条“结束记录”(End Record)表示事务已完成持久化。
- commit log的问题是,因此有ARIES 理论。为了能够在事务提交前,允许变动数据提前写入则称为 STEAL,不允许则称为 NO-STEAL。优化磁盘 I/O 性能。方法:增加了另一种被称为 Undo Log 的日志类型,当变动数据写入磁盘前,必须先记录 Undo Log,注明修改了哪个位置的数据、从什么值改成什么值,等等。以便在事务回滚或者崩溃恢复时根据 Undo Log 对提前写入的数据变动进行擦除。
- 常用“Commit Logging”(提交日志),拆分每一步不原子的操作为一系列的日志并记录。
- 实现上
-
隔离性Isolation
- 写锁:也叫做排他锁,如果数据有加写锁,就只有持有写锁的事务才能对数据进行写入操作,数据加持着写锁时,其他事务不能写入数据,也不能施加读锁。这里要注意:这个是不能写入数据,不能施加读锁
- 读锁:也叫做共享锁,多个事务可以对同一个数据添加多个读锁,数据被加上读锁后就不能再被加上写锁,所以其他事务不能对该数据进行写入,但仍然可以读取。对于持有读锁的事务,如果该数据只有它自己一个事务加了读锁,允许直接将其升级为写锁,然后写入数据。
- 范围锁:对于某个范围直接加排他锁,在这个范围内的数据不能被写入。
隔离性级别
- ANSI/ISO SQL-92中定义的最高等级的隔离级别便是
可串行化
(Serializable)。 可串行化
的下一个隔离级别是可重复读
(Repeatable Read),可重复读
对事务所涉及的数据加读锁和写锁,且一直持有至事务结束,但不再加范围锁。可重复读
比可串行化
弱化的地方在于幻读问题(Phantom Reads),它是指在事务执行过程中,两个完全相同的范围查询得到了不同的结果集。可重复读
的下一个隔离级别是读已提交
(Read Committed),读已提交
对事务涉及的数据加的写锁会一直持续到事务结束,但加的读锁在查询操作完成后就马上会释放。读已提交
比可重复读
弱化的地方在于不可重复读问题(Non-Repeatable Reads),它是指在事务执行过程中,对同一行数据的两次查询得到了不同的结果。读已提交
的下一个级别是读未提交
(Read Uncommitted),读未提交
对事务涉及的数据只加写锁,会一直持续到事务结束,但完全不加读锁。读未提交
比读已提交
弱化的地方在于脏读问题(Dirty Reads),它是指在事务执行过程中,一个事务读取到了另一个事务未提交的数据。
-
-
全局事务
- 全局事务被限定为一种适用于单个(也没说必须单个)服务使用多个数据源场景的事务解决方案。
- 方法:
- 2PC
- 准备阶段:又叫作投票阶段,在这一阶段,协调者询问事务的所有参与者是否准备好提交,参与者如果已经准备好提交则回复 Prepared,否则回复 Non-Prepared。这里所说的准备操作跟人类语言中通常理解的准备并不相同,对于数据库来说,准备操作是在重做日志中记录全部事务提交操作所要做的内容,它与本地事务中真正提交的区别只是暂不写入最后一条 Commit Record 而已,这意味着在做完数据持久化后并不立即释放隔离性,即仍继续持有锁,维持数据对其他非事务内观察者的隔离状态。
- 提交阶段:又叫作执行阶段,协调者如果在上一阶段收到所有事务参与者回复的 Prepared 消息,则先自己在本地持久化事务状态为 Commit,在此操作完成后向所有参与者发送 Commit 指令,所有参与者立即执行提交操作;否则,任意一个参与者回复了 Non-Prepared 消息,或任意一个参与者超时未回复,协调者将自己的事务状态持久化为 Abort 之后,向所有参与者发送 Abort 指令,参与者立即执行回滚操作。对于数据库来说,这个阶段的提交操作应是很轻量的,仅仅是持久化一条 Commit Record 而已,通常能够快速完成,只有收到 Abort 指令时,才需要根据回滚日志清理已提交的数据,这可能是相对重负载的操作。
- 2PC
3 流量治理(分级与分流)
为什么我要郑重其事地写这条?因为我发现任何系统(这个只要是基础服务或者是稍微大一点的分布式系统/单点系统)的分级分流是必然要考虑的!buildfarm服务的机器一直在增加,但是性能依然跟不上用户的脚步,因此我们需要在前面加流量控制,容错处理啥的.
我们目前针对错误采用的就是重试策略,这一次我要采用的流量控制。
3.1 服务容错
Martin Fowler 与 James Lewis 提出的“微服务的九个核心特征”是构建微服务系统的指导性原则,但不是技术规范,并没有严格的约束力。在实际构建系统时候,其中多数特征可能会有或多或少的妥协,譬如分散治理、数据去中心化、轻量级通信机制、演进式设计,等等。但也有一些特征是无法做出妥协的,其中的典型就是今天我们讨论的主题:容错性设计。
3.1.1 容错策略
要落实容错性设计这条原则,除了思想观念上转变过来,正视程序必然是会出错的,对它进行有计划的防御之外,还必须了解一些常用的容错策略和容错设计模式。常见的容错策略有以下几种:
- 故障转移(Failover):关键路径上的服务,均会部署有多个副本。如果调用的服务器出现故障,不会立即返回失败结果,而是自动切换到其他服务副本,尝试其他副本能否返回成功调用的结果,从而保证了整体的高可用性。
- 快速失败(Failfast):部分业务场景是不允许做故障转移的,故障转移策略能够实施的前提是要求服务具备幂等性,对于非幂等的服务,重复调用就可能产生脏数据,应直接返回失败。
- 安全失败(Failsafe):即使旁路逻辑调用实际失败了,也当作正确来返回,自动记录一条服务调用出错的日志备查即可,这种策略被称为安全失败。典型的有审计、日志、调试信息,等等
- 沉默失败(Failsilent):如果大量的请求造成服务失败,认为服务不可用,直接隔离错误的服务提供者,不再分配请求流量,将错误隔离开来,避免对系统其他部分产生影响,此即为沉默失败策略。
- 故障恢复(Failback):故障恢复一般不单独存在,而是作为其他容错策略的补充措施,系统通常默认会采用快速失败加上故障恢复的策略组合:失败之后,返回失败+由系统自动开始异步重试调用。
上面五种以“Fail”开头的策略是针对调用失败时如何进行弥补的,以下这两种策略则是在调用之前就开始考虑如何获得最大的成功概率。
- 并行调用(Forking):“双重保险”或者“多重保险”的处理思路,它是指一开始就同时向多个服务副本发起调用,只要有其中任何一个返回成功,那调用便宣告成功。
- 广播调用(Broadcast):广播调用与并行调用是相对应的,都是同时发起多个调用,但并行调用是任何一个调用结果返回成功便宣告成功,广播调用则是要求所有的请求全部都成功。
比较下表:
容错策略 | 优点 | 缺点 | 应用场景 |
---|---|---|---|
故障转移 | 系统自动处理,调用者对失败的信息不可见 | 增加调用时间,额外的资源开销 | 调用幂等服务 对调用时间不敏感的场景 |
快速失败 | 调用者有对失败的处理完全控制权 不依赖服务的幂等性 | 调用者必须正确处理失败逻辑,如果一味只是对外抛异常,容易引起雪崩 | 调用非幂等的服务 超时阈值较低的场景 |
安全失败 | 不影响主路逻辑 | 只适用于旁路调用 | 调用链中的旁路服务 |
沉默失败 | 控制错误不影响全局 | 出错的地方将在一段时间内不可用 | 频繁超时的服务 |
故障恢复 | 调用失败后自动重试,也不影响主路逻辑 | 重试任务可能产生堆积,重试仍然可能失败 | 调用链中的旁路服务 对实时性要求不高的主路逻辑也可以使用 |
并行调用 | 尽可能在最短时间内获得最高的成功率 | 额外消耗机器资源,大部分调用可能都是无用功 | 资源充足且对失败容忍度低的场景 |
广播调用 | 支持同时对批量的服务提供者发起调用 | 资源消耗大,失败概率高 | 只适用于批量操作的场景 |
3.1.2 容错设计模式
为了实现各种各样的容错策略,开发人员总结出了一些被实践证明是有效的服务容错设计模式,譬如微服务中常见的断路器模式、舱壁隔离模式,重试模式,等等
- 断路器模式:本质是一种快速失败策略的实现方式
- 通过代理(断路器对象)来一对一地(一个远程服务对应一个断路器对象)接管服务调用者的远程请求。断路器会持续监控并统计服务返回的成功、失败、超时、拒绝等各种结果,当出现故障(失败、超时、拒绝)的次数达到断路器的阈值时
- 舱壁隔离模式
- 实现方式
- 线程池方法:为每个服务单独设立线程池,这些线程池默认不预置活动线程,只用来控制单个服务的最大连接数。
- 信号量机制(Semaphore)。控制一个服务并发调用的最大次数,可以只为每个远程服务维护一个线程安全的计数器即可。服务开始调用时计数器加 1,服务返回结果后计数器减 1,一旦计数器超过设置的阈值就立即开始限流,在回落到阈值范围之前都不再允许请求了。
- 细节:以上介绍的是从微观的、服务调用的角度应用的舱壁隔离设计模式,舱壁隔离模式还可以在更高层、更宏观的场景中使用,不是按调用线程,而是按功能、按子系统、按用户类型等条件来隔离资源都是可以的。一般来说,我们会选择将服务层面的隔离实现在服务调用端或者边车代理上,将系统层面的隔离实现在 DNS 或者网关处。
- 实现方式
- 重试模式
- 有可能自己恢复(Resilient,称为自愈,也叫做回弹性)的临时性失灵,网络抖动、服务的临时过载(典型的如返回了 503 Bad Gateway 错误)这些都属于瞬时故障。这些可以用重试
- 使用条件:
- 仅在主路逻辑的关键服务上进行同步的重试,不是关键的服务,尤其不该进行同步重试。
- 仅对由瞬时故障导致的失败进行重试。尽管一个故障是否属于可自愈的瞬时故障并不容易精确判定,比方说没权限就不该重试
- 仅对具备幂等性的服务进行重试。具体服务如何实现并无强制约束力,但我们自己建设系统时,遵循业界惯例本身就是一种良好的习惯。
- 重试必须有明确的终止条件,常用的终止条件有两种:
- 超时终止:并不限于重试,所有调用远程服务都应该要有超时机制避免无限期的等待。
- 次数终止:重试必须要有一定限度,不能无限制地做下去,通常最多就只重试 2 至 5 次。
3.1.3 流量控制
需要妥善解决以下三个问题:
-
依据什么限流?:要不要控制流量,要控制哪些流量,控制力度要有多大,等等这些操作都没法在系统设计阶段静态地给出确定的结论,必须根据系统此前一段时间的运行状况,甚至未来一段时间的预测情况来动态决定。
- 主流系统大多倾向使用 HPS 作为首选的限流指标,它是相对容易观察统计的,而且能够在一定程度上反应系统当前以及接下来一段时间的压力。
- 方法:
- 每秒事务数(Transactions per Second,TPS):TPS 是衡量信息系统吞吐量的最终标准。“事务”可以理解为一个逻辑上具备原子性的业务操作。比方说taobao买东西,真个买完了就是一个t
- 每秒请求数(Hits per Second,HPS):HPS 是指每秒从客户端发向服务端的请求数(请将 Hits 理解为 Requests 而不是 Clicks,国内某些翻译把它理解为“每秒点击数”多少有点望文生义的嫌疑)。
- 每秒查询数(Queries per Second,QPS):QPS 是指一台服务器能够响应的查询次数。如果只有一台服务器来应答请求,
-
具体如何限流?:解决系统具体是如何做到允许一部分请求能够通行,而另外一部分流量实行受控制的失败降级,这必须了解掌握常用的服务限流算法和设计模式。
-
流量计数器:设置一个计算器,根据当前时刻的流量计数结果是否超过阈值来决定是否限流。譬如前面场景应用题中,我们计算得出了该系统能承受的最大持续流量是 80 TPS。但是
- 每一秒的统计流量都没有超过 80 TPS,也不能说明系统没有遇到过大于 80 TPS 的流量压力
- 即使连续若干秒的统计流量都超过了 80 TPS,也不能说明流量压力就一定超过了系统的承受能力。
-
滑动时间窗
-
漏桶:以请求对象作为元素的先入先出队列(FIFO Queue),队列长度就相当于漏桶的大小,当队列已满时便拒绝新的请求进入。漏桶实现起来很容易,困难在于如何确定漏桶的两个参数:桶的大小和水的流出速率。
-
令牌桶:系统在 X 秒内最大请求次数不超过 Y,那就每间隔 X/Y 时间就往桶中放一个令牌,当有请求进来时,首先要从桶中取得一个准入的令牌,然后才能进入系统处理。任何时候,一旦请求进入桶中却发现没有令牌可取了
-
分布式限流:单机限流很好办,指标都是存储在服务的内存当中,而分布式限流的目的就是要让各个服务节点的协同限流,无论是将限流功能封装为专门的远程服务
-
实现
-
请求进入集群时,首先在 API 网关处领取到一定数额的“货币”,将用户 A 的额度表示为 QuanityA。由于任何一个服务在响应请求时都需要消耗集群一定量的处理资源,所以访问每个服务时都要求消耗一定量的“货币”,假设服务 X 要消耗的额度表示为 CostX,那当用户 A 访问了 N 个服务以后,他剩余的额度 LimitN即表示为:
LimitN = QuanityA - ∑NCostX
此时,我们可以把剩余额度 LimitN作为内部限流的指标,规定在任何时候,只要一旦剩余额度 LimitN小于等于 0 时,就不再允许访问其他服务了。此时必须先发生一次网络请求,重新向令牌桶申请一次额度
-
-
-
客户端限流,如果能控制客户端的限流方式那么直接控制客户端的并发
-
-
超额流量如何处理?:超额流量可以有不同的处理策略,也许会直接返回失败(如 429 Too Many Requests),或者被迫使它们进入降级逻辑,这种被称为否决式限流。也可能让请求排队等待,暂时阻塞一段时间后继续处理,这种被称为阻塞式限流。
架构安全
我们重点关注5个点
- 认证
- 授权
- 凭证
- 保密
- 传输
- 验证
2 K8S学习笔记
2.1 基本概念
kubernetes的概念可以拆分为多种
- 资源类
- 某种资源的对象,比方说节点,pod,服务,存储卷。资源对象一般包含一些通用属性比方说版本,类别,标签,名称注解。资源对象的名称,标签,注解是资源对象的元数据
- 与资源对象相关的事物和动作,比方说标签,注解,命名空间,部署,HPA,PVC
- 集群类
- 表示一个由master和node组成的kubernetes集群
- master是集群的控制节点,运行着关键进程
- kube-apiserver,提供http restful api接口的主要服务
- kube-controller-manager,资源对象的控制中心
- kube-echeduler,负责资源调度的进程,
- node,集群里面除了master之外的所有服务器
- kubelet进程
- kube-proxy进程
- 容器runtime,比方说docker
- 多租户隔离:命名空间。
- 命名空间属于kubernetes的集群范畴的资源对象,不同命名空间的资源对象从逻辑上相互隔离。系统相关的资源对象比方说网络组建,dns组件,监控类组件都放在kube-system里面。
- master是集群的控制节点,运行着关键进程
- 表示一个由master和node组成的kubernetes集群
- 应用类
- service类
- pod类
- 高级扩缩容功能HPA
- 高级扩缩容VPA
- 存储类
2.2 核心组件运行原理
核心组件交互实际上可以拆分为api server,controller,scheduler等组件交互流程,实际上可以理解为对etcd的简单crud。注意,这里主要用到的list watch机制
- kube api server原理解析
- kubeapi的listwatch接口
- kubeapi server分为四层
- API
- 访问控制层
- api server的网络隔离
- scheduler原理:
- controler的原理
- kubeproxy机制,三代发展
- 一代
- 二代
- 三代
2.3 节点调度相关
2.4 安全机制
2.5 kubernetes运维管理
2.6 kubernetes开发
3 云工程师
从单体架构迁移到云原生架构,需要考虑的方面很多,虽然云原生是一种文化理念,但是参考下面写的十二因素应用程序和云原生变革理念,就能知道从单体到云原生绝对不是迁移几个接口就完了的
云原生是什么?是一种理念,如果可以建议直接阅读https://tanzu.vmware.com/content/ebooks/migrating-to-cloud-native-application-architectures,或者看看https://lib.jimmysong.io/migrating-to-cloud-native-application-architectures/ 的翻译
云原生程序的架构特性
从传统的单体到云原生架构,需要文化,组织和技术的变革。云原生涉及的应用架构包括如下的方面:
-
十二因素应用程序:云原生应用架构模式的集合,这些模式可以用来说明什么才是云原生
-
代码库
-
依赖:使用可以声明的标准依赖,比方说maven,等。不该有部署环境里面隐式的依赖
-
配置:配置或其他随发布环境(如部署、staging、生产)而变更的部分应当作为操作系统级的环境变量注入。
-
后端服务:后端服务,例如数据库、消息代理应视为附加资源,并在所有环境中同等看待。
-
编译、发布、运行:构建一个可部署的 app 组件并将它与配置绑定,根据这个组件 / 配置的组合来启动一个或者多个进程,这两个阶段是严格分离的。
-
进程:该 app 执行一个或者多个无状态进程(例如 master/work),它们之间不需要共享任何东西。任何需要的状态都置于后端服务(例如 cache、对象存储等)。
-
端口绑定:该应用程序是独立的,并通过端口绑定(包括 HTTP)导出任何 / 所有服务。
-
并发:并发通常通过水平扩展应用程序进程来实现(尽管如果需要的话进程也可以通过内部管理的线程多路复用来实现)。
-
可任意处置性:通过快速迅速启动和优雅的终止进程,可以最大程度上的实现鲁棒性。这些方面允许快速弹性缩放、部署更改和从崩溃中恢复。
-
开发 / 生产平等:通过保持开发、staging 和生产环境尽可能的相同来实现持续交付和部署。
-
日志:不管理日志文件,将日志视为事件流,允许执行环境通过集中式服务收集、聚合、索引和分析事件。
-
管理进程行政或管理类任务(如数据库迁移),应该在与 app 长期运行的相同的环境中一次性完成。
-
-
微服务:独立部署的服务,每个服务只做一件事情
-
自助服务的敏捷基础设施:快速,可重复和一致地提供应用环境和后台服务的平台,这里面有两点要注意:服务化和基础设施IAC
-
基于 API 的协作:发布和版本化的 API,允许在云原生应用架构中的服务之间进行交互
- 这样子,只要不破坏现有的承诺,那么团队可以做快速的部署
-
抗压性:根据压力变强的系统
-
安全:
云原生理念带来变革
- 文化变革
- 云原生意味着将过去曾经的“信息孤岛”,比方说DBA相关的领域构建成共享的工具集、词汇表和沟通结构,以服务于专注于单一目标的文化:快速、安全得交付价值。然后创建激励结构,强制和奖励领导组织朝着这一目标迈进的行为。做产品而不是做项目
- 从间断开发到敏捷开发:每次迭代(实际上是次每个源代码提交!)都被证明可以以自动化的方式部署。我们构建部署流水线,可自动执行每次测试,如果该测试失败,将会阻止生产部署。
- 从集中治理到分散自治:团队个体的分散自治和自主性是通过最小化、轻量级的结构进行平衡的,这些结构在可独立开发和部署的服务之间使用集成模式(例如,他们更喜欢 HTTP REST JSON API 而不是不同风格的 RPC)来实现。这些结构通常会在底层解决交叉问题,如容错。激励团队自己设法解决这些问题,然后自发组织与其他团队一起建立共同的模式和框架。随着整个组织中的最优解决方案出现,该解决方案的所有权通常被转移到云框架 / 工具团队,这可能嵌入到平台运营团队中也可能不会。当组织正在围绕对架构共识进行改革时,云框架 / 工具团队通常也将开创解决方案。
- 组织变革:康威定律和逆康威定律
- 寻求迁移到将业务能力分离的微服务等云原生架构的公司经常采用 Thoughtworks 称之为的“逆康威定律”。他们没有建立一个与其组织结构图相匹配的架构,而是决定了他们想要的架构,并重组组织以匹配该架构。
- 技术变革:技术变革实际上是说现代架构的变化,采用云提供的独特能力设计新的架构,
- 分解数据:
- 将有界上下文与每个服务模式的数据库结合,每个微服务封装、管理和保护自己的领域模型和持久存储。在每个服务模式的数据库中,只允许一个应用程序服务访问逻辑数据存储,逻辑数据存储可能是以多租户集群中的单个 schema 或专用物理数据库中存在。
- 到容器化
- 容器调度相关,这个不用多解释了
- 分解数据:
迁移指南:
- 如何迁移到新的架构呢?
- 首先,新功能用微服务构建
- 使用DDD经典的
- 表现层表现层的目的是为了简化与单体应用接口集成的过程。单体应用设计之初很可能没有考虑这个集成,因此我们引入了表现层来解决这个问题。它没有改变单体应用的模型,这很重要,注意不要将转换和集成问题耦合到一起。
- 适配器我们用适配器来定义 service,用来提供我们需要的新功能。它知道如何获取系统请求并使用协议将请求发送给单体应用的表层。
- 转换器转换器的职责是在单体应用与新的微服务之间进行请求和响应的领域模型转换。
- 因此,技术上我们需要使用分布式系统
- 版本化和分布式配置
- 服务注册发现
- 路由和负载均衡
- 容错
- API网关/边缘服务
结尾
唉,尴尬