[转]一张“无脑”清单告诉你分布式系统代码有多复杂

https://mp.weixin.qq.com/s/abohPG3ACJD34BsGszvrNA

开篇

微服务架构在当今的软件工程领域被广泛采用。同时,采用分布式架构的组织也发现需要考虑分布式故障的附加复杂性,而这种复杂性往往超出实际业务逻辑。

虽然分布式计算的谬误是有据可查的,但对于组织而言并不是一件容易的事情。因此,构建大规模、可靠的分布式系统架构就成为一个难题。作为推论,当我们将网络交互的复杂性引入其中时,在原先非分布式系统中看起来很好的代码就有可能成为一个大问题。

在生产代码中摸爬滚打几年后,遭遇了各种故障模式并且发现导致故障的根源之后,我逐渐能够识别一些更常见的故障模式。由于不同公司以及使用不同的语言堆栈之间存在差异(取决于内部基础设施和工具的成熟度),但是可以从产生问题的原因中总结出一些具有共性的经验。

下面就是我从这些经验中总结出来的一些代码审查指南,这个指南可以形成一份清单,并用来审查分布式环境中与系统间通信相关的代码。虽然这份清单上提到的问题并不适用所有情况,但它们覆盖了代码审查的基本面,可以按照这个清单将问题走查一遍,在此过程中标记缺失的项目以供进一步讨论,利用这种方式发现系统中的问题是非常行之有效的。从这个意义上来说,可以通过这个“无脑清单”来发现大多数问题。

图片

如何调用远程系统

1、当远程系统发生故障时会发生什么?

无论系统设计的多么谨慎,它都会出现故障 – 这是在生产中被印证的事实。故障的发生可能源于代码错误,基础设施问题,流量激增,系统疏于管理等,总之结果是引发故障。调用者如何处理故障将决定整个架构的弹性和健壮性。

定义错误处理路径:必须在代码中明确错误处理路径,而不是让系统在最终用户面前崩溃。这里需要向用户明确指出错误,例如:设计良好的错误页面、带有错误信息的异常日志,以及带有回退机制的断路器等。

制定恢复计划:考虑代码中的每一次远程交互,并弄清楚如何恢复被中断的工作。思考如下价格问题:工作流程是否需要有状态才能从故障点触发?是否将所有失败的有效请求发布到重试队列/数据库表,并在远程系统恢复时重试请求?是否有脚本来比较两个系统的数据库并以某种方式使它们同步?在部署系统之前,是否有一个明确的系统的恢复计划?

2、当远程系统变慢时会发生什么?

这种情况比彻底失败更难办,因为我们不知道远程系统是否在工作。因此需要检查以下事项从而处理这种情况。如果我们使用类似 Istio 的服务网格技术,其中一些问题可以轻松搞定而不需要修改应用程序代码。即便如此,我们也应该关注这些问题。

为远程系统调用设置超时:这包括远程 API 调用、事件发布和数据库调用的超时时间。我在很多代码中发现过这个问题,因此需要检查远程系统是否设置了合理的超时时间,从而避免该系统在无响应时调用者因为等待而浪费资源的情况发生。

超时重试:网络和系统并不是100%可靠的,重试对于系统恢复是非常必要的。重试机制会消除系统交互中的许多“问题”。如果可能,在重试中使用某种补偿机制(固定的、指数的)。在重试机制中添加一点抖动(这里的抖动可以理解为随机重试,例如设置随机的重试时间3-5s重试一次,避免所有调用者一起不断地对被调用者进行重试,导致被调用者的负载增大),这样做可以给被调用系统一些喘息的空间,通过能够保证调用者在负载下获得更好的调用成功率。重试的另一面是幂等性,我们将在本文后面介绍。

使用断路器:一些应用程序并没有预先打包这个功能,但我看到公司内部会编写自己的包装器。如果你有这个需求,一定要实现它,对断路器的投入会让你获益。它会提供明确的框架来定义错误情况下的回退策略。

不要把超时当作请求失败来处理——超时不是失败,而是一种不确定的场景,应该通过一种处理方式来应对这种不确定性。因此需要建立明确的处理机制,允许系统在发生超时的情况下进行同步。处理机制可以是简单的协调脚本,也可以是有状态的工作流,或者是通过死信队列(消息被拒绝、消息TTL过期、队列达到最大长度)实现。

不要在事务中调用远程系统——当远程系统访问速度变慢时,依旧会长时间保持数据库连接,如果访问持续而因为速度的问题一直无法完成系统的访问,会导致数据库的连接也无法释放,也就将数据库连接用完,最终造成系统中断的后果。

使用智能批处理:如果处理大量数据请求,可以逐个进行批量远程调用(API 调用、数据库读取)从而消除网络开销。每个批量处理的量越大,整体延迟就会越大,可能失败的工作单元也会越多。因此需要针对性能和容错性优化批量大小。

如何面对调用方请求

所有 API 必须保证幂等性:幂等性是为了实现调用方 API 的超时重试功能。只有 API 能够支持安全重试且不会有副作用时,调用者才能安心使用重试功能。这里的 API 是指同步 API 和任何消息传递接口——调用者可能会发布两次相同的消息(或者代理可能会发送两次)给到该 API。

图片

明确定义响应时间和吞吐量 SLA 以及遵守定义的规则:在分布式系统中,快速失败比让调用者等待要好得多。诚然,吞吐量 SLA 很难实现(分布式速率限制一个难题),但我们需要确保 SLA 在主动呼叫失败时做好准备。另一个重要方面是了解下游系统的响应时间,以确定系统最快的速度。

定义和限制批处理 API:如果公开批处理 API ,则应明确定义最大批处理的数量,这个数量需要受到 SLA 的限制,也就是需要遵守 SLA 的规则定义。

预先考虑可观察性:可观察性意味着能够分析系统的行为,而无需通过查看API或组件的内部来实现。预先考虑你关心的系统指标以及需要收集的数据,帮助你回答以前未提出的问题。再对系统进行检测并获得这些数据。执行此操作的一个强大机制是识别系统的域模型,当域中发生某个事件时进行发布事件的操作。(例如收到请求id 123,返回请求 123 的响应——注意如何使用这两个“域”事件会导出一个称为“响应时间”的新指标。将原始数据转换到预先确定的聚合中)。

一般性原则

尽量使用缓存:网络变化无常,因此尽可能多地使用缓存,并不断讲最新的数据保存其中。当然,有可能会使用远程缓存机制(例如,Redis服务器运行在单独的服务器上),但至少通过缓存的方式可以将数据带入控制域并减少系统的负载。

考虑单元故障:如果一个 API 或一条消息代表多个工作单元(批处理),那么需要思考单元故障意味着什么?如果有效载荷都失败一次意味着什么?又或者单个单元独立成功或失败意味着什么?部分成功呢,API 是否响应成功或失败代码?

这里的意思是一个 API 调用多个工作单元,这里的工作单元可以是一个组件或者是一个 API 。有可能在调用多个工作单元的时候,其中一个工作单元失败了,或者有的工作单元成功了,这个时候作为最外层调用这些工作单元的 API 来说要考虑好是成功还是失败,如果失败如何返回失败信息。

在系统边缘隔离外部域对象:不允许以重用的名义在系统中使用其他系统的域对象。这将会加剧我们的系统与其他系统的实体建模的耦合,在其他系统发生更改时,我们的系统都会进行大量重构。我们应该始终构建自己的实体表示并将外部有效负载转换为此我们系统内的模式,然后我们的系统中使用它。

安全性

在每个边缘清理输入:在分布式环境中,系统的任何部分都可能受到损害(从安全角度来看)。因此,在系统边界处会对进入系统的数据进行“消毒”处理,这里有一个假设就是这些进入系统的数据有可能不是干净或安全的。

永远不要提交凭证(Credentials):永远不要将凭证(数据库用户名/密码或 API 密钥)提交到代码库。虽然提交凭证到代码库对于某些人来说是常规操作,但我们需要摒弃这种陋习。始终遵守“凭证必须始终从外部(有安全存储保证)加载到系统”的规则。

译者介绍

崔皓,51CTO社区编辑,资深架构师,拥有18年的软件开发和架构经验,10年分布式架构经验。曾任惠普技术专家。乐于分享,撰写了很多热门技术文章,阅读量超过60万。《分布式架构原理与实践》作者。

原文链接:
https://kislayverma.com/programming/code-review-checklist-for-distributed-systems/

[转]向死而生:面向失败设计之道、术、技

一、序

1.1 从两个故事说起

2015 年 5 月,杭州市萧山区某地光缆被挖断,某公司支付软件受到影响,用户反复登录却无法使用,一时间#XXX炸了#成为微博热词;2021 年 7 月 ,某视频网站深夜宕机,各系产品所有功能似乎全崩,直至次日凌晨才恢复服务。这两个故事,导致吃瓜群众对企业技术实力产生了质疑和误解,影响颇深……

1.2 关于我

讲完两个故事,说说我自己,前抖音电商 C 端营销&大促方向 POC,阿里巴巴 2020 年货节&后年货节大促集团技术总执行 PM,广告和电商领域六年后端开发经验,久经大数据量、高并发、巨额资金场景下的技术考验。

1.3 关于选题

从两个故事可以看出,对于失败场景考虑不充分对于企业声誉的打击有多大。站在程序员个体角度,面向失败设计对于个人的影响也同样巨大,企业的事故责任终究要落到程序员个人头上,而事故也往往会消耗组织对于个人的信任,直接或者间接地影响个人的发展。在字节跳动,事故对个人的影响不算太大,但在其他一些公司,一次事故往往意味着程序员“一年白干”。

不同年限的程序员差异到底在哪里?这个问题,我的理解是,除了架构设计能力、项目管理能力、技术规划能力、技术领导力之外,面向失败设计能力也是极其重要的一环。

业务开发的新同学有时候可能会有迷之自信,觉得自己写的代码与老鸟们没有什么不同。实际上,编写正常流程的业务代码大家的差异不会太大,但是针对异常、边界、不确定性的处理才真正体现一个程序员的功力。老鸟们往往在长期的训练下已经形成多种肌肉记忆,遇到具体问题就会举一反三脑海里冒出诸多面向失败的设计点,从而写出高可用的业务代码。如何去学习面向失败设计的方法论,并慢慢形成自己独有的肌肉记忆,才是新手向老鸟蜕变的康庄大道。

基于这样的考量,我写了这篇文章,对自己这些年来的一些经验和教训做了一些总结,希望能够抛砖引玉,让更多的老鸟们把自己的经验 share 出来,相互学习共同进步。

二、道

道的层面,我想讲讲面向失败设计的世界观。

2.1 失败无处不在

理想中,机器硬件永不老化、系统软件永不过期、流量总在预期范围内、自己写的代码没有 bug、产品经理永不改需求,但现实往往给你饱以老拳,给你社会的毒打:硬件一定会在某个时间点故障、软件总在一个时间节点跟不上时代潮流、流量总在你意想不到的时候突增——即使你在婚礼上、没有程序员不写 bug、产品经理不但天天改需求,甚至还给你提自相矛盾或者存在逻辑漏洞的需求。

图片

无论是在传统软件时代还是在互联网、云时代,系统终究会在某个时间点失败。面向失败设计不是消除失败,而是减少乃至消除失败造成的影响,守着企业和个人的钱袋子。

2.2 唯一不变的是变化

不但失败无处不在,变化也无处不在。

2.2.1 不要写死——你的 PM 为改需求而生

“不要写死|你的 PM 为改需求而生”,这句话是我对口的一个产品经理的飞书个性签名,它深得我心。永远对代码写死保持不安,根据墨菲定律,你越是认为不会改变的字段或功能,就越会发生改变。所以,多配置、少写死,让你在产品改需求时快速响应从而令别人刮目相看,也能让你在发生故障时有更多的手段做快速恢复。

2.2.2 隔离可变性——程序员应软件变化而生

如果系统软件永不变化,我们还需要设计模式么?还需要面向对象么?面向过程一把梭不是又快又好么?但是,永不变化的系统软件,要程序员何用?抖音已经如此强大,什么都不改也能给字节挣很多钱,那抖音的程序员都可以下岗了么?好像并非如此。

设计模式,是前辈们总结的应对变化的利器。23 种设计模式,一言以蔽之,曰:隔离可变性。无论是创建型模式,还是结构性模型,又或者是行为型模式,设计的目的都是为了把变化关进设计模式的笼子里。

2.2.3 定期回归——功能在演化中变质

定期回归,也是应对失败的重要原则。互联网的迭代实在是太快了,传统软件往往以年月为维度迭代,而互联网往往以周乃至日迭代。每一天,系统的功能都可能在演化中变质,快速的迭代不但让业务代码迅速腐化变成屎山,也让内部逻辑日益臃肿,乃至相互冲突。终有一天,原本运行良好无 bug 的代码,会变成事故的导火索。

2.3 对代码的世界保持警惕

对代码的世界保持警惕吧,不然总有一天你会经历血泪教训。

2.3.1 不要相信合作方的“鬼话”

对合作方给你的所有接口、方案保持怀疑,也不要相信合作方任何一个未经你亲身验证的论断。实践才是检验真理的唯一标准,对世界始终保持怀疑是工程师的核心素质。不要在出现故障之后跟合作方相互甩锅时才追悔莫及,前期多做些验证,保护了你也保护了他,更是保护了你们之间的塑料友情。

2.3.2 不要相信代码注释

一行错误的代码注释,把我从阿里带到了字节,亲身经历的血泪教训。错误的代码注释不如没有注释,不要再用错误的注释给后来人埋坑了,救救孩子吧。

2.3.3 不要相信函数输入

NPE(NullPointerException 空指针异常)也许是程序员职业生涯中遇到过的最多的错误,这一点颇令人困惑,因为程序员从刷 LeetCode 第一道题开始,就知道需要对函数参数做检查。

之所以出现这样的结果,是因为线上生产环境所能遭遇的场景远比一道代码题复杂,这其实也是工业界与学术界的区别,学术界的问题是确定的,工业界的问题是不确定的。即使上游传递参数的是一个你认为极为可靠的系统,即使你遍览程序上下文确定不会出现空参数,也最好去做一些防御性的设计,因为可靠的系统也会给你返回不合规范的参数,当前不存在空参数的代码在未来的某一天也会被改得面目全非。

2.3.4 不要相信基础设施

即使是支付宝也会崩溃,即使是可用性 6 个 9 的系统,全年也有 31 秒中断。不要相信基础设施,做好灾备,搞好混沌工程,才能让你每个晚上睡得安稳,避免被报警电话打醒。

2.4 设计原则

2.4.1 简洁的方案最优雅

如果你设计的技术方案没有太多的花里胡哨,整体透露着一种大道至简的美感,也许你就离成功很近了。简洁的方案代表着更小的理解成本、更小的维护成本、更好的扩展性。

如果你的方案里面到处都是花里胡哨的炫技,看起来复杂而严谨,那么也许你离让自己头疼也让别人头疼不远了,一顿操作猛如虎,一看月薪两千五。

当然,并不是最简洁的方案就是最合适的方案,举个栗子,核心交易链路的服务必然会比数据展示的服务稳定性要求更高,因而做了较多高可用设计之后方案会更加复杂,因而在满足稳定性的前提下选用尽可能简洁的方案才是推荐的做法。

2.4.2 开闭原则是设计模式的总纲

开闭原则是设计模式的总纲,大部分设计模式里面都有开闭原则的影子,软件实体应当对扩展开放,对修改关闭,可以通过“抽象约束、封装变化”来实现开闭原则。开闭原则可以使软件实体拥有一定的适应性和灵活性的同时具备稳定性和延续性。

基于开闭原则,很多常见的设计问题都有了答案:

(1)大量 if-else 的屎山代码问题。 大量的 if-else 肯定是不符合开闭原则的,每一个 if-else 的代码支路都是对原有代码结构的破坏,这里就可以应用工厂+策略设计模式对 if-else 进行剥离,把逻辑的新增和修改限制在工厂模式子类的内部。

(2)冗长的业务工作流处理问题。 业务流程代码往往非常冗长,封装得不好的话阅读和维护代码都非常困难,可以考虑用命令+职责链设计模式对工作流做封装。封装的好处在于,整体的工作流读起来将非常清晰,主流程代码往往能从数百行精简到十行以内,并且,对流程的修改仅仅是简单的断链或者增加链节点的操作,从而把修改的影响减到最低。

(3)历史字段类型修改问题。 互联网开发过程中经常需要修改历史字段的类型,根据开闭原则,我们不该去修改原有字段的类型,而应该新增一个字段,这样才能保证对上下游链路的影响最小。

(4)对象属性中途篡改问题。 举个实际的业务场景,在某些业务请求中,抖音极速版需要做与抖音相同的处理,把抖音极速版的 APPID 改成抖音的 APPID 是最简单的方法,但是这种做法是不符合开闭原则的,对对象属性中途的篡改,会改变对象在程序中的语义,总有一天它会有不符合预期的表现,很多事故因此而起。正确的做法是,在上下文中传递一个新的字段,下游的每一步处理都可以选择正确的字段做正确的处理,而不会被中途篡改的字段蒙蔽。

2.4.3 懒惰是程序员最大的美德

懒惰是程序员最大的美德,好的程序员往往是默默无闻的,越是在团队里面滋哇乱叫到处救火刷存在感的程序员越可能是团队的慢性毒药。

为了让自己懒惰,安安稳稳躺平就把业务做好,程序员必须掌握平台化、工具化、自动化三板斧。平台化,把程序员从无穷尽的重复劳动中解救出来;工具化,把程序员从水深火热的人肉运维和 oncall 中解救出来;自动化,让程序如流水线般顺滑,从而提升程序员的人效。能将这三板斧挥舞到什么层次,也体现了程序员能力到达了什么层次。有了平台化、工具化、自动化,就可以做标准化、规模化,助力公司和业务持续往上走。

三、术

术的层面,我想讲讲在组织和流程角度如何面向失败设计。

3.1 组织

3.1.1 面向失败设计的工种

测试工程师、测试开发工程师、风控&安全合规工程师都是开发工程师最可靠的合作伙伴,也是企业为了面向失败设计而设置的工种。

测试工程师是软件质量的把关者,他们是线上质量的卫士,对开发工程师代码的质量和性能负责。测试开发工程师是一个技术型的软件测试工种,除了做常规的测试工作之外,还会写一些测试工具和自动化脚本,用自动化的手段来提高测试的质量和效率。风控和反作弊工程师对业务的生态负责,监测业务的异常问题,提高业务风控的效果。安全合规工程师,则是对信息安全负责,能够对于项目提供合规咨询、信息安全风险评估。

3.1.2 面向失败设计的组织形式

安全生产小组是一种面向失败设计的组织形式。安全生产小组往往是横向的技术团队,对多个业务团队提供规范制定和推行、生产过程管控、事故复盘组织等技术支持,为线上质量负责,通常还会在每个业务团队设置系统稳定性负责人,作为接口人来有效推行他们制定的制度。

结对编程,也是一种面向失败设计的组织形式。严格意义的结对编程,要求两个程序员在一个计算机上共同工作。一个人输入代码,而另一个人审查他输入的每一行代码。结对编程可以让程序员写出更短的程序,更好的设计,以及更少的缺陷,同时,结对编程也可以促进知识的传播,让新人快速进步,也让老人在带新的过程中总结自己的知识和经验,还可以规避在相应开发人员请假或者离职带来的工作交接的问题。

严格意义的结对编程,在互联网行业极为罕见,很少有团队会真正这样实操,也许是因为在管理者看来,两个人干同一件事情大大增加了人力的成本。但是,结对编程的一些思想和理念,也值得我们借鉴,比如我们可以让两个程序员结对做业务 owner,互为 backup,相互 code review,从而在一定程度上获得结对编程的好处。

3.2 流程

假设不做面向失败设计,那么软件开发流程也许可以简化为编码+发布两步。但是成熟企业的开发流程大致如下:

图片

需求提出阶段,需要先期做一些合规评估、反作弊评估、安全评估,在前期就把一些潜在的安全合规风险排除。

编码阶段,在设计技术方案时需要考虑止血/降级/回滚措施,并组织技术评审和安全技术评审,针对技术方案中的安全风险做一些评估。除此之外,最好做一些单元测试,可以大大提高代码的质量。

测试阶段,需要开发人员先做自测,再让测试工程师参与功能测试、安全工程师做安全检查,针对代码改动可能造成的额外影响,做好做一次更大范围的回归测试,以排除一些预期外的影响。

发布阶段,需要采用灰度发布的机制,先发布小部分机器,或者仅针对部分地区用户灰度,在灰度发布之后做灰度测试验证功能正常,在继续分批发布、全量发布。

验证阶段,可以让测试同学在发布完成之后做一次线上回归,保证功能在线上环境稳定可用。对于大型活动,往往还需要组织内部用户线上预演或众测。针对非预期内流量可能把系统打挂的风险,可以做单链路压测和全链路压测。在大型活动开始前,如果条件允许,或者在小范围做一次线上试玩,提前暴露一些风险。

运行阶段,需要开发人员做好监控报警和离在线数据对账。对于项目的效果,可以用 AB 测试来量化收益。

故障发生时,第一时间必须做好故障快速恢复,尽可能减少线上损失,之后再考虑定位故障原因。

在项目结束或者故障处理结束之后,需要组织一次有效的复盘,并对过程中的问题做一些总结,形成有效的改进方案,并持续跟进改进方案的落地

3.3 一些观点

3.3.1 测试同学的重要性,怎么吹都不为过

测试工程师是线上质量最重要的卫士,他们的重要性,怎么吹都不为过。一个优秀的测试同学,可以做到以下事情:

  • 非黑盒测试,具备读懂开发代码的能力,根据代码针对性地设计测试用例
  • 设计完备的测试用例,覆盖所有测试场景
  • 编写数据对账脚本,能够做离线数据对账和实时数据对账
  • 编写自动化测试工具
  • 编写数据一致性监控脚本、资损防控工具

3.3.2 单元测试最省时间

编写单元测试用例,看似费时间,实则是最省时间的做法。单元测试保证了代码的行为与我们期望一致,从而省下了大量的发布、自测、联调、修改代码的返工时间,另外,可以做单元测试的代码往往职责更加清晰、分层分块更加合理、稳定性更好。

3.3.3 复盘是对齐做事高标准的一个必要方式

复盘是不断优化组织,对齐做事高标准的一个必要方式。通过 PDCA(Plan-Do-Check-Action,戴明环)这样的一个循环,工作在不断的改善后,最终形成知识沉淀,作用于下一次计划执行,团队于是变得越来越有执行力,个人则成为 Better Me。

3.3.4 研发红线是程序员的保护伞

研发红线是企业面向失败设计行之有效的暴力机器,它由无数零件(规范和条目)组成、冰冷、机械、运行起来无法阻挡,不以个人意志为转移。研发红线强制要求程序员遵守企业的流程和规范,警告程序员不犯低级错误,看似冰冷无情,实则是程序员的保护伞。

四、技

在技的层面,我想谈谈面向失败设计的具体技术细节。但是技术细节实在太多,限于篇幅,此处只列举一些经典技术问题的解法。

4.1 将面向失败当做系统设计的一部分

  • 针对非预期流量,可以做系统限流、系统过载保护、自适应扩缩容;
  • 针对依赖服务超时或错误,需要对依赖系统设置超时时间,并对所有依赖做强弱依赖梳理,关键时刻降级非核心依赖;
  • 针对预期外的情况,可以提前准备好紧急预案,并做好预案演练;
  • 针对瞬时高流量,需要敏锐地判断系统的极限,做好流量打散,并避免 DB 和缓存热 key;
  • 针对可能出现的机房问题,做好同城双(多)活和异地多活;
  • 针对人为失误,可以使用平台化、工具化、自动化的方法减少人肉操作;
  • 避免出现单点问题,做冗余设计来降低局部失败对系统的影响;
  • 失败重试时需谨慎,避免踩踏雪崩;
  • 故障只能减少,不能消除,做好监控报警、故障演练、攻防演练,锤炼风险应急能力;

4.2 分布式锁的六个层次

你只看到了第二层,你把我想成了第一层。实际上,我在第五层。

——芜湖大司马

Redis 实现分布式锁有六个层次,看看大家平常用的分布式锁处在第几个层次。

分布式锁设计原则:

  • 互斥性。在任意时刻,只有一个客户端持有锁。
  • 不死锁。分布式锁本质上是一个基于租约(Lease)的租借锁,如果客户端获得锁后自身出现异常,锁能够在一段时间后自动释放,资源不会被锁死。
  • 一致性。硬件故障或网络异常等外部问题,以及慢查询、自身缺陷等内部因素都可能导致 Redis 发生高可用切换,replica 提升为新的 master。此时,如果业务对互斥性的要求非常高,锁需要在切换到新的 master 后保持原状态。

层次一:

redis.SetNX(ctx, key, "1")
defer redis.del(ctx, key)

使用 SetNx 命令,可以解决互斥性的问题,但不能做到不死锁。

层次二:

redis.SetNX(ctx, key, "1", expiration)
defer redis.del(ctx, key)

使用 lua 脚本保证 SetNX 与 Expire 的原子性,做到了不死锁,但是做不到一致性。

层次三:

redis.SetNX(ctx, key, randomValue, expiration)
defer redis.del(ctx, key, randomValue)

// 以下为del的lua脚本
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
else
   return 0
end

分布式锁的值设定一个随机数,删除时只删除当前线程/协程抢到的锁,避免在程序运行过慢锁过期时删除别的线程/协程的锁,能做到一定程度的一致性。

层次四:

func myFunc() (errCode *constant.ErrorCode) {
    errCode := DistributedLock(ctx, key, randomValue, LockTime)
    defer DelDistributedLock(ctx, key, randomValue)
    if errCode != nil {
       return errCode
    }
    // doSomeThing
}

func DistributedLock(ctx context.Context, key, value string, expiration time.Duration) (errCode *constant.ErrorCode) {

   ok, err := redis.SetNX(ctx, key, value, expiration)
   if err == nil {
      if !ok {
         return constant.ERR_MISSION_GOT_LOCK
      }
      return nil
   }

   // 应对超时且成功场景,先get一下看看情况
   time.Sleep(DistributedRetryTime)
   v, err := redis.Get(ctx, key)
   if err != nil {
      return constant.ERR_CACHE
   }
   if v == value {
      // 说明超时且成功
      return nil
   } else if v != "" {
      // 说明被别人抢了
      return constant.ERR_MISSION_GOT_LOCK
   }

   // 说明锁还没被别人抢,那就再抢一次
   ok, err = redis.SetNX(ctx, key, value, expiration)
   if err != nil {
      return constant.ERR_CACHE
   }
   if !ok {
      return constant.ERR_MISSION_GOT_LOCK
   }
   return nil
}

// 以下为del的lua脚本
if redis.call("get",KEYS[1]) == ARGV[1] then
   return redis.call("del",KEYS[1])
else
   return 0
end


// 如果你的Redis版本已经支持CAD命令,那么以上lua脚本可以改为以下代码
func DelDistributedLock(ctx context.Context, key, value string) (errCode *constant.ErrorCode) {
   v, err := redis.Cad(ctx, key, value)
   if err != nil {
      return constant.ERR_CACHE
   }
   return nil
}

解决超时且成功的问题,写入超时且成功是偶现的、灾难性的经典问题。

还存在的问题是:

  • 单点问题,单 master 有问题,如果有主从,那主从复制过程有问题时,也存在问题
  • 锁过期然后没完成流程怎么办

层次五:

启动定时器,在锁过期却没完成流程时续租,只能续租当前线程/协程抢占的锁。

// 以下为续租的lua脚本,实现CAS(compare and set)
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("expire",KEYS[1], ARGV[2])
else
    return 0
end

// 如果你的Redis版本已经支持CAS命令,那么以上lua脚本可以改为以下代码
redis.Cas(ctx, key, value, value)

能保障锁过期的一致性,但是解决不了单点问题。

同时,可以发散思考一下,如果续租的方法失败怎么办?我们如何解决“为了保证高可用而使用的高可用方法的高可用问题”这种套娃问题?开源类库 Redisson 使用了看门狗的方式一定程度上解决了锁续租的问题,但是这里,个人建议不要做锁续租,更简洁优雅的方式是延长过期时间,由于我们分布式锁锁住代码块的最大执行时长是可控的(依赖于 RPC、DB、中间件等调用都设定超时时间),因而我们可以把超时时间设得大于最大执行时长即可简洁优雅地保障锁过期的一致性。

层次六:

Redis 的主从同步(replication)是异步进行的,如果向 master 发送请求修改了数据后 master 突然出现异常,发生高可用切换,缓冲区的数据可能无法同步到新的 master(原 replica)上,导致数据不一致。如果丢失的数据跟分布式锁有关,则会导致锁的机制出现问题,从而引起业务异常。针对这个问题介绍两种解法:

(1)使用红锁(RedLock)。红锁是 Redis 作者提出的一致性解决方案。红锁的本质是一个概率问题:如果一个主从架构的 Redis 在高可用切换期间丢失锁的概率是 k%,那么相互独立的 N 个 Redis 同时丢失锁的概率是多少?如果用红锁来实现分布式锁,那么丢锁的概率是(k%)^N。鉴于 Redis 极高的稳定性,此时的概率已经完全能满足产品的需求。

红锁的问题在于:

  • 加锁和解锁的延迟较大。
  • 难以在集群版或者标准版(主从架构)的 Redis 实例中实现。
  • 占用的资源过多,为了实现红锁,需要创建多个互不相关的云 Redis 实例或者自建 Redis。

(2)使用 WAIT 命令。Redis 的 WAIT 命令会阻塞当前客户端,直到这条命令之前的所有写入命令都成功从 master 同步到指定数量的 replica,命令中可以设置单位为毫秒的等待超时时间。客户端在加锁后会等待数据成功同步到 replica 才继续进行其它操作。执行 WAIT 命令后如果返回结果是 1 则表示同步成功,无需担心数据不一致。相比红锁,这种实现方法极大地降低了成本。

4.3 热点库存扣减

秒杀是非常常见的面试题,很多面试官上来就让面试者设计一个秒杀系统,面试者当然也是“身经百战”,很快可以给出熟背的“标准答案”。

但是,秒杀还是相对简单的热点库存扣减问题,因为扣减的库存量不大。更加典型的热点库存扣减问题是春节红包雨,同一个资金池数亿人抢红包。对于春节红包雨介绍两种方案:

方案一:

图片

存在问题:

  • 不同分桶之间,库存消耗不均,可能导致部分用户无法扣减库存,但其他用户可扣减库存,从而引发用户投诉。

方案二:

图片

小量多次地分派库存,从而缓解分桶库存消耗不均问题。

2021 年抖音春节红包,将用户进入的时间打散,减少瞬时请求峰值,也是一个很好的技术思路。

如何体现面向失败设计:

(1)为何用定时任务调度主动分配库存,而不是在分桶库存不足时被动拉库存?

答:因为主动分配库存 QPS 比被动拉库存低几个量级。

(2)如何应对超大流量?

答:流量不触达 DB、分桶、打散。

(3)Redis 库存总池为何不用某个 master 机器维护,而用定时任务调度随机挑选机器?

答:防单点。

五、跋

编程之美,蔚为大观。好的代码,往往结构清晰,表意明确,设计精巧,无论是读代码还是写代码都可以给程序员一种直击心灵的美感,甚至让读者爱不释手,让作者引以为傲,引之为自己的代表作。但是,为了留住这种美,我们还需要去做面向失败的设计,充分考虑失败场景,才能减少失败的概率,向死而得生。

本文对面向失败设计做了一些浅显的思考,欢迎探讨、补充和指正。

六、引

  1. 面向失败的设计-概述 https://developer.aliyun.com/article/726333
  2. 高性能分布式锁 https://help.aliyun.com/document_detail/146758.html

原文链接https://mp.weixin.qq.com/s/a-RA9hP400qUjcdsXxjSbg

【转】DDD 到底是银弹还是垃圾

[原文链接:https://mp.weixin.qq.com/s/PjNc7YLKT5JX_Obg2815Bg]

每过一段时间,就会有人跳出来批判 DDD,这东西到底是垃圾还是银弹?

在某某公司干活的时候,有一批人声称要用 DDD 改造老旧系统,彻底解决核心流程规模化之后,项目难以维护的问题。之前某篇文章里的这张图,就是在用 DDD 做项目重构之前的烂摊子:

图片

大家都很聪明,聪明到最后没人知道这新需求到底该往哪里写了。架构师们聚在一起学习 DDD 精神,产出学习报告,大半年过去,终于出了一些成果,有些子项目完成了用 DDD 进行的重构,年底可以拿来在酒会上邀功了,这下我们跟上了业界业务开发的主流方法论,可喜可贺,可喜可贺啊。

年末的时候部门内匿名提问的小纸条却向架构师们发直球:“为什么用了 DDD 以后,代码更难懂了?”,当时引得各位 DDD 推手尴尬无比,只能搪塞过去。

所以你觉得我是要批判么?那倒不是。

在某某司工作期间,到离职前,我把市面上所有 DDD 相关的书全部看了一遍。对其理论体系进行了完整的了解,可以说这套理论还是有些用处的,DDD 的理论诞生时间比较早,微服务的趋势是后来才爆发的。但微服务刚开始没有明确的拆分指导,人们发现 DDD 里的 bounded context 好像看着正好和服务的粒度是可以做个对应的,DDD 就成为了很多公司做业务的绝对主流方法论。

虽然很多技术人员不爱听,但是技术优劣和商业成败其实没什么必然的联系。同样的,方法论的对错和项目的成功与否也没有必然的关系。很多大公司做业务的人出来讲他们的技术方法论,这些人可能连自己的项目为啥成功都不一定知道,你指望能对你的场景产生直接帮助那可能是想多了。只是当听个乐,得个借鉴那可能还没什么问题。真的当金科玉律去执行,那撞一头包也正常。

DDD 和其它的工程方法论一样,没有办法证伪。放眼望去,纯粹堆砌人肉电池,不用 DDD 的项目也那么多成功的,大家的屁股还是在跟着公司的市值跑,哪家公司市值涨到中国第一了,那他们的技术就牛逼,这叫看市值决定价值观。如果一家公司靠 996 成功了,那 996 就是商业致胜的法宝,不学你就落后了。屁股可以决定脑袋嘛。

不过作为一个矜持的技术人员,我们在批判方法论的时候,还是应该要先对敌人有一些了解。

所以这一篇,我就简单带你们看看 DDD 里那些鬼名词都是什么意思。

战术设计与战略设计

整个 DDD 的方法论可以划分为两个大模块,战术设计、战略设计。这个你顾名思义,战术是小,战略是大。

  • 战术设计指的就是单模块级的设计,基本都是纯技术范畴的东西,只DDD 中给代码命名和模块设计给出了一些指导方法
  • 战略设计指的是大项目的模块拆分,这个和一线程序员关系不大,主要是公司内怎么在 bu 之间切蛋糕,bu 内怎么在 team 之间分赃

现在很多校招程序员可能或多或少都会碰到一些 OOP 方面的面试题,比如三大特性五大原则之类的,这些原则是设计项目的时候可以参考的原则, DDD 的战术设计就是在单模块上的各种命名规则和设计方法。只不过 OOP 这些原则的发明人(严格的说应该是汇总人)是 uncle bob,就是 《clean code》,《clean architecture》 的作者,这位白胡子爷爷大概率和 DDD 社区是尿不到一个壶里的,所以 《clean architecture》 这本书里只字未提 DDD。

公司的业务要怎么分派给不同的 bu(部门)去完成,这个一般是公司 CTO 或者 GM 要做的事情,部门内的项目要怎么分,哪些组做哪些事情。这是战略设计的范畴。DDD 声称战略设计也是要有方法的。这部分也是很多程序员认为最没用的一部分,我们后面来批判一下这些程序员。

战术设计

战术设计是纯技术范畴的东西,最让人头痛的就是里面的名词。

贫血模式和充血模式:DDD 推荐你用充血模式写代码,也就是按 OOP 的方式去做抽象,然后把行为挂在对象上,而不是以纯过程式 的方法去写代码。所谓的充血,就是对象本身有很多关联的行为,而不只是一个单纯的数据库的表的字段映射。DDD 声称的充血模式的优势是,大部分的行为被封装到了对象内部,这样我们在阅读流程代码的时候,是一目了然的,直接能看到 step 1,step 2,step 3。但实际即使我们不用 OOP 来组织行为,一样可以把不同的业务 step 做好封装和复用。有些公司的服务粒度拆的特别细,比如只有 5000-10000 行代码,在 DDD 里声称的充血模式的优势没有那么明显。

值对象和实体:这个也挺离谱的,值对象就是纯粹的数值、文本类型,比如:

type person struct {
  age int
  name string
}

就是值对象,如果我们给这个 person 加一个 id,让它能表示 person 的唯一性了;

type person struct {
  id  int
  age int
  name string
}

那它就是实体了。

这两个概念只是给我们日常用的对象们进行了一个简单的分类,没什么大用处。

聚合根:DDD 里所谓的聚合根是事务粒度的 entity,也就是说,如果我们对 db 进行存取,那么我们就需要有一个聚合根,如果在一个事务里需要操作多张表,那么就需要给多张表关联一个单独的聚合根。

图片

聚合根可以由一个 entity 组成,也可以由多个 entity 组成,就是你完成一个 db 事务的时候有多少关联的对象 ,那可能就有多少在同一个聚合根下面的 entity。

六边形架构:这个所谓的六边形架构,就是除了业务以外的所有外部变化都抽象成 adapter interface 做适配。如果你稍微理解一点点点依赖反转,那应该知道怎么样去做这种抽象。如果你一点都不了解,那我建议你去看看 go-micro 的代码。如果看不懂,建议还是尽早转行吧~

图片

六边形架构这东西主要是名字实在起的太奇怪,在 《clean architecture》那本书里,uncle bob 也给过一张图:

图片

《evolutionary architecture》这本书出自造词大本营 thoughtworks 的员工之手,里面有一个 plugin architecture,就是有些人特别喜欢说的插件化架构:

图片

Repo Pattern:DDD 理论认为我们业务项目的存储这一层是可能经常变化的,所以就专门存储层的 interface 设计单独拿出来,称为 Repo Pattern,这东西实在没啥可说的,find,getlist,save,你只要有一点点 orm 经验,里面有啥接口应该自己都可以默写出来。

事实是在 2021 年,我们的存储系统基本是不太可能做切换的了,即使切换,那些新兴的社区存储系统也会支持 MySQL 协议,基础设施想要侵入代码,那简单是大逆不道啊。

领域事件:其实就是做上下游解耦的 kafka message,我们用 domain event 显得会更洋气一些。

领域服务:Domain service,顾名思义,你认为是自己部门或者组内的局部 api gateway 也是可以的。

综上,如果你是在大公司一线工作了两三年的程序员,上面这些东西应该马上就能理解,没有啥值得说的。如果是为了去架构师大会上秀一秀,你总得包装一下让自己显得没那么土吧?

战略设计

Domain:领域,你们公司是干啥的,你都不知道吗?

Core Domain:你们公司的卖货的,那卖货就是你们与其它竞争对手的关键竞争环节。这就是核心域,就是核心业务,为啥聪明人都往核心业务挤?核心业务的汤也比边缘业务的饭好啊。

SubDomain:你们公司的卖货的,但是用户没法付钱,那也没法干,支付就是子领域。

Supporting Domain:你们公司是卖货的,但是客户想看一些指标,你总得有系统能支持吧?可能就是些写写 SQL 的系统。支持域。

Generic Domain:你管你们公司干什么呢?员工的在职离职,工资发放总得有系统能支持吧,这些就是通用域。

除了第一个 Domain ,其余四个 domain 重要性逐级递减,递减的意思是,如果公司要裁员,那是从下面往上面裁。

前面我说有些程序员觉得 DDD 战略设计没用,你连自己所在的组,从事的工作职责对于公司来说重不重要都不清楚,那被裁的时候也别哭哦。

统一语言:这个就更好理解了,比如跳水这个词,你说跳水的时候指的是这个:

图片

而你同事说跳水的时候指的是这个:

图片

这里你们聊的是工作,那说明你们一定不是在同一个上下文里工作,可能你们俩一个在体育赛事部门,另一个可能是在金融部门, DDD 认为可以用统一语言来进行领域划分工作。划分后在同一个上下文内,同一个名词大家说出来意思一致。这就是 Bounded Context,  ain。

既然拆分了,如果我们还在同一个 domain 内,那完成业务流程是需要协作的,这个不同 Context 的协作方式就叫 Context Maps 或者 Integration Type。

名词很恶心,但具体的方法就两种,两个微服务要么通过 RPC 通信,要么通过 MQ 通信。

如果通过 RPC 通信,那 callee 一般是 caller 的爹,很多时候 callee 挂了是要影响 caller 的(当然也有熔断之类的方法避免一起死)。

通过通过 MQ 通信,那上游一般是下游的爹,因为上游一个重构,下游们可能就都炸了,最终一致都是屁话,多少公司的最终一致都是靠人肉修的。

这种爹和儿子的关系就是 Conformist。如果爹能多考虑一下儿子的需求,那就是 Customer-Supplier 关系,毕竟顾客名义上还是上帝。如果跨系统有一些需要共享的定义,比如公司里的业务分类,可能大家都要从某个系统的 PHP 文件里解析出来在自己的系统里去用,那这时候可能得去使用别人的代码,这种叫 Shared-Kernel,Kernel 一改,大家一起死。

最后,有时候我们可以用一个叫 ACL 的东西拦住上游的一些修改对我们的业务逻辑侵入:

防腐层:Anti-Corruption-Layer,就是我要把外部系统的变化拦截在对接层,不要让别人的屎甩到我身上。

讲到这里,基本的概念我们已经都过一遍了,你要说 DDD 一点用处都没有,那我也是不同意的,至少看完了这些书,我知道去哪里能赚到更多的钱了。

额外再说一句,DDD 的书写的都不怎么样。

[转]Golang 调度器 GMP 原理与调度全分析

【原文链接 https://learnku.com/articles/41728】

第一章 Golang 调度器的由来

第二章 Goroutine 调度器的 GMP 模型及设计思想

第三章 Goroutine 调度场景过程全图文解析

一、Golang “调度器” 的由来?
(1) 单进程时代不需要调度器
我们知道,一切的软件都是跑在操作系统上,真正用来干活 (计算) 的是 CPU。早期的操作系统每个程序就是一个进程,直到一个程序运行完,才能进行下一个进程,就是 “单进程时代”

一切的程序只能串行发生。
早期的单进程操作系统,面临 2 个问题:

1. 单一的执行流程,计算机只能一个任务一个任务处理。

2. 进程阻塞所带来的 CPU 时间浪费。

那么能不能有多个进程来宏观一起来执行多个任务呢?

后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把 CPU 利用起来,CPU 就不浪费了。

(2) 多进程 / 线程时代有了调度器需求
在多进程 / 多线程的操作系统中,就解决了阻塞的问题,因为一个进程阻塞 cpu 可以立刻切换到其他进程中去执行,而且调度 cpu 的算法可以保证在运行的进程都可以被分配到 cpu 的运行时间片。这样从宏观来看,似乎多个进程是在同时被运行。

但新的问题就又出现了,进程拥有太多的资源,进程的创建、切换、销毁,都会占用很长的时间,CPU 虽然利用起来了,但如果进程过多,CPU 有很大的一部分都被用来进行进程调度了。

怎么才能提高 CPU 的利用率呢?

但是对于 Linux 操作系统来讲,cpu 对进程的态度和线程的态度是一样的。
很明显,CPU 调度切换的是进程和线程。尽管线程看起来很美好,但实际上多线程开发设计会变得更加复杂,要考虑很多同步竞争等问题,如锁、竞争冲突等。

(3) 协程来提高 CPU 利用率
多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存 (进程虚拟内存会占用 4GB [32 位操作系统], 而线程也要大约 4MB)。

大量的进程 / 线程出现了新的问题

高内存占用
调度的高消耗 CPU
好了,然后工程师们就发现,其实一个线程分为 “内核态 “线程和” 用户态 “线程。

一个 “用户态线程” 必须要绑定一个 “内核态线程”,但是 CPU 并不知道有 “用户态线程” 的存在,它只知道它运行的是一个 “内核态线程”(Linux 的 PCB 进程控制块)。

这样,我们再去细化去分类一下,内核线程依然叫 “线程 (thread)”,用户线程叫 “协程 (co-routine)”.

​ 看到这里,我们就要开脑洞了,既然一个协程 (co-routine) 可以绑定一个线程 (thread),那么能不能多个协程 (co-routine) 绑定一个或者多个线程 (thread) 上呢。

​ 之后,我们就看到了有 3 中协程和线程的映射关系:

N:1 关系
N 个协程绑定 1 个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1 个进程的所有协程都绑定在 1 个线程上

缺点:

某个程序用不了硬件的多核加速能力
一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
1:1 关系
1 个协程绑定 1 个线程,这种最容易实现。协程的调度都由 CPU 完成了,不存在 N:1 缺点,

缺点:

协程的创建、删除和切换的代价都由 CPU 完成,有点略显昂贵了。
M:N 关系
M 个协程绑定 N 个线程,是 N:1 和 1:1 类型的结合,克服了以上 2 种模型的缺点,但实现起来最为复杂。

​ 协程跟线程是有区别的,线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。

(4) Go 语言的协程 goroutine
Go 为了提供更容易使用的并发方法,使用了 goroutine 和 channel。goroutine 来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被 runtime 调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go 中,协程被称为 goroutine,它非常轻量,一个 goroutine 只占几 KB,并且这几 KB 就足够 goroutine 运行完,这就能在有限的内存空间内支持大量 goroutine,支持了更多的并发。虽然一个 goroutine 的栈只占几 KB,但实际是可伸缩的,如果需要更多内容,runtime 会自动为 goroutine 分配。

Goroutine 特点:

占用内存更小(几 kb)
调度更灵活 (runtime 调度)
(5) 被废弃的 goroutine 调度器
​ 好了,既然我们知道了协程和线程的关系,那么最关键的一点就是调度协程的调度器的实现了。

Go 目前使用的调度器是 2012 年重新设计的,因为之前的调度器性能存在问题,所以使用 4 年就被废弃了,那么我们先来分析一下被废弃的调度器是如何运作的?

大部分文章都是会用 G 来表示 Goroutine,用 M 来表示线程,那么我们也会用这种表达的对应关系。

下面我们来看看被废弃的 golang 调度器是如何实现的?

M 想要执行、放回 G 都必须访问全局 G 队列,并且 M 有多个,即多线程访问同一资源需要加锁进行保证互斥 / 同步,所以全局 G 队列是有互斥锁进行保护的。

老调度器有几个缺点:

创建、销毁、调度 G 都需要每个 M 获取锁,这就形成了激烈的锁竞争。
M 转移 G 会造成延迟和额外的系统负载。比如当 G 中包含创建新协程的时候,M 创建了 G’,为了继续执行 G,需要把 G’交给 M’执行,也造成了很差的局部性,因为 G’和 G 是相关的,最好放在 M 上执行,而不是其他 M’。
系统调用 (CPU 在 M 之间的切换) 导致频繁的线程阻塞和取消阻塞操作增加了系统开销。
二、Goroutine 调度器的 GMP 模型的设计思想
面对之前调度器的问题,Go 设计了新的调度器。

在新调度器中,除了 M (thread) 和 G (goroutine),又引进了 P (Processor)。

Processor,它包含了运行 goroutine 的资源,如果线程想运行 goroutine,必须先获取 P,P 中还包含了可运行的 G 队列。

(1) GMP 模型
在 Go 中,线程是运行 goroutine 的实体,调度器的功能是把可运行的 goroutine 分配到工作线程上。

全局队列(Global Queue):存放等待运行的 G。
P 的本地队列:同全局队列类似,存放的也是等待运行的 G,存的数量有限,不超过 256 个。新建 G’时,G’优先加入到 P 的本地队列,如果队列满了,则会把本地队列中一半的 G 移动到全局队列。
P 列表:所有的 P 都在程序启动时创建,并保存在数组中,最多有 GOMAXPROCS(可配置) 个。
M:线程想运行任务就得获取 P,从 P 的本地队列获取 G,P 队列为空时,M 也会尝试从全局队列拿一批 G 放到 P 的本地队列,或从其他 P 的本地队列偷一半放到自己 P 的本地队列。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。
Goroutine 调度器和 OS 调度器是通过 M 结合起来的,每个 M 都代表了 1 个内核线程,OS 调度器负责把内核线程分配到 CPU 的核上执行。

有关 P 和 M 的个数问题
1、P 的数量:

由启动时环境变量 $GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。
2、M 的数量:

go 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。
runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量
一个 M 阻塞了,会创建新的 M。
M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

P 和 M 何时会被创建
1、P 何时创建:在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

2、M 何时创建:没有足够的 M 来关联 P 并运行其中的可运行的 G。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

(2) 调度器的设计策略
复用线程:避免频繁的创建、销毁线程,而是对线程的复用。

1)work stealing 机制

​ 当本线程无可运行的 G 时,尝试从其他线程绑定的 P 偷取 G,而不是销毁线程。

2)hand off 机制

​ 当本线程因为 G 进行系统调用阻塞时,线程释放绑定的 P,把 P 转移给其他空闲的线程执行。

利用并行:GOMAXPROCS 设置 P 的数量,最多有 GOMAXPROCS 个线程分布在多个 CPU 上同时运行。GOMAXPROCS 也限制了并发的程度,比如 GOMAXPROCS = 核数/2,则最多利用了一半的 CPU 核进行并行。

抢占:在 coroutine 中要等待一个协程主动让出 CPU 才执行下一个协程,在 Go 中,一个 goroutine 最多占用 CPU 10ms,防止其他 goroutine 被饿死,这就是 goroutine 不同于 coroutine 的一个地方。

全局 G 队列:在新的调度器中依然有全局 G 队列,但功能已经被弱化了,当 M 执行 work stealing 从其他 P 偷不到 G 时,它可以从全局 G 队列获取 G。

(3) go func () 调度流程
从上图我们可以分析出几个结论:

​ 1、我们通过 go func () 来创建一个 goroutine;

​ 2、有两个存储 G 的队列,一个是局部调度器 P 的本地队列、一个是全局 G 队列。新创建的 G 会先保存在 P 的本地队列中,如果 P 的本地队列已经满了就会保存在全局的队列中;

​ 3、G 只能运行在 M 中,一个 M 必须持有一个 P,M 与 P 是 1:1 的关系。M 会从 P 的本地队列弹出一个可执行状态的 G 来执行,如果 P 的本地队列为空,就会想其他的 MP 组合偷取一个可执行的 G 来执行;

​ 4、一个 M 调度 G 执行的过程是一个循环机制;

​ 5、当 M 执行某一个 G 时候如果发生了 syscall 或则其余阻塞操作,M 会阻塞,如果当前有一些 G 在执行,runtime 会把这个线程 M 从 P 中摘除 (detach),然后再创建一个新的操作系统的线程 (如果有空闲的线程可用就复用空闲线程) 来服务于这个 P;

​ 6、当 M 系统调用结束时候,这个 G 会尝试获取一个空闲的 P 执行,并放入到这个 P 的本地队列。如果获取不到 P,那么这个线程 M 变成休眠状态, 加入到空闲线程中,然后这个 G 会被放入全局队列中。

(4) 调度器的生命周期
特殊的 M0 和 G0

M0

M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了。

G0

G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0。

我们来跟踪一段代码

package main

import “fmt”

func main() {
fmt.Println(“Hello world”)
}
接下来我们来针对上面的代码对调度器里面的结构做一个分析。

也会经历如上图所示的过程:

runtime 创建最初的线程 m0 和 goroutine g0,并把 2 者关联。
调度器初始化:初始化 m0、栈、垃圾回收,以及创建和初始化由 GOMAXPROCS 个 P 构成的 P 列表。
示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。
启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。
G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境
M 运行 G
G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。
调度器的生命周期几乎占满了一个 Go 程序的一生,runtime.main 的 goroutine 执行之前都是为调度器做准备工作,runtime.main 的 goroutine 运行,才是调度器的真正开始,直到 runtime.main 结束而结束。

(5) 可视化 GMP 编程
有 2 种方式可以查看一个程序的 GMP 的数据。

方式 1:go tool trace

trace 记录了运行时的信息,能提供可视化的 Web 页面。

简单测试代码:main 函数创建 trace,trace 会运行在单独的 goroutine 中,然后 main 打印”Hello World” 退出。

trace.go

package main

import (
“os”
“fmt”
“runtime/trace”
)

func main() {

//创建trace文件
f, err := os.Create(“trace.out”)
if err != nil {
panic(err)
}

defer f.Close()

//启动trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()

//main
fmt.Println(“Hello World”)
}
运行程序

$ go run trace.go
Hello World
会得到一个 trace.out 文件,然后我们可以用一个工具打开,来分析这个文件。

$ go tool trace trace.out
2020/02/23 10:44:11 Parsing trace…
2020/02/23 10:44:11 Splitting trace…
2020/02/23 10:44:11 Opening browser. Trace viewer is listening on http://127.0.0.1:33479
我们可以通过浏览器打开 http://127.0.0.1:33479 网址,点击 view trace 能够看见可视化的调度流程。

G 信息

点击 Goroutines 那一行可视化的数据条,我们会看到一些详细的信息。

一共有两个G在程序中,一个是特殊的G0,是每个M必须有的一个初始化的G,这个我们不必讨论。
其中 G1 应该就是 main goroutine (执行 main 函数的协程),在一段时间内处于可运行和运行的状态。

M 信息

点击 Threads 那一行可视化的数据条,我们会看到一些详细的信息。

一共有两个 M 在程序中,一个是特殊的 M0,用于初始化使用,这个我们不必讨论。

P 信息
G1 中调用了 main.main,创建了 trace goroutine g18。G1 运行在 P1 上,G18 运行在 P0 上。

这里有两个 P,我们知道,一个 P 必须绑定一个 M 才能调度 G。

我们在来看看上面的 M 信息。

我们会发现,确实 G18 在 P0 上被运行的时候,确实在 Threads 行多了一个 M 的数据,点击查看如下:

多了一个 M2 应该就是 P0 为了执行 G18 而动态创建的 M2.

方式 2:Debug trace

package main

import (
“fmt”
“time”
)

func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println(“Hello World”)
}
}
编译

$ go build trace2.go
通过 Debug 方式运行

$ GODEBUG=schedtrace=1000 ./trace2
SCHED 0ms: gomaxprocs=2 idleprocs=0 threads=4 spinningthreads=1 idlethreads=1 runqueue=0 [0 0]
Hello World
SCHED 1003ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 2014ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 3015ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED 4023ms: gomaxprocs=2 idleprocs=2 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0 0]
Hello World
SCHED:调试信息输出标志字符串,代表本行是 goroutine 调度器的输出;
0ms:即从程序启动到输出这行日志的时间;
gomaxprocs: P 的数量,本例有 2 个 P, 因为默认的 P 的属性是和 cpu 核心数量默认一致,当然也可以通过 GOMAXPROCS 来设置;
idleprocs: 处于 idle 状态的 P 的数量;通过 gomaxprocs 和 idleprocs 的差值,我们就可知道执行 go 代码的 P 的数量;
threads: os threads/M 的数量,包含 scheduler 使用的 m 数量,加上 runtime 自用的类似 sysmon 这样的 thread 的数量;
spinningthreads: 处于自旋状态的 os thread 数量;
idlethread: 处于 idle 状态的 os thread 的数量;
runqueue=0: Scheduler 全局队列中 G 的数量;
[0 0]: 分别为 2 个 P 的 local queue 中的 G 的数量。
下一篇,我们来继续详细的分析 GMP 调度原理的一些场景问题。

三、Go 调度器调度场景过程全解析
(1) 场景 1
P 拥有 G1,M1 获取 P 后开始运行 G1,G1 使用 go func() 创建了 G2,为了局部性 G2 优先加入到 P1 的本地队列。
(2) 场景 2
G1 运行完成后 (函数:goexit),M 上运行的 goroutine 切换为 G0,G0 负责调度时协程的切换(函数:schedule)。从 P 的本地队列取 G2,从 G0 切换到 G2,并开始运行 G2 (函数:execute)。实现了线程 M1 的复用。

(3) 场景 3
假设每个 P 的本地队列只能存 3 个 G。G2 要创建了 6 个 G,前 3 个 G(G3, G4, G5)已经加入 p1 的本地队列,p1 本地队列满了。

(4) 场景 4
G2 在创建 G7 的时候,发现 P1 的本地队列已满,需要执行负载均衡 (把 P1 中本地队列中前一半的 G,还有新创建 G 转移到全局队列)

(实现中并不一定是新的 G,如果 G 是 G2 之后就执行的,会被保存在本地队列,利用某个老的 G 替换新 G 加入全局队列)

这些 G 被转移到全局队列时,会被打乱顺序。所以 G3,G4,G7 被转移到全局队列。

(5) 场景 5
G2 创建 G8 时,P1 的本地队列未满,所以 G8 会被加入到 P1 的本地队列。

G8 加入到 P1 点本地队列的原因还是因为 P1 此时在与 M1 绑定,而 G2 此时是 M1 在执行。所以 G2 创建的新的 G 会优先放置到自己的 M 绑定的 P 上。

(6) 场景 6
规定:在创建 G 时,运行的 G 会尝试唤醒其他空闲的 P 和 M 组合去执行。

假定 G2 唤醒了 M2,M2 绑定了 P2,并运行 G0,但 P2 本地队列没有 G,M2 此时为自旋线程(没有 G 但为运行状态的线程,不断寻找 G)。

(7) 场景 7
M2 尝试从全局队列 (简称 “GQ”) 取一批 G 放到 P2 的本地队列(函数:findrunnable())。M2 从全局队列取的 G 数量符合下面的公式:

n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))
至少从全局队列取 1 个 g,但每次不要从全局队列移动太多的 g 到 p 本地队列,给其他 p 留点。这是从全局队列到 P 本地队列的负载均衡。

假定我们场景中一共有 4 个 P(GOMAXPROCS 设置为 4,那么我们允许最多就能用 4 个 P 来供 M 使用)。所以 M2 只从能从全局队列取 1 个 G(即 G3)移动 P2 本地队列,然后完成从 G0 到 G3 的切换,运行 G3。

(8) 场景 8
假设 G2 一直在 M1 上运行,经过 2 轮后,M2 已经把 G7、G4 从全局队列获取到了 P2 的本地队列并完成运行,全局队列和 P2 的本地队列都空了,如场景 8 图的左半部分。

全局队列已经没有 G,那 m 就要执行 work stealing (偷取):从其他有 G 的 P 哪里偷取一半 G 过来,放到自己的 P 本地队列。P2 从 P1 的本地队列尾部取一半的 G,本例中一半则只有 1 个 G8,放到 P2 的本地队列并执行。

(9) 场景 9
G1 本地队列 G5、G6 已经被其他 M 偷走并运行完成,当前 M1 和 M2 分别在运行 G2 和 G8,M3 和 M4 没有 goroutine 可以运行,M3 和 M4 处于自旋状态,它们不断寻找 goroutine。

为什么要让 m3 和 m4 自旋,自旋本质是在运行,线程在运行却没有执行 G,就变成了浪费 CPU. 为什么不销毁现场,来节约 CPU 资源。因为创建和销毁 CPU 也会浪费时间,我们希望当有新 goroutine 创建时,立刻能有 M 运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费 CPU,所以系统中最多有 GOMAXPROCS 个自旋的线程 (当前例子中的 GOMAXPROCS=4,所以一共 4 个 P),多余的没事做线程会让他们休眠。

(10) 场景 10
​ 假定当前除了 M3 和 M4 为自旋线程,还有 M5 和 M6 为空闲的线程 (没有得到 P 的绑定,注意我们这里最多就只能够存在 4 个 P,所以 P 的数量应该永远是 M>=P, 大部分都是 M 在抢占需要运行的 P),G8 创建了 G9,G8 进行了阻塞的系统调用,M2 和 P2 立即解绑,P2 会执行以下判断:如果 P2 本地队列有 G、全局队列有 G 或有空闲的 M,P2 都会立马唤醒 1 个 M 和它绑定,否则 P2 则会加入到空闲 P 列表,等待 M 来获取可用的 p。本场景中,P2 本地队列有 G9,可以和其他空闲的线程 M5 绑定。

(11) 场景 11
G8 创建了 G9,假如 G8 进行了非阻塞系统调用。
​ M2 和 P2 会解绑,但 M2 会记住 P2,然后 G8 和 M2 进入系统调用状态。当 G8 和 M2 退出系统调用时,会尝试获取 P2,如果无法获取,则获取空闲的 P,如果依然没有,G8 会被记为可运行状态,并加入到全局队列,M2 因为没有 P 的绑定而变成休眠状态 (长时间休眠等待 GC 回收销毁)。

四、小结
总结,Go 调度器很轻量也很简单,足以撑起 goroutine 的调度工作,并且让 Go 具有了原生(强大)并发的能力。Go 调度本质是把大量的 goroutine 分配到少量线程上去执行,并利用多核并行,实现更强大的并发。

GO语言调试利器dlv快速上手

https://www.cnblogs.com/realjimmy/p/13418508.html

一、dlv的安装

1)下载dlv

git clone https://github.com/go-delve/delve.git $GOPATH/src/github.com/go-delve/delve

或者 go get github.com/derekparker/delve/cmd/dlv

2)安装

cd $GOPATH/src/github.com/go-delve/delve

make install

二、dlv简要使用说明

2.1、获取帮助信息

安装后执行dlv -h将会看到帮助信息:

image

上面的信息只是列出了命令列表,具体使用方法没有给出,我们可以执行dlv help + 具体命令来查看详细说明,

比如我们执行dlv help attach:

image

2.2、进入调试模式

1)dlv attach pid:类似与gdb attach pid,可以对正在运行的进程直接进行调试(pid为进程号)。

2)dlv debug:运行dlv debug test.go会先编译go源文件,同时执行attach命令进入调试模式,该命令会在当前目录下生成一个名为debug的可执行二进制文件,退出调试模式会自动被删除。

3)dlv exec executable_file :直接从二进制文件启动调试模式。如果要带参数执行需要添加–,如dlv exec executable_file — -f xxx.conf

4)dlv core executable_file core_file:以core文件启动调试,通常进行dlv的目的就是为了找出可执行文件core的原因,通过core文件可直接找出具体进程异常的信息。

3、常用调试方法

3.1 dlv trace追踪调用轨迹

该命令最直接的用途是可以追踪代码里函数的调用轨迹,

如下源代码,现用trace命令跟踪其调轨迹。

package main import ( “fmt” “time” ) func Test() { fmt.Println(“hello”) time.Sleep(1000 * 1000 * 100) } func Test2() { fmt.Println(“world”) time.Sleep(1000 * 1000 * 100) } func main() { for i := 0; i < 2; i++ { go Test() go Test2() } time.Sleep(1000 * 1000 * 2000) fmt.Println(“end”) }

运行结果,这里看除了Test,test2也被追踪:

$ dlv trace hello.go Test

> goroutine(19): main.Test2()

> goroutine(21): main.Test2()

> goroutine(18): main.Test()

world

hello

world

> goroutine(20): main.Test()

hello

=> ()

=> ()

=> ()

=> ()

end

3.2 调试模式基本命令

这里用上节的源码作为示例进行调试。开始调试:dlv debug hello.go

1)b(break):打断点

设置断点,当需要设置多个断点时,为了断点可识别可进行自定义命名。进入调试模式后先打断点。

例:b Test

b test.go:13

image

2)r(restart):重启当前进程

类似gdb里的run,如果刚执行dlv debug hello.go,进程已经起来,不用执行。如果进程已结算或需要重新开始则需要执行r

3)c(continue):继续执行到断点处

image

4)bp:查看所有断点

image

5)on  :当运行到某断点时执行相应命令

断点可以是名称(在设置断点时可命名断点)或者编号,例如on 3 p i表示运行到断点3时打印变量i。

image

6)cond(condition)   :有条件的断点

针对某个断点,只有表达式成立才会被中断。例:

condition 3 i==1

image

image

7)n(next):逐行执行代码,不进入函数内

8)s(step):逐行执行代码,遇到函数会跳进内部

9)stepout:当使用s命令进入某个函数后,执行它可跳出函数

10)si(step-instruction):单步单核执行代码

如果不希望多协程并发执行可以使用该命令,这在多协程调试时极为方便。

11)args:查看被调用函数所传入的参数值

12)locals:查看所有局部变量

locals var_name:查看具体某个变量,var_name可以是正则表达式。

13)clear:清除单个断点

14)clearall:清除所有断点

15)list:打印当前断点位置的源代码

list后面加行号可以展示该行附近的源代码,要注意该行必须是代码行而不能是空行。

16)bt:打印当前栈信息。

3.3 多协程调试

1)goroutines:显示所有协程

image

2)goroutine:协程切换

先执行goroutine 7表示切换到7号协程上

3.4 其他命令

1)frame:切换栈。

2)regs:打印寄存器内容。

3)sources:打印所有源代码文件路径

4)source:执行一个含有dlv命令的文件

source命令允许将dlv命令放在一个文件中,然后逐行执行文件内的命令。

5)trace:类似于打断点,但不会中断,同时会输出一行提示信息

浅析控制反转

https://zhuanlan.zhihu.com/p/60995312

介绍

控制反转 (Inversion of control) 并不是一项新的技术,是 Martin Fowler 教授提出的一种软件设计模式。那到底什么被反转了?获得依赖对象的过程被反转了。控制反转 (下文统一简称为 IoC) 把传统模式中需要自己通过 new 实例化构造函数,或者通过工厂模式实例化的任务交给容器。通俗的来理解,就是本来当需要某个类(构造函数)的某个方法时,自己需要主动实例化变为被动,不需要再考虑如何实例化其他依赖的类,只需要依赖注入 (Dependency Injection, 下文统一简称为 DI), DI 是 IoC 的一种实现方式。所谓依赖注入就是由 IoC 容器在运行期间,动态地将某种依赖关系注入到对象之中。所以 IoC 和 DI 是从不同的角度的描述的同一件事情,就是通过引入 IoC 容器,利用依赖注入的方式,实现对象之间的解耦。

那反转控制这种设计模式到底给前端带来了什么价值?这里先给出答案:

  1. 提升开发效率
  2. 提高模块化
  3. 便于单元测试

为什么我们需要它?

先给出一个例子,传统模式下当我们创建汽车 (Car) 这个类的时候,我们需要依赖轮子,发动机。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }
}

在 Car 这个类的构造器中我们装备了这个类中需用到的依赖项,这有什么问题呢?正如你所见,构造器不仅需要把依赖赋值到当前类内部属性上还需要把依赖实例化。比如 Engine 是通过 new 实例化的, 而 Tires 是通过工厂模式创建的。这样的高度耦合的依赖关系大大增加了单元测试难度和后期维护的成本。必然会出现牵一发而动全身的情形。而且在依赖 hard-code 写死在代码中并不符合 SOLID 开发原则中的 “开闭原则”。试想一个程序中,我们有超多种类的 Car,他们都依赖同一个依赖 Engine,但是有一天我想把所有的 Engine 换成 V8Engine 我该怎么做?全局搜索 Engine 修改为 V8Engine,想想都有点麻烦。


每辆车都需要自己控制引擎的创建

然后我们尝试一下 IoC 的版本。

import { Engine } from 'path/to/engine';
import { Tires } from 'path/to/tires';
import { Container } from 'path/to/container';

const container = new Container();
container.bind('engine', Engine);
container.bind('tires', Tires);

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = container.get('engine');
    this.tires = container.get('tires');
  }
}

现在引擎和轮胎的创建不再直接依赖它们的构造函数,而是通过 IoC 容器 (container) 来创建,使得 Car 类 和 Engine,Tires 没有了强耦合关系。代码中不再依赖于具体,而是依赖于 container 抽象容器,即要针对接口编程,不针对实现编程。过去思维中想要什么依赖,需要自己去 “拉” 改为抽象容器主动 “推” 给你,你只管使用实体就可以了。这是依赖倒转 (DIP) 的一种表现形式。


所有车装有引擎

因为汽车不直接依赖引擎,所以现在我想把所有引擎换成 V8 引擎,只需要把 IoC 容器中的引擎替换掉就可以了。


所有车装有 V8 引擎

原理

首先让我们实现一个最简单的容器来管理依赖,这里省略了大量类型定义,类型判断和异常处理,并不适用于生产环境。

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, constructor) {
    this.constructorPool.set(name, constructor);
  }

  get(name) {
    const target = this.constructorPool.get(name);
    return new target();
  }
  
}

container.register('myClass', DemoClass);
const classInstance = container.get('myClass');

constructorPool 是存放所有依赖的集合, 这是最简单的对象池,池中存储着构造函数和唯一标识符的集合。当调用 get 方法时,根据唯一标识符从对象池中拿到构造函数并返回实例,这只考虑了在注册时如参是构造函数,并且每次 get 的时候都返回新的实例。当我们需要在全局使用单一实例,并且在不同的地方拿到同一个实例,就需要在注册 (register) 的时候添加配置区分是单例模式还是工厂模式

class Container {
  private constructorPool;

  constructor() {
    this.constructorPool = new Map();
  }

  register(name, definition, dependencies) {
    this.constructorPool.set(name, {
      definition: definition,
      dependencies: dependencies
    });
  }

  get(name) {
    const targetConstructor = this.constructorPool.get(name);
    if (this._isClass(targetConstructor.definition)) {
      return this._createInstance(targetConstructor);
    } else {
      return targetConstructor.definition;
    }
  }
  
  // 递归拿到类的所有依赖集合
  _getResolvedDependencies(target) {
    let classDependencies = [];
    if (target.dependencies) {
      classDependencies = target.dependencies.map(dependency => {
        return this.get(dependency);
      });
    }
    return classDependencies;
  }

  _createInstance(target) {
    return new target.definition(...this._getResolvedDependencies(service));
  }

  // 判断是否为构造函数
  _isClass(definition) {
    return Object.prototype.toString.call(definition) === "[object Function]";
  }
}

而且依赖容器中需要维护一套自己的生命周期去满足连接数据库等需求,这里建议大家读一下 midway 团队出品的 injection ,这里有更完整的解决方案。

可测性

接下来我们用实际开发的例子看一下 IoC 是如何提高代码的可测性。

这里还是使用汽车的例子。

import { Engine } from 'engine/path';
import { Tires } from 'tires/path';

class Car {
  private engine;
  private tires;

  constructor() {
    this.engine = new Engine();
    this.tires = Tires.getInstance();
  }

  async run() {
    const engineStatus = await this.engine.check();
    const tiresStatus = await this.tires.check();

    if (engineStatus && tiresStatus) {
      return console.log('car running.');
    }
    return console.log('car broken');
  }
}

当我们实例化 Car 之后,执行 run 的时候,我们会调用 engine 和 tires 依赖里的方法,这个方法有可能会有外部依赖,比如从数据库中读数据,或者一次 http 请求。

export class Engine {
  private health = true;
  async check() {
    const result1 = await http.get('demo'); //check 1
    const result2 = await db.find({         //check 2
      id: 'demoId'
    });                                     
    const result3 = this.health;            //check 3

    return result1 && result2 && result3;
  }
}

当生产环境下我们执行 check,我们期望 3 个 check 都是 true 才让引擎发动,但是在测试阶段,我们只想执行 check3,忽略 check1 和 check2,这在传统开发模式下是很难做的,因为在 Car 构造函数中,已经写死了 Engine 的创建。想在测试阶段提供一个永远保持健康状态的引擎只能通过实例化时判断环境变量,赋值不同的实例,或者修改构造函数。

实例化时判断环境。

class Car {
  private engine;
  public running = false;

  constructor() {
    if (process.env === 'test') {
      this.engine = new TestEngine();
    } else {
      this.engine = new Engine();
    }
  }

  async run() {
    const engineStatus = await this.engine.check();

    return this.running = engineStatus;
}

公用类判断环境。

export class Engine {
  private health = true;
  async check() {
    if (process.env === 'test') {
      // test check
    } else {
      // normal check
    }
  }
}

这两种方式都不是优雅的解决方案,这种脏代码不应该在项目中出现。为了单元测试而需要判断执行环境的代码不应该写在具体实现上,而是应该放在公共的地方统一处理。

借由 IoC 容器,我们的业务代码不需要为单元测试作出修改,只需要在测试的时候,把测试的实例注册到 IoC 的容器中就可以了。

class Car {
  private engine;
  public running = false;

  constructor() {
    this.engine = container.get('engine');
  }

  async run() {
    const engineStatus = await this.engine.check();

    if (engineStatus) {
      return this.running = true;
    }
    return this.running = false;
  }
}

通过 IoC 我们可以优雅的处理测试环境下,业务代码中需要的依赖实体。因为当测试开始时,我们可以通过配置创建符合预期的类放到对象池中,业务代码中只需要直接使用就可以了。

以下给出一段对于 Car 的测试代码。

// car.spec.js
const Car = require('./car');

describe('Car', function () {
  it('#car.run', async function () {
    // 注册测试用依赖
    container.register('engine', MockEngine);

    const car = new Car();

    await car.run()

    expect(car.running).to.eql(true);
  });
});

社区最佳实践

在前端领域,反转控制可能被提及的比较少 (Angular 2 发布之前),但是在服务端领域, IoC 有很多实现,比如 Java 的 Spring 框架,PHP 的 Laravel 等等。Angular 的出现让我对前端工程化有了新的见解,Angular 把依赖注入作为应用设计模式,在框架的高度管理所有依赖和帮助开发者获取依赖,Angular 官方自己维护了一套自己的 DI 框架。

想揭开 DI 的神秘面纱需要了解两个东西。

首先是 @Injectable。这是 JavaScript 装饰器 (Decorators) 语法特性,装饰器语法已经进入 TC39 提案 Stage 2,但是还没正式进入 ECMA 语法标准。这个特发特性是使类可被注入的关键。开发者可以使用注解的方式自定义类的行为,方法,和运行时的属性。在 Angular 中使用 @Injectable 注解向 IoC 容器注册。angular/packages/core/src/di/ 在这个命名空间下 Angular 组织了 DI 的逻辑。框架提供了一套解决方案跟踪被注解的所有依赖,当你需要时提供正确的实例。

然后是 reflect-metadata。这个包提供了读取和修改类的源数据的能力,是帮助 Angular 判断被注入方所需实例类型的关键点。当使用这个包时,必须设置在 tsconfig.json 中开启 emitDecoratorMetadata: true 。

通过这两位的帮助,TypeScript 便可在编译时拿到被注解类的原数据,而且这些原属组是在运行时可用的。

总结

因篇幅原因,这里只是简单介绍 IoC 的使用,控制反转设计模式的优点是显而易见的,它有益于编写单元测试。因为依赖的实例化交给了容器,所以减少了实例化模版代码。让程序更易于扩展。去除代码之间的直接依赖关系,降低了耦合度。控制反转离不开依赖注入,现阶段社区中解决方案是通过 reflect-metadata 和装饰器来进行注入。

依赖注入

https://www.zhihu.com/question/32108444

第一章:小明和他的手机

从前有个人叫小明

小明有三大爱好,抽烟,喝酒…… 咳咳,不好意思,走错片场了。应该是逛知乎、玩王者农药和抢微信红包


小明的三大爱好

我们用一段简单的伪代码,来制造一个这样的小明

class Ming extends Person
{
    private $_name;

    private $_age;

    function read()
    {
        //逛知乎
    }

    function  play()
    {
        //玩农药
    }

    function  grab()
    {
        //抢红包
    }

}

但是,小明作为一个人类,没有办法仅靠自己就能实现以上的功能,他必须依赖一部手机,所以他买了一台iphone6,接下来我们来制造一个iphone6

class iPhone6 extends Iphone
{
    function read($user="某人")
    {
        echo $user."打开了知乎然后编了一个故事 \n";
    }

    function play($user="某人")
    {
        echo $user."打开了王者农药并送起了人头 \n";
    }

    function grab($user="某人")
    {
        echo $user."开始抢红包却只抢不发 \n";
    }
}

小明非常珍惜自己的新手机,每天把它牢牢控制在手心里,所以小明变成了这个样子

class Ming extends Person
{
    private $_name;

    private $_age;

    public function  __construct()
    {
        $this->_name = '小明';
        $this->_age = 26;
    }

    function read()
    {
        //……  省略若干代码
        (new iPhone6())->read($this->_name); //逛知乎
    }

    function  play()
    {
        //……  省略若干代码
        (new iPhone6())->play($this->_name);//玩农药

    }

    function  grab()
    {
        //……  省略若干代码
        (new iPhone6())->grab($this->_name);//抢红包

    }

}

今天是周六,小明不用上班,于是他起床,并依次逛起了知乎,玩王者农药,并抢了个红包。

$ming = new Ming();  //小明起床
$ming->read();
$ming->play();
$ming->grab();

这个时候,我们可以在命令行里看到输出如下

小明打开了知乎然后编了一个故事 
小明打开了王者农药并送起了人头 
小明开始抢红包却只抢不发

这一天,小明过得很充实,他觉得自己是世界上最幸福的人。

第二章: 小明的快乐与忧伤

小明和他的手机曾一起度过了一段美好的时光,一到空闲时刻,他就抱着手机,逛知乎,刷微博,玩游戏,他觉得自己根本不需要女朋友,只要有手机在身边,就满足了。

可谁能想到,一次次地系统更新彻底打碎了他的梦想,他的手机变得越来越卡顿,电池的使用寿命也越来越短,一直到某一天的寒风中,他的手机终于耐不住寒冷,头也不回地关了机。

小明很忧伤,他意识到,自己要换手机了。

为了能获得更好的使用体验,小明一咬牙,剁手了一台iphoneX,这部手机铃声很大,电量很足,还能双卡双待,小明很喜欢,但是他遇到一个问题,就是他之前过度依赖了原来那一部iPhone6,他们之间已经深深耦合在一起了,如果要换手机,他就要拿起刀来改造自己,把自己体内所有方法中的iphone6 都换成 iphoneX。


漫长的改造过程

经历了漫长的改造过程,小明终于把代码中的 iphone6 全部换成了 iphoneX。虽然很辛苦,但是小明觉得他是快乐的。

于是小明开开心心地带着手机去上班了,并在回来的路上被小偷偷走了。为了应急,小明只好重新使用那部刚刚被遗弃的iphone6,但是一想到那漫长的改造过程,小明的心里就说不出的委屈,他觉得自己过于依赖手机了,为什么每次手机出什么问题他都要去改造他自己,这不仅仅是过度耦合,简直是本末倒置,他向天空大喊,我不要再控制我的手机了。

天空中的造物主,也就是作为程序员的我,听到了他的呐喊,我告诉他,你不用再控制你的手机了,交给我来管理,把控制权交给我。这就叫做控制反转

第三章:造物主的智慧

小明听到了我的话,他既高兴,又有一点害怕,他跪下来磕了几个头,虔诚地说到:“原来您就是传说中的造物主,巴格梅克上神。我听到您刚刚说了 控制反转 四个字,就是把手机的控制权从我的手里交给你,但这只是您的想法,是一种思想罢了,要用什么办法才能实现控制反转,又可以让我继续使用手机呢?”

“呵“,身为造物主的我在表现完不屑以后,扔下了四个大字,“依赖注入!”

接下来,伟大的我开始对小明进行惨无人道的改造,如下

class Ming extends Person
{
    private $_name;

    private $_age;

    private $_phone; //将手机作为自己的成员变量

    public function  __construct($phone)
    {
        $this->_name = '小明';
        $this->_age = 26;
        $this->_phone = $phone;
        echo "小明起床了 \n";
    }

    function read()
    {
        //……  省略若干代码
        $this->_phone->read($this->_name); //逛知乎
    }

    function  play()
    {
        //……  省略若干代码
        $this->_phone->play($this->_name);//玩农药

    }

    function  grab()
    {
        //……  省略若干代码
        $this->_phone->grab($this->_name);//抢红包

    }

}

接下来,我们来模拟运行小明的一天

$phone = new IphoneX(); //创建一个iphoneX的实例
if($phone->isBroken()){//如果iphone不可用,则使用旧版手机
    $phone = new Iphone6();
}
$ming = new Ming($phone);//小明不用关心是什么手机,他只要玩就行了。
$ming->read();
$ming->play();
$ming->grab();

我们先看一下iphoneX 是否可以使用,如果不可以使用,则直接换成iphone6,然后唤醒小明,并把手机塞到他的手里,换句话说,把他所依赖的手机直接注入到他的身上,他不需要关心自己拿的是什么手机,他只要直接使用就可以了。

这就是依赖注入

第四章:小明的感悟

小明的生活开始变得简单了起来,而他把省出来的时间都用来写笔记了,他在笔记本上这样写到

我曾经有很强的控制欲,过度依赖于我的手机,导致我和手机之间耦合程度太高,只要手机出现一点点问题,我都要改造我自己,这实在是既浪费时间又容易出问题。自从我把控制权交给了造物主,他每天在唤醒我以前,就已经替我选好了手机,我只要按照平时一样玩手机就可以了,根本不用关心是什么手机。即便手机出了问题,也可以由造物主直接搞定,不需要再改造我自己了,我现在买了七部手机,都交给了造物主,每天换一部,美滋滋!
我也从其中获得了这样的感悟: 如果一个类A 的功能实现需要借助于类B,那么就称类B是类A的依赖,如果在类A的内部去实例化类B,那么两者之间会出现较高的耦合,一旦类B出现了问题,类A也需要进行改造,如果这样的情况较多,每个类之间都有很多依赖,那么就会出现牵一发而动全身的情况,程序会极难维护,并且很容易出现问题。要解决这个问题,就要把A类对B类的控制权抽离出来,交给一个第三方去做,把控制权反转给第三方,就称作控制反转(IOC Inversion Of Control)控制反转是一种思想,是能够解决问题的一种可能的结果,而依赖注入(Dependency Injection)就是其最典型的实现方法。由第三方(我们称作IOC容器)来控制依赖,把他通过构造函数、属性或者工厂模式等方法,注入到类A内,这样就极大程度的对类A和类B进行了解耦

go 静态检查工具

看了看日历,现在已经是 2021 年了,偶尔还是能看到有人在发诸如 《http body 未关闭导致线上事故》,或者 《sql.Rows 未关闭半夜惊魂》类的文章,令人有一种梦回 2015 的感觉。

在这个 Go 的静态分析工具已经强到烂大街的时代,写这些文章除了暴露这些人所在的公司基础设施比较差,代码质量低以外,并不能体现出什么其它的意思了。毕竟哪怕是不懂怎么读源码,这样的问题你 Google 搜一下也知道是怎么回事了。

特别是有些人还挂着大公司的 title,让人更加不能理解了。下面是简单的静态分析工具的科普,希望给那些还在水深火热的 Gopher 们送点解药。

何谓静态分析

静态分析是通过扫描并解析用户代码,寻找代码中的潜在 bug 的一种手段。

静态分析一般会集成在项目上线的 CI 流程中,如果分析过程找到了 bug,会直接阻断上线,避免有问题的代码被部署到线上系统。从而在部署早期发现并修正潜在的问题。

图片

社区常见 linter

时至今日,社区已经有了丰富的 linter 资源供我们使用,本文会挑出一些常见 linter 进行说明。

go lint

go lint 是官方出的 linter,是 Go 语言最早期的 linter 了,其可以检查:

  • 导出函数是否有注释
  • 变量、函数、包命名不符合 Go 规范,有下划线
  • receiver 命名是否不符合规范

但这几年社区的 linter 蓬勃发展,所以这个项目也被官方 deprecated 掉了。其主要功能被另外一个 linter:revive[^1] 完全继承了。

go vet

go vet 也是官方提供的静态分析工具,其内置了锁拷贝检查、循环变量捕获问题、printf 参数不匹配等工具。

比如新手老手都很容易犯的 loop capture 错误:

package main

func main() {
 var a = map[int]int {1 : 1, 2: 3}
 var b = map[int]*int{}
 for k, r := range a {
  go func() {
   b[k] = &r
  }()
 }
}

go vet 会直接把你骂醒:

~/test git:master ❯❯❯ go vet ./clo.go
# command-line-arguments
./clo.go:8:6: loop variable k captured by func literal
./clo.go:8:12: loop variable r captured by func literal

执行 go tool vet help 可以看到 go vet 已经内置的一些 linter。

~ ❯❯❯ go tool vet help
vet is a tool for static analysis of Go programs.

vet examines Go source code and reports suspicious constructs,
such as Printf calls whose arguments do not align with the format
string. It uses heuristics that do not guarantee all reports are
genuine problems, but it can find errors not caught by the compilers.

Registered analyzers:

    asmdecl      report mismatches between assembly files and Go declarations
    assign       check for useless assignments
    atomic       check for common mistakes using the sync/atomic package
    bools        check for common mistakes involving boolean operators
    buildtag     check that +build tags are well-formed and correctly located
    cgocall      detect some violations of the cgo pointer passing rules
    composites   check for unkeyed composite literals
    copylocks    check for locks erroneously passed by value
    errorsas     report passing non-pointer or non-error values to errors.As
    httpresponse check for mistakes using HTTP responses
    loopclosure  check references to loop variables from within nested functions
    lostcancel   check cancel func returned by context.WithCancel is called
    nilfunc      check for useless comparisons between functions and nil
    printf       check consistency of Printf format strings and arguments
    shift        check for shifts that equal or exceed the width of the integer
    stdmethods   check signature of methods of well-known interfaces
    structtag    check that struct field tags conform to reflect.StructTag.Get
    tests        check for common mistaken usages of tests and examples
    unmarshal    report passing non-pointer or non-interface values to unmarshal
    unreachable  check for unreachable code
    unsafeptr    check for invalid conversions of uintptr to unsafe.Pointer
    unusedresult check for unused results of calls to some functions

默认情况下这些 linter 都是会跑的,当前很多 IDE 在代码修改时会自动执行 go vet,所以我们在写代码的时候一般就能发现这些错了。

但 go vet 还是应该集成到线上流程中,因为有些程序员的下限实在太低。

errcheck

Go 语言中的大多数函数返回字段中都是有 error 的:

func sayhello(wr http.ResponseWriter, r *http.Request) {
 io.WriteString(wr, "hello")
}

func main() {
 http.HandleFunc("/", sayhello)
 http.ListenAndServe(":1314", nil) // 这里返回的 err 没有处理
}

这个例子中,我们没有处理 http.ListenAndServe 函数返回的 error 信息,这会导致我们的程序在启动时发生静默失败。

程序员往往会基于过往经验,对当前的场景产生过度自信,从而忽略掉一些常见函数的返回错误,这样的编程习惯经常为我们带来意外的线上事故。例如,规矩的写法是下面这样的:

data, err := getDataFromRPC()
if err != nil {
 return nil, err
}

// do business logic
age := data.age

而自信的程序员可能会写成这样:

data, _ := getDataFromRPC()

// do business logic
age := data.age

如果底层 RPC 逻辑出错,上层的 data 是个空指针也是很正常的,如果底层函数返回的 err 非空时,我们不应该对其它字段做任何的假设。这里 data 完全有可能是个空指针,造成用户程序 panic。

errcheck 会强制我们在代码中检查并处理 err。

gocyclo

gocyclo 主要用来检查函数的圈复杂度。圈复杂度可以参考下面的定义:

圈复杂度(Cyclomatic complexity)是一种代码复杂度的衡量标准,在 1976 年由 Thomas J. McCabe, Sr. 提出。在软件测试的概念里,圈复杂度用来衡量一个模块判定结构的复杂程度,数量上表现为线性无关的路径条数,即合理的预防错误所需测试的最少路径条数。圈复杂度大说明程序代码可能质量低且难于测试和维护,根据经验,程序的可能错误和高的圈复杂度有着很大关系。

看定义较为复杂但计算还是比较简单的,我们可以认为:

  • 一个 if,圈复杂度 + 1
  • 一个 switch 的 case,圈复杂度 + 1
  • 一个 for 循环,圈复杂度 + 1
  • 一个 && 或 ||,圈复杂度 + 1

在大多数语言中,若函数的圈复杂度超过了 10,那么我们就认为该函数较为复杂,需要做拆解或重构。部分场景可以使用表驱动的方式进行重构。

由于在 Go 语言中,我们使用 if err != nil 来处理错误,所以在一个函数中出现多个 if err != nil 是比较正常的,因此 Go 中函数复杂度的阈值可以稍微调高一些,15 是较为合适的值。

下面是在个人项目 elasticsql 中执行 gocyclo 的结果,输出 top 10 复杂的函数:

~/g/s/g/c/elasticsql git:master ❯❯❯ gocyclo -top 10  ./
23 elasticsql handleSelectWhere select_handler.go:289:1
16 elasticsql handleSelectWhereComparisonExpr select_handler.go:220:1
16 elasticsql handleSelect select_handler.go:11:1
9 elasticsql handleGroupByFuncExprDateHisto select_agg_handler.go:82:1
9 elasticsql handleGroupByFuncExprDateRange select_agg_handler.go:154:1
8 elasticsql buildComparisonExprRightStr select_handler.go:188:1
7 elasticsql TestSupported select_test.go:80:1
7 elasticsql Convert main.go:28:1
7 elasticsql handleGroupByFuncExpr select_agg_handler.go:215:1
6 elasticsql handleSelectWhereOrExpr select_handler.go:157:1

bodyclose

使用 bodyclose[^2] 可以帮我们检查在使用 HTTP 标准库时忘记关闭 http body 导致连接一直被占用的问题。

resp, err := http.Get("http://example.com/") // Wrong case
if err != nil {
 // handle error
}
body, err := ioutil.ReadAll(resp.Body)

像上面这样的例子是不对的,使用标准库很容易犯这样的错。bodyclose 可以直接检查出这个问题:

# command-line-arguments
./httpclient.go:10:23: response body must be closed

所以必须要把 Body 关闭:

resp, err := http.Get("http://example.com/")
if err != nil {
 // handle error
}
defer resp.Body.Close() // OK
body, err := ioutil.ReadAll(resp.Body)

HTTP 标准库的 API 设计的不太好,这个问题更好的避免方法是公司内部将 HTTP client 封装为 SDK,防止用户写出这样不 Close HTTP body 的代码。

sqlrows

与 HTTP 库设计类似,我们在面向数据库编程时,也会碰到 sql.Rows 忘记关闭的问题,导致连接大量被占用。sqlrows[^3] 这个 linter 能帮我们避免这个问题,先来看看错误的写法:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
if err != nil {
    return nil, err
}

for rows.Next() {
 err = rows.Scan(...)
 if err != nil {
  return nil, err // NG: this return will not release a connection.
 }
}

正确的写法需要在使用完后关闭 sql.Rows:

rows, err := db.QueryContext(ctx, "SELECT * FROM users")
defer rows.Close() // NG: using rows before checking for errors
if err != nil {
    return nil, err
}

与 HTTP 同理,公司内也应该将 DB 查询封装为合理的 SDK,不要让业务使用标准库中的 API,避免上述错误发生。

funlen

funlen[^4] 和 gocyclo 类似,但是这两个 linter 对代码复杂度的视角不太相同,gocyclo 更多关注函数中的逻辑分支,而 funlen 则重点关注函数的长度。默认函数超过 60 行和 40 条语句时,该 linter 即会报警。

linter 集成工具

一个一个去社区里找 linter 来拼搭效率太低,当前社区里已经有了较好的集成工具,早期是 gometalinter,后来性能更好,功能更全的 golangci-lint 逐渐取而代之。目前 golangci-lint 是 Go 社区的绝对主流 linter。

golangci-lint

golangci-lint[^5] 能够通过配置来 enable 很多 linter,基本主流的都包含在内了。

在本节开头讲到的所有 linter 都可以在 golangci-lint 中进行配置,

使用也较为简单,只要在项目目录执行 golangci-lint run . 即可。

~/g/s/g/c/elasticsql git:master ❯❯❯ golangci-lint run .
main.go:36:9: S1034: assigning the result of this type assertion to a variable (switch stmt := stmt.(type)) could eliminate type assertions in switch cases (gosimple)
 switch stmt.(type) {
        ^
main.go:38:34: S1034(related information): could eliminate this type assertion (gosimple)
  dsl, table, err = handleSelect(stmt.(*sqlparser.Select))
                                 ^
main.go:40:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleUpdate(stmt.(*sqlparser.Update))
                      ^
main.go:42:23: S1034(related information): could eliminate this type assertion (gosimple)
  return handleInsert(stmt.(*sqlparser.Insert))
                      ^
select_handler.go:192:9: S1034: assigning the result of this type assertion to a variable (switch expr := expr.(type)) could eliminate type assertions in switch cases (gosimple)
 switch expr.(type) {

参考资料

[1] https://revive.run/

[2] https://github.com/timakin/bodyclose

[3] https://github.com/gostaticanalysis/sqlrows

[4] https://github.com/ultraware/funlen

[5] https://github.com/golangci/golangci-lint

滴滴曹乐:如何成为技术大牛?

https://mp.weixin.qq.com/s/blazNWi-z2mAT9U-c6VdPw

桔妹导读:曹乐,清华大学毕业,16年初加入滴滴,带领团队建设了滴滴网约车技术体系,现任滴滴网约车技术部负责人。面对技术团队同学的成长困惑,曹乐给同学们写过一封信,他从各个维度去阐明自己的见解与想法,帮助同学们不再局限于从技术视角去看待问题,而是拥有更广阔的视野与方法。他围绕如何成为技术大牛这一话题提出以下一些想法:寻找范式、刻意练习、及时反馈;垂直打透、横向迁移、深度复盘;聪明人要下笨功夫。在此再次分享给大家这封信的内容。

很多同学都有关于工程师该如何成长的问题,大家普遍对如何成长为牛人,如何获得晋升,如何在繁忙的工作中持续学习充满了困惑,这其实是每一位同学成长过程中必经之路,在这里也想跟大家分享一下我的一些心得。
同学们普遍对成长充满了焦虑感。工作太忙没时间学习,需求太多太琐碎感觉自己没什么进步,做技术是不是做到35岁以后就没人要了,等等,都是对成长焦虑的体现。这种焦虑是正常的,所有的渴望,在内心的投射其实都是焦虑。任何一个渴望成长的人,不管处于什么阶段,一线工程师,架构师,还是总监,副总裁,其实内心中都是充满了焦虑的,无一例外。对于这种焦虑,我们所要做的是接纳,而不需要过度担忧。这种焦虑并不是说,想明白如何成长了就会没有了,到了某个阶段就会没有了的。成长的脚步和期待一刻不止,内心的焦虑也一刻不会停歇。正是这种焦虑感,驱使你写代码追查问题到星夜,驱使你牺牲休息娱乐的时间和一本本厚厚枯燥的书作伴,驱使你不断努力向前,不舍昼夜。相反的,如果内心中没有这种焦虑,反而是值得担忧的。这可能说明已经习惯呆在自己的舒适区了。在现在这样一个高速发展的社会,以及我们这样一个高速发展和变化的行业,失去对成长的渴望和焦虑反而是一个非常危险的信号。
所谓的程序员35岁危机,其实背后的根本原因是,有太多太多人在工作几年以后,就觉得自己什么都会了,之后的十几年工作只不过是头2-3年的简单重复而已。在我们这样一个行业里,在招聘的时候,如果摆在管理面前的两个人,一个是初出茅庐或刚工作2-3年,充满了对成长的渴望;另一个工作十多年了但水平和工作2-3年的人差不多,只是更熟练一些,不过在舒适区已经躺了十年了。如果负责招聘的是你,你会做出什么样的选择?
而另一方面,其实是高端人才在行业内的极度极度稀缺,这在行业内是非常普遍的现象,真正的大牛太稀缺了。在这样一个行业里,如果一个人能够持续成长,能力和工作年限成正比的持续提升,这样的人,任何时候在行业里都是被疯抢,怎么可能会遇到任何年龄的危机呢?
如何学习,其实是有方法论的,那就是刻意练习。所谓的10000小时成为大牛的理论是片面的,如果只是简单重复10000小时,是不可能成为大牛的。刻意练习包含了三个步骤。第一,找到你要学习的这个领域体系的范式(pattern);第二,针对每个范式刻意的反复学习和练习;第三,及时反馈。
大家在过往的工作和学习生活中,或多或少都在实践着刻意练习。拿面临高考的中学生举例子,好的学生通常是把一门功课拆成了很多知识点(寻找pattern),然后针对知识点以及他们的排列组合,有针对性的反复做各种难度的题(刻意练习),每次做完题都对一下答案看看正确与否,如果错了就思考,记录,复盘(持续及时反馈)。这样的学习方法就是事半功倍的。而事倍功半的学习方法,就是不分青红皂白拿起一本习题或卷子就拼命做,我上学的时候身边不少同学非常勤奋但成绩并不好,多半都是这个原因。再举一个我最近在学打羽毛球的例子,正确的学习方法是把打羽毛球拆解成步法和手上动作,小碎步,米字步,正反手挑球,放网,正手和头顶高远球吊球杀球等(寻找pattern),然后针对每一个动作反复练习(刻意练习),然后请教练或者录下来看视频纠正自己的动作(及时反馈);而错误的学习方法是,上来就盲目找人打比赛,以赛代练,这样的进步是很慢的,而且错误的动作形成习惯以后未来反而很难纠正。
当学习方法不正确的时候,刻苦的学习常常只是看起来很勤奋,并没有应有的效果。当接触一个陌生领域的时候,错误的学习方法是不带目的性,上来就找一堆相关的大部头开始啃。而正确的学习方法应该是快速梳理该领域的知识点,形成框架体系(寻找pattern),这里有些小窍门可以快速构建起一个领域的知识点体系,例如看一些该领域的综述性或开创性的文章(看论文,别瞎看网上的文章),或者找本该领域综述性的教科书看它的目录(注意,好的教科书的目录往往就是这个领域的知识框架,内容倒不一定非要看下去)。然后,针对每个知识点,找书里的相关章节,该领域相关paper里的相关section深入学习,建立起自己对这个知识点的理解(刻意练习)。最后,再把知识点和现实工作中的情况(自己工作,或其他公司相关的工作)进行对照(及时反馈),从而建立对一个知识点的深度理解,最后融会贯通建立对一个领域的理解。
这样说可能有点抽象,拿我当年学习分布式存储的过程为例子,先结合自己的工作内容梳理出需要深入了解的知识点(例如,元信息组织,Meta Server设计和HA,副本组织和管理,Recovery,Rebalance,单机存储引擎,数据/元信息流,纠删码,一致性,多租户,存储介质,网络环境和IDC等等),同时看很多综述性的材料,梳理分布式存储的知识点(有网上各种整理的比较好的文章,也有从各种系统实现的paper里抽出),不断迭代构建分布式存储领域的知识点(寻找pattern,这是最难的一个过程);然后针对每一个知识点,找相关材料进行深度学习,例如,对于分布式一致性,需要阅读CAP理论,Paxos的论文,Raft的论文等等以及周边的很多材料(刻意练习);然后找各种系统实现的论文或文章,比如GFS,Dynamo,Aurora,OceanBase,Ceph,Spanner等等,看看和对比它们在一致性上是如何考虑和取舍的,当然,最重要的是结合自己工作中的反复实践和所学知识点进行比对(及时反馈)。
这三个阶段并不是割裂的,而是周而复始的,经常会在刻意练习和及时反馈的学习过程中,发现自己遗漏的知识点,或者发现自己梳理的两个知识点其实是重合的。通过这种交叉比对,以及在实践中不断检验的方式建立的知识点是非常可落地的,而不会看了几篇论文以后就人云亦云。拿分布式存储的一致性举例子,如果不是反复对比、思考和反复实践,你不会发现GFS论文里最难的一段,多个Writer对一个文件进行append的逻辑,在实践中根本没用;你也不会发现看起来优雅而学术的CAP三选二的理论,实践中压根不是这么完美,很多时候只能三选一;你也不会发现Dynamo论文里的Vector Clock,网上有无数文章摇头晃脑的解读,但在Amazon的应用场景里是个典型的over design,Cassandra在这点就务实很多。
这时候大家可能会有个疑问,工作本身就如此繁忙了,哪里能抽出足够多的时间去学习?
其实工作和学习本身,是不应该被割裂的。工作本来就应该是学习的一部分,是学习中的实践和及时反馈的部分。学习如果脱离工作的实践,其实是非常低效的。因此每个同学应该对自己工作所在的这个技术和业务领域进行系统性的学习,并在工作中反复实践和验证。不同的领域之间其实是融汇贯通的,当你对一个领域精通并总结出方法论以后,很容易就能上手别的领域。因此花几年实践彻底研究透一个领域,对于刚工作几年的同学来说,是非常重要,甚至是必须的,也只有在一个领域打透之后才谈得上跨领域迁移,去拓展自己的知识面。更直接的说,对于一个领域还未完全掌握的同学,深度是最重要的,不用想广度的事情,等掌握了一个领域之后,再去拓展广度就变得很容易了。
这里一个常见的误区是,学习的内容和工作的领域没有太多直接的关系。例如,我以前曾经花了非常大的功夫去读Linux内核的源代码以及很多相关的大部头,几乎花掉了我将近两年的所有空闲时间,然而在我这些年的工作里,几乎是没有用处的,最多就是有一些“启发”,ROI实在是太低了,现在也忘得差不多了。更重要的,软件工程是一门实践科学,从书本上得到的知识如果没有在实践中应用和检验,基本上是没有用处的。举一个例子,很多优秀的架构师,尽管日常工作中可能反复在用,但未必说得出开闭原则,里氏替换原则,迪米特法则等等,反过来,对面向对象设计这7大原则出口成章的人,很多其实离真正的架构师还远得很,有些甚至只是博客架构师而已。实践远远比看书,看文章重要得多,上文所述的我构建自己分布式存储知识体系的过程,看起来好像都是看材料,看论文,而实际上80%的收获都来源于带着理论的实践,和从实践中总结沉淀的理论。因此,彻底搞明白自己工作所在的技术和业务领域,是最务实高效的做法,工作和学习割裂,会导致工作和学习都没做好。
这时候大家可能会有另一个疑问,感觉日常工作非常琐碎,学不到什么东西,怎么办?
如果把学习分成从书本中学,和从工作中学这两种的话,那毫无疑问,工作中的“知识密度”,比起书本的“知识密度”,肯定是要低很多的,因为书本里的知识,那都是人家从他们的工作中抽象总结出来的。这也是为什么大家普遍觉得日常工作“琐碎”。然而工作中每个点滴的琐事与平凡,都是可以抽象总结成为方法论的,更别说工作所在的领域自身的博大精深了。从日常工作中学习的秘诀,就是“行动中思考”。
对于每一个软件工程师,最重要的两个能力,是写代码的能力和trouble shooting的能力。并且,要成为优秀的架构师,出色的开发能力和追查问题的能力是一切的基础。提高写代码的能力的核心,首先在于坚持不断的写,但更重要的,在于每天,每周,持续不断的review自己之前的代码;同时,多review牛人写的代码,比如是团队里你觉得代码写的比你好的同事,比如社区里以代码漂亮著称的开源代码(作为一个C++程序员,当年我的榜样之一是boost库)。一旦觉得自己之前的代码不够好,就立刻复盘,立刻重构。更重要的是,多思考自己代码和好的代码之间不同之处背后的为什么,通常这就是为什么这些代码更好的背后的秘密。
特别要说明的是,代码规范除了知道是什么外,要格外重视思考每一个代码规范背后的为什么。代码规范的每一句话,背后无一例外都是一片江湖上的血泪史。要提高trouble shooting的能力,关键在于要深度复盘自己遇到的每一个问题,包括线上的,包括测试发现的,寻找每一个问题,每一次事故背后的root cause,并且思考后续如何避免同类问题,如何更快的发现同类问题。要对团队内外遇到的所有问题都要保持好奇心,关注一下周边的事故、问题背后的root cause。Trouble shooting能力的提高是几乎无法从书本上得到的,完全来源于对每一个问题的深度思考,以及广泛积累每一个问题。对于架构师而言,可能未必在一线写代码了,但看团队中一个架构师是否真正牛逼的一个很重要标准,就是看他是否能够追查出团队其他同学查不出来的问题。我见过的一个真正牛的架构师,对于系统中疑难杂症,通常问几个问题,就能大致猜出是哪里出的问题,以及可能的原因是什么,准确程度如同算命,屡试不爽,令人叹为观止。
对于一个架构师,除了更加优秀的代码能力和trouble shooting能力外,需要构建相对完整的当前技术领域的知识体系,需要有体系化的思维能力,需要对技术所服务的业务有非常深入的了解。体系化的思维能力,来源于两个方面。一方面是在日常工作中,对每一个接口设计,每一个逻辑,每一个模块、子系统的拆分和组织方式,每一个需求的技术方案,每一个系统的顶层设计,都要反复思考和推敲,不断地复盘。另一方面,需要大量广泛地学习行业内相似系统的架构设计,这其实就是开天眼,只是技术相对来说,行业内的交流更加频繁。淘宝、美团、百度、Google、Facebook、Amazon等各个公司介绍系统架构的论文和PPT铺天盖地,需要带着问题持续学习。除了技术领域本身外,架构师需要非常了解业务上是如何使用我们的系统的,否则非常容易over design,陷入技术的自嗨中,这也是为什么我说Amazon Dynamo论文里讲的Vector Clock是个over design的原因。
另一方面,很多时候技术上绕不过去的坎,可能非常复杂的实现,往往只需要上层业务稍微变通一下,就完全可以绕过去,这也是为什么我说GFS论文里,多个Writer同时Append同一个文件是个根本没用的设计(实际上Google内部也把这个功能去掉了)。只有真正知道上层业务是如何使用系统的,才可能真正做好架构。深入了解业务并不难,对于每个同学,只要对于每一个接到的需求,对于每一个需求评审中的需求,对于周边同学或团队要做的需求,都深入思考为什么业务要提出这个需求,这个需求解决了业务的什么问题,有没有更好的方案。遇到不明白的多和周边同学、产品、运营同学请教。最怕的是自己把自己限定为纯粹的研发,接到需求就无脑做,这等于放弃了主动思考。衡量一个人是不是好的架构师,也有一个方法。对于一个需求,如果他给出了好几个可行的方案,说这些方案也可以,那些方案也可以,往往说明他在架构师的路上还没有完全入门。架构师的难点不在于给出方案,而在于找到唯一的那一个最简单优雅的方案。
总结起来看,行动中思考,就是始终保持好奇,不断从工作中发现问题,不断带着问题回到工作中去;不断思考,不断在工作中验证思考;不断从工作中总结抽象,不断对工作进行复盘,持续不断把工作内容和全领域的知识交叉验证,反复实践的过程。
在工作所在的技术和业务领域中刻意练习,加上行动中思考,就是成为技术大牛的秘诀。
看起来方法也不复杂,为什么大牛还是非常稀少?
尽管我们通篇都在讲方法,但其实在成为技术大牛的路上,方法反而是没那么重要的。真正困难的,在于数年,数十年如一日的坚持。太多人遇到挫折,遇到瓶颈,就觉得手头的事情太乏味枯燥,就想要换一个方向,换一个领域,去学新的技术,新的东西。而真正能够成为大牛的,必须是能够青灯古佛,熬得住突破瓶颈前长时间的寂寞的,必须是肯下笨功夫的聪明人。因此,和坚持相比,方法其实并没有那么重要。

Linux 命令神器:lsof 入门

lsof是系统管理/安全的尤伯工具。我大多数时候用它来从系统获得与网络连接相关的信息,但那只是这个强大而又鲜为人知的应用的第一步。将这个工具称之为lsof真实名副其实,因为它是指“列出打开文件(lists openfiles)”。而有一点要切记,在Unix中一切(包括网络套接口)都是文件。

有趣的是,lsof也是有着最多开关的Linux/Unix命令之一。它有那么多的开关,它有许多选项支持使用-和+前缀。

  1. usage: [-?abhlnNoOPRstUvV] [+|-c c] [+|-d s] [+D D] [+|-f[cgG]]
  2. [-F [f]] [-g [s]] [-i [i]] [+|-L [l]] [+|-M] [-o [o]]
  3. [-p s] [+|-r [t]] [-S [t]] [-T [t]] [-u s] [+|-w] [-x [fl]] [–] [names]

正如你所见,lsof有着实在是令人惊讶的选项数量。你可以使用它来获得你系统上设备的信息,你能通过它了解到指定的用户在指定的地点正在碰什么东西,或者甚至是一个进程正在使用什么文件或网络连接。

对于我,lsof替代了netstat和ps的全部工作。它可以带来那些工具所能带来的一切,而且要比那些工具多得多。那么,让我们来看看它的一些基本能力吧:

关键选项

理解一些关于lsof如何工作的关键性东西是很重要的。最重要的是,当你给它传递选项时,默认行为是对结果进行“或”运算。因此,如果你正是用-i来拉出一个端口列表,同时又用-p来拉出一个进程列表,那么默认情况下你会获得两者的结果。

下面的一些其它东西需要牢记:

  • 默认 : 没有选项,lsof列出活跃进程的所有打开文件
  • 组合 : 可以将选项组合到一起,如-abc,但要当心哪些选项需要参数
  • -a : 结果进行“与”运算(而不是“或”)
  • -l : 在输出显示用户ID而不是用户名
  • -h : 获得帮助
  • -t : 仅获取进程ID
  • -U : 获取UNIX套接口地址
  • -F : 格式化输出结果,用于其它命令。可以通过多种方式格式化,如-F pcfn(用于进程id、命令名、文件描述符、文件名,并以空终止)

获取网络信息

正如我所说的,我主要将lsof用于获取关于系统怎么和网络交互的信息。这里提供了关于此信息的一些主题:

使用-i显示所有连接

有些人喜欢用netstat来获取网络连接,但是我更喜欢使用lsof来进行此项工作。结果以对我来说很直观的方式呈现,我仅仅只需改变我的语法,就可以通过同样的命令来获取更多信息。

  1. # lsof i
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. dhcpcd 6061 root 4u IPv4 4510 UDP *:bootpc
  5. sshd 7703 root 3u IPv6 6499 TCP *:ssh (LISTEN)
  6. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->192.168.1.5:49901 (ESTABLISHED)

使用-i 6仅获取IPv6流量

  1. # lsof i 6

仅显示TCP连接(同理可获得UDP连接)

你也可以通过在-i后提供对应的协议来仅仅显示TCP或者UDP连接信息。

  1. # lsof iTCP
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. sshd 7703 root 3u IPv6 6499 TCP *:ssh (LISTEN)
  5. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->192.168.1.5:49901 (ESTABLISHED)

使用-i:port来显示与指定端口相关的网络信息

或者,你也可以通过端口搜索,这对于要找出什么阻止了另外一个应用绑定到指定端口实在是太棒了。

  1. # lsof i :22
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. sshd 7703 root 3u IPv6 6499 TCP *:ssh (LISTEN)
  5. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->192.168.1.5:49901 (ESTABLISHED)

使用@host来显示指定到指定主机的连接

这对于你在检查是否开放连接到网络中或互联网上某个指定主机的连接时十分有用。

  1. # lsof i@172.16.12.5
  2.  
  3. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->172.16.12.5:49901 (ESTABLISHED)

使用@host:port显示基于主机与端口的连接

你也可以组合主机与端口的显示信息。

  1. # lsof i@172.16.12.5:22
  2.  
  3. sshd 7892 root 3u IPv6 6757 TCP 10.10.1.5:ssh->172.16.12.5:49901 (ESTABLISHED)

找出监听端口

找出正等候连接的端口。

  1. # lsof i sTCP:LISTEN

你也可以grep “LISTEN”来完成该任务。

  1. # lsof i | grep i LISTEN
  2.  
  3. iTunes 400 daniel 16u IPv4 0x4575228 0t0 TCP *:daap (LISTEN)

找出已建立的连接

你也可以显示任何已经连接的连接。

  1. # lsof i sTCP:ESTABLISHED

你也可以通过grep搜索“ESTABLISHED”来完成该任务。

  1. # lsof i | grep i ESTABLISHED
  2.  
  3. firefoxb 169 daniel 49u IPv4 0t0 TCP 1.2.3.3:1863->1.2.3.4:http (ESTABLISHED)

用户信息

你也可以获取各种用户的信息,以及它们在系统上正干着的事情,包括它们的网络活动、对文件的操作等。

使用-u显示指定用户打开了什么

  1. # lsof u daniel
  2.  
  3. snipped
  4. Dock 155 daniel txt REG 14,2 2798436 823208 /usr/lib/libicucore.A.dylib
  5. Dock 155 daniel txt REG 14,2 1580212 823126 /usr/lib/libobjc.A.dylib
  6. Dock 155 daniel txt REG 14,2 2934184 823498 /usr/lib/libstdc++.6.0.4.dylib
  7. Dock 155 daniel txt REG 14,2 132008 823505 /usr/lib/libgcc_s.1.dylib
  8. Dock 155 daniel txt REG 14,2 212160 823214 /usr/lib/libauto.dylib
  9. snipped

使用-u user来显示除指定用户以外的其它所有用户所做的事情

  1. # lsof u ^daniel
  2.  
  3. snipped
  4. Dock 155 jim txt REG 14,2 2798436 823208 /usr/lib/libicucore.A.dylib
  5. Dock 155 jim txt REG 14,2 1580212 823126 /usr/lib/libobjc.A.dylib
  6. Dock 155 jim txt REG 14,2 2934184 823498 /usr/lib/libstdc++.6.0.4.dylib
  7. Dock 155 jim txt REG 14,2 132008 823505 /usr/lib/libgcc_s.1.dylib
  8. Dock 155 jim txt REG 14,2 212160 823214 /usr/lib/libauto.dylib
  9. snipped

杀死指定用户所做的一切事情

可以消灭指定用户运行的所有东西,这真不错。

  1. # kill 9 `lsof -t -u daniel`

命令和进程

可以查看指定程序或进程由什么启动,这通常会很有用,而你可以使用lsof通过名称或进程ID过滤来完成这个任务。下面列出了一些选项:

使用-c查看指定的命令正在使用的文件和网络连接

  1. # lsof c syslogng
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. syslogng 7547 root cwd DIR 3,3 4096 2 /
  5. syslogng 7547 root rtd DIR 3,3 4096 2 /
  6. syslogng 7547 root txt REG 3,3 113524 1064970 /usr/sbin/syslogng
  7. snipped

使用-p查看指定进程ID已打开的内容

  1. # lsof p 10075
  2.  
  3. snipped
  4. sshd 10068 root mem REG 3,3 34808 850407 /lib/libnss_files2.4.so
  5. sshd 10068 root mem REG 3,3 34924 850409 /lib/libnss_nis2.4.so
  6. sshd 10068 root mem REG 3,3 26596 850405 /lib/libnss_compat2.4.so
  7. sshd 10068 root mem REG 3,3 200152 509940 /usr/lib/libssl.so.0.9.7
  8. sshd 10068 root mem REG 3,3 46216 510014 /usr/lib/liblber2.3
  9. sshd 10068 root mem REG 3,3 59868 850413 /lib/libresolv2.4.so
  10. sshd 10068 root mem REG 3,3 1197180 850396 /lib/libc2.4.so
  11. sshd 10068 root mem REG 3,3 22168 850398 /lib/libcrypt2.4.so
  12. sshd 10068 root mem REG 3,3 72784 850404 /lib/libnsl2.4.so
  13. sshd 10068 root mem REG 3,3 70632 850417 /lib/libz.so.1.2.3
  14. sshd 10068 root mem REG 3,3 9992 850416 /lib/libutil2.4.so
  15. snipped

-t选项只返回PID

  1. # lsof t c Mail
  2.  
  3. 350

文件和目录

通过查看指定文件或目录,你可以看到系统上所有正与其交互的资源——包括用户、进程等。

显示与指定目录交互的所有一切

  1. # lsof /var/log/messages/
  2.  
  3. COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
  4. syslogng 7547 root 4w REG 3,3 217309 834024 /var/log/messages

显示与指定文件交互的所有一切

  1. # lsof /home/daniel/firewall_whitelist.txt

高级用法

tcpdump类似,当你开始组合查询时,它就显示了它强大的功能。

显示daniel连接到1.1.1.1所做的一切

  1. # lsof u daniel i @1.1.1.1
  2.  
  3. bkdr 1893 daniel 3u IPv6 3456 TCP 10.10.1.10:1234->1.1.1.1:31337 (ESTABLISHED)

同时使用-t和-c选项以给进程发送 HUP 信号

  1. # kill HUP `lsof -t -c sshd`

lsof +L1显示所有打开的链接数小于1的文件

这通常(当不总是)表示某个攻击者正尝试通过删除文件入口来隐藏文件内容。

  1. # lsof +L1
  2.  
  3. (hopefully nothing)

显示某个端口范围的打开的连接

  1. # lsof i @fw.google.com:2150=2180

结尾

本入门教程只是管窥了lsof功能的一斑,要查看完整参考,运行man lsof命令或查看在线版本。希望本文对你有所助益,也随时欢迎你的评论和指正

资源