Junedayday Blog

六月天天的个人博客

0%

2020-03

2022-03-14 Go垃圾回收之旅3 - 静态编译

Go的源码会被编译成二进制文件,然后直接在对应的操作系统上运行。那么,这对学习GC有什么意义呢?让我们一起看看今天的内容。

《Go垃圾回收之旅》原文链接 - https://go.dev/blog/ismmkeynote

我们先和JAVA程序做个对比:

  • Go
    • Go编译的二进制文件
    • Linux
  • JAVA
    • Java打包的JAR文件
    • JVM
    • Linux

从这个架构不难猜到,上文谈到的 运行时,Go语言是直接编译到二进制文件里的;而JAVA是在JVM里实现的。

Go的这种实现方式,主要优劣点如下:

  • 优点: 程序的运行更具备 确定性,即开发人员可以根据代码,预测到程序的运行逻辑,更容易针对性地优化
  • 缺点:运行时没有JIT机制,无法针对具体的运行结果进行反馈优化

JIT的优化方向很多,我这里举一个热点函数优化的例子:

  1. 在代码中,函数f需要输入参数a和b
  2. 运行了一段时间后,JIT发现b的输入参数一直都是某个固定值b1
  3. 这时,JIT进行编译优化,将函数f编译成一个新函数f1
    1. f1只需要入参a
    2. b参数被替换为固定值b1
    3. 减少参数复杂度,能提升程序效率,尤其是热点函数
  4. 如果参数b突然变成了b2,那JIT就会从f1回退到f

简单来说:Go程序会怎么运行,往往在编码阶段就可以预期到了;而JAVA引入的JIT能力,可以在程序运行后,根据具体的运行情况,做针对性地优化,提升效率的同时也带了很多的不确定性。

两种实现方式各有利弊,团队可以根据实际情况自行选择。单从Go语言开发者来说,排查线上问题相对有JIT机制的JAVA程序简单很多。

这种确定性也让Go的GC相对简单不少,方便我们的学习。

2022-03-15 Go垃圾回收之旅4 - 性能压力下的Go程序

这篇演讲中,有这么一段很有意思的描述:

Out of memory, OOMs, are tough on Go;

temporary spikes in memory usage should be handled by increasing CPU costs, not by aborting.

Basically if the GC sees memory pressure it informs the application that it should shed load. Once things are back to normal the GC informs the application that it can go back to its regular load.

这段话包含了Go语言的GC,在面对CPU和内存压力下的决策:

  1. Go程序很少会OOM
    1. 这句话有一定前提,即内存设置是合理的,代码也没有明显的内存泄露问题
    2. 至于具体原因,我们看下文
  2. 业务高峰时内存使用率过高,应该通过提升CPU能力来解决,而不是中止程序
    1. 自动GC是需要CPU的计算资源做支持,来清理无用内存
    2. 要保证内存资源能支持程序的正常运行,有两个思路:
      1. 减少已有内存 - 通过GC来回收无用的内存
      2. 限制新增内存 - 即运行时尽可能地避免新内存的分配,最简单的方法就是不运行代码
    3. 显然,中止程序对业务的影响很大,我们更倾向于通过GC去回收内存,腾出新的空间
  3. GC压力高时,通知应用减少负载;而当恢复正常后,GC再通知应用可以恢复到正常模式了
    1. 我们可以将上述分为两类工作
      1. 业务逻辑的Goroutine
      2. GC的Goroutine
    2. 这两类Goroutine都会消耗CPU资源,区别在于:
      1. 运行业务逻辑往往会增加内存
      2. GC是回收内存
    3. 这里就能体现出Go运行时的策略
      1. 内存压力高时,GC线程更容易抢占到CPU资源,进行内存回收
      2. 代价是业务处理逻辑会有一定性能损耗,被分配的计算资源减少

GC最直观的影响就体现在延迟上。尤其是在STW - Stop The World情况下,程序会暂停所有非GC的工作,进行全量的垃圾回收。即便整个GC只花费了1s,所有涉及到这个程序的业务调用,都会增加1s延迟;在微服务场景下,这个问题会变得尤为复杂。

而GC的方案迭代,最直观的效果就体现在这个延迟优化上。

2022-03-17 Go垃圾回收之旅5 - GC Pacer

今天我们会重点讨论Go语言GC Pacer这个概念。

《Go垃圾回收之旅》原文链接 - https://go.dev/blog/ismmkeynote

要理解透彻GC Pacer的非常困难,底层实现细节必须深入到源码。这里,我们会通过分享中的关键性描述,来思考GC Pacer的设计理念。

It is basically based on a feedback loop that determines when to best start a GC cycle.

我们聚焦到两个词:

  • feedback loop 反馈循环,GC Pacer是会根据实际GC情况会不断迭代、反馈的
  • when to best start a GC cycle 强调了GC Pacer的目标 - 为了决定一个最佳启动GC的时机

GC Pacer的内部原理也和它的定义非常贴切,它是根据步长来决定GC的:

  • 对象:堆上的内存分配
  • 步长:设定值,如100%
  • 触发时机:当前堆上内存大小 >= 上次堆上内存大小 * (1 + 100%)

简单来说,就是一种 按比例增长 的触发机制。但这个机制没有那么简单,我们看下面这段:

If need be, the Pacer slows down allocation while speeding up marking.

At a high level the Pacer stops the Goroutine, which is doing a lot of the allocation, and puts it to work doing marking.

这两句描述和我们上一讲的内容对应上了 - 在一定的性能压力下,Pacer会减少内存的分配,而花更多的时间在对象的标记(marking)上,它是GC里的最耗性能的步骤。

对应到上面提到的反馈呢,也就是GC Pacer并不是单纯的一种 按比例增长 的触发机制,还有一些其余因素的影响:比如,当前这次的GC花费的CPU计算资源与标记的耗时超过了预期,表示当前整个GC存在一定压力,下次的GC的开始时间需要适当提前。

GC Pacer最近也重新做了一次大的改动,有兴趣的可以参考这篇文章:

https://go.googlesource.com/proposal/+/a216b56e743c5b6b300b3ef1673ee62684b5b63b/design/44167-gc-pacer-redesign.md

深入研究GC Pacer需要很多数学知识储备,留给有兴趣的同学自行探索了。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

2020-03

2022-03-07 CNCF-Provisioning层

今天,我们将加快进度,来对Provisioning这一层的项目做一下概览。Provisioning层是一种工具性质的项目,能一定程度上提升Kubernetes的综合能力,尤其是镜像管理和安全性。

KubeEdge

KubeEdge在近几年非常火,贴合边缘计算这个概念。

众所周知,由于Kubernetes是一个以master为核心的调度系统,许多核心能力都依赖master节点,会导致边端能力的受限。KubeEdge就是以这个为切入点。

目前落地KubeEdge的公司主要就是以华为为代表,其余大厂并没有加入到这个阵营。我之前的公司也引入过KubeEdge,但整体效果不佳。

在引入KubeEdge前,我们需要思考一个问题:边缘计算的系统一定要结合Kubernetes吗?

Harbor

Harbor是云原生的制品仓库,用来存储镜像等内容。它非常强调自身的安全性。

Harbor整体的学习与使用成本较低,也提供大量的界面化工具,主要存在新老版本的兼容问题。对于新团队,强烈建议直接使用Harbor。

Dragonfly

Dragonfly这个项目利用了P2P的思想,进行镜像、文件的分发,对多机房、多数据中心且传输的文件量大的场景才能突出其价值。

一般情况下我们无需考虑。

Open Policy Agent

OPA是一个很有意思的项目,我们可以看看它的实际构成。一个具体的OPA主要包括2块:

  1. Policy - Rego语法、特有
  2. Data - JSON语法

Policy即策略,例如大于某个值时执行策略;而Data则是配置Policy的具体数据,例如将Policy的某个值设置为10。组合了Policy+Data,这个策略才能真正地执行,可以使用OPA的库或者服务。

OPA的思想对项目的可读性和扩展性很有意义,尤其是对于一些需要大量策略配置的服务,如Envoy。

TUF/Notary

TUF是软件更新系统的行业事实上的标准,对于实际开发的意义不大。

Notary是一个允许任何人信任任意数据集合的项目,是TUF的一个具体实现。目前主要应用在镜像上。

Falco

Falco是一个保证运行时安全的项目,用来检测云原生运行时的各种异常与安全问题。

运行时的安全问题是系统安全的最后一道防线,往往需要研发团队紧急处理。

SPIFFE/SPIRE

SPIFFE 定义了服务的认证标准和认证信息的标准,SPIRE 是它的一个具体实现。

这块内容仍处于初期,我们了解即可。

小结

今天,我们走马观花地查看了Provisioning层的项目,大家重点关注Harbor和KubeEdge即可。其中Harbor操作难度低,可以快速上手使用;而KubeEdge面向的边缘计算领域比较窄,适用于特定人群。

到这里,我们的CNCF之旅已经基本完成了。后续有机会,我会挑选几个受欢迎的项目做细致的分析。

2022-03-10 Go垃圾回收之旅1 - 调度概览

关于Go语言的垃圾回收Garbage Collector,相信大家都在网上看过很多相关的文章:有的是科普性质的讲解,有的是直接对着源码的分析,也有的是与其余语言的对比。但文章往往具有时效性,或多或少与最新的Go语言实现有一些偏差。

从这篇开始,我将分析更具权威和参考价值的官方资料,让大家对Go的GC有深刻而长远的认识。

我们今天看的这一篇文章,来自内存管理大师理查德·哈德森的一次分享。我将挑选其中的一些关键点来描述。

《Go垃圾回收之旅》原文链接 - https://go.dev/blog/ismmkeynote

今天我们先来看第一块 - 调度概览

The Go scheduler multiplexes Goroutines onto OS threads which hopefully run with one OS thread per HW thread.

学习GC前,我们先得对Go的GMP模型有一定的了解。这句话包括了三个关键对象:

  • Goroutines - 即Go语言中通过关键词go产生的协程
  • OS thread - 系统线程,一般由操作系统创建
  • HW thread - 硬件线程,一般1核(物理核)CPU对应2个硬件线程

这三者,分别对应GMP模型中的G、M、P。

我们再聚焦于两个关键的描述:

  • Go scheduler multiplexes Goroutines - Go的调度器参考多路复用的机制,调度Goroutines的运行;
  • hopefully run with one OS thread per HW thread - 尽可能地将系统线程与硬件线程绑定,这样可以减少切换上下文时带来的开销。

关于GMP,我们到这里浅尝辄止。更多的实现细节,会在后面单独开启一个系列。

2022-03-11 Go垃圾回收之旅2 - value-oriented

我们继续看理查德·哈德森的分享 - https://go.dev/blog/ismmkeynote, 原文中有这么一句描述:

Go is a value-oriented language.

理解value-oriented与reference-oriented的差别,对我们学习与理解GC意义很大。以官方tar包中的Reader为例:

1
2
3
4
5
6
7
8
9
10
11
type Reader struct {
r io.Reader
pad int64 // Amount of padding (ignored) after current file entry
curr fileReader // Reader for current file entry
blk block // Buffer to use as temporary local storage

// err is a persistent error.
// It is only the responsibility of every exported method of Reader to
// ensure that this error is sticky.
err error
}

为了方便理解,举一个最简单的实现:

  • value-oriented语言,Reader结构体里的所有数据(各个field)都是放在 栈上连续的内存

  • reference-oriented语言,会将Reader结构体保存在堆空间里,而在栈上分配一个指针,记录Reader的起始地址,方便找到。

所以,两者的内存分配大致情况如下:

  • value-oriented
    • 栈:sizeof(Reader)
    • 堆:无
  • reference-oriented
    • 栈:1个指针(如64bit)
    • 堆:Reader主对象+以及Reader内部的子对象

强调一下,上面只是一个最简单的实现,实际情况会复杂得多。比如说复杂情况下的reference-oriented:

  • 栈:指针 + 对象信息

  • 堆:Reader对象以及Reader内部的各子对象

两种实现各有优劣。为了加深大家的印象,我这边以 运行时 作为考量点,来分析分析:

运行时 可以简单理解为:

在一个程序开始运行后,内部的数据量越多、数据变化越频繁、运行时间越长,运行时就越复杂,需要在内存中维护大量的信息。

  • value-oriented - 更适合轻量级的运行时,在栈上维护会更省空间、访问起来也高效
  • reference-oriented - 适合重量级的运行时,当对象数量达到一定级别后,统一在堆上管理更为方便

再次提醒:以上内容只是为了更好地描述value-oriented,简化了问题,不可以偏概全。比如,在Go语言中会涉及到变量的逃逸分析,可能会分配到堆上。

小结

本篇文章需要大家对 程序的堆与栈 有一定的基础了解,如果有同学不太清楚,建议花几分钟的时间去补一补。

文中提到了两个value-oriented的价值,建议了解大致原理即可,对理解GC意义不大。

  • 提高缓存命中 - 将相关的字段临近分配
  • 支持跨语言接口的访问 - 如Go访问C/C++

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

2022-02

2022-02-28 CNCF-OpenTelemetry等

今天,我们会以OpenTelemetry的三个核心Metrics、Logs、Traces为切入点,来看看OpenMetrics、Fluentd、Jaeger这三个具有代表性的项目。

OpenTelemetry

OpenTelemetry主要分为三大块:Metrics、Logs、Traces。

  • Metrics指标:程序将运行中关键的一些指标数据保存下来,常通过RPC的方式Pull/Push到统一的平台
  • Logs日志:依赖程序自身的打印。可通过ELK/EFK等工具采集到统一的平台并展示
  • Traces分布式追踪:遵循Dapper等协议,获取一个请求在整个系统中的调用链路

OpenTelemetry有多语言的、具体落地的现成库,供业务方快速落地实践。

更多可以参考 https://junedayday.github.io/2021/10/14/readings/go-digest-2/

Metrics - OpenMetrics

Evolving the Prometheus exposition format into a standard.

这个项目更多的是一种规范性质,基本就是以Prometheus的指标为标准。

更多的信息可以参考 https://prometheus.io/docs/instrumenting/exposition_formats/。

Logs - Fluentd

unified logging layer 统一的日志层

我们这里谈的Logs并不是指各编程语言的日志库,更多是指对日志产生后,如何进行解析与采集,而Fluentd就是一个代表性的项目。

当前主流的日志采集与分析方案,也由ELK转变成了EFK,也就是Logstash被Fluentd所替代。

Fluentd最核心的优势,在于它提供了大量的可供快速接入的插件 - https://www.fluentd.org/plugins。

Traces - Jaeger

open source, end-to-end distributed tracing

jaeger

Jaeger为OpenTracing提供了一套具体落地的方案,在Jaeger-Client侧也提供了多语言的SDK,我们就可以在分布式系统中查到请求的整个生命周期的具体数据。但落地到平台时,我们要重点思考以下两点:

  1. Traces与Logs的关联:两者的收集、推送、分析、展示的整个链路非常相似,而且我们也往往希望在Trace里查询信息时,能查到应用程序中自行打印的日志;
  2. Traces与Service Mesh的关联:Jaeger-Agent与Service Mesh的Sidecar模式非常类似,两者该怎么配合实践

我们可以独立建设Traces、Logs、Service Mesh这三块技术,但如果能将它们有机结合起来,有助于整个基础平台的统一化。

小结

OpenTelemetry提倡的可观测性在复杂工程中非常重要,能大幅提高程序的可维护性。如果有机会实践,建议大家应优先理解它的理念,再结合当前开源生态进行落地。

2022-03-01 CNCF-Litmus/ChaosMesh

随着Kubernetes的落地,混沌工程在近几年越来越流行,CNCF也将它作为重点项目。如果用一个词概括混沌工程,最常用的就是 故障注入

今天我将针对其中两个重要项目 - Litmus 和 ChaosMesh 做简单介绍,让大家对混沌工程有基本理解。

Litmus

litmus

Litmus的架构分为控制平面和执行平面。前者更多是提供可交互的web界面与整体的功能管理;而后者更专注于具体故障功能的实现。

整体来说,Litmus的架构是比较重量级的:

  1. 平台组件复杂
  2. 和Argo/Prometheus等软件有一定的交叉

ChaosMesh

chaos-mesh

相对而言,Chaos Mesh是一个比较轻量级的实现,整体的架构分为三块:

  1. Dashboard - 提供界面化交互能力
  2. Controller Manager - 统一管理多种CRD
  3. Daemon - 负责Pod端具体的故障注入

我们可以仔细分析这里的三大块,都有不少的扩展点:

  1. 可通过kubectl或自定义客户端下发指令
  2. Controller Manager 可实现工作流等复杂CRD
  3. Daemon可通过直接请求、容器运行时和Sidecar三种方式注入错误

故障注入能力

我个人更看好ChaosMesh这个项目,它的架构图中所呈现的扩展性非常棒。那么,接下来我就以Chaos Mesh为例,看看它所提供的的故障注入能力:

要覆盖基本故障这些case,已经需要投入非常多的人力物力了。

小结

我个人认为,混沌工程更多地是面向Iaas/Paas/Saas这类通用服务而提供的能力:

  1. Iaas/Paas/Saas这类服务是大规模共用的,对稳定性要求极高,才能体现出混沌工程的价值;
  2. 在业务系统中引入混沌工程有两大问题:
    1. 一方面,ROI是非常低的,业务变化多、迭代快,从业务开发的角度来看,更希望基础平台侧能覆盖这些异常情况
    2. 另一方面,混沌工程会带来很多不确定性,可能导致业务受损

对大部分的开发者来说,可以学习混沌工程的理念,提高自己设计系统时的健壮性,但不要过于追求完美。

2022-03-02 CNCF-Rook/Longhorn

今天,我们一起看看CNCF中存储这块。在云原生的环境下,分布式存储绝对是排名前三的技术难点,我也不可能通过短短五分钟描述清楚。

所以,我将针对性地介绍核心概念,帮助大家有个初步印象。

CSI - Container Storage Interface

容器存储之所以能在市场中蓬勃发展,离不开一个优秀的接口定义 - CSI。有了标准可依,各家百花齐放、优胜劣汰。

CSI规范链接 - https://github.com/container-storage-interface/spec/blob/master/spec.md

CSI整套规范内容很多,非存储这块的专业人士无需深入研究。不过,我们可以将它作为一个学习资料,花10分钟看看如下内容:

  1. 记住核心术语概念 - https://github.com/container-storage-interface/spec/blob/master/spec.md#terminology
  2. 了解架构 - https://github.com/container-storage-interface/spec/blob/master/spec.md#architecture
  3. 学习核心RPC的命名 - https://github.com/container-storage-interface/spec/blob/master/spec.md#rpc-interface

Ceph

开源中最有名的分布式存储系统当属Ceph了。它并没有被捐献给CNCF组织,所以我们无法在全景图里找到它。

https://docs.ceph.com/en/latest/start/intro/

这里不会讨论Ceph的细节,但还是希望大家能够了解:Ceph的维护成本不低,不要把它当作分布式存储的“银弹”。

所以,对于中小型公司来说,核心业务优先考虑使用公有云的存储产品。

Rook

Rook这个项目其实分为两类概念:

  1. 云原生存储编排引擎 - Rook
  2. 对接具体文件系统的实现 - rook-ceph/rook-nfs

Rook将Ceph的存储抽象为了Kubernetes系统中的Pod,进行统一调度,更加贴合云原生的设计理念。

Rook在市场上的应用基本集中在rook-ceph上,不太建议使用rook-nfs。

Longhorn

CNCF中另一个项目 - Longhorn则选择脱离Ceph的生态,实现了一整个从编排到具体存储的链路。

从其官方介绍来说,它更聚焦于微服务的场景,也就是能调度更大量级的Volume。

关于Longhorn的实践资料并不多,很难对其下结论,不过官方提供了完善的文档资料,给对应的开发者不小信心。

官网 - https://longhorn.io/

小结

分布式存储是一块仍在快速发展的领域,对大部分公司或团队来说选择比较有限:

  1. 优先考虑云服务
  2. 有Ceph维护经验+一定二次开发能力的,考虑rook+ceph
  3. 有强烈的技术信心的,可以考虑小规模落地Longhorn体验

到这里,我再补充一点:我们千万不要过度迷恋分布式存储中的“分布式”这个词,很多时候单点存储(本地存储和远程存储)就能满足我们的开发要求了。

2022-03-03 CNCF-containerd/cri-o

容器的运行时是Kubernetes运行容器的基础。与CSI类似,Kubernetes提出了CRI - Container Runtime Interface的概念。

今天,我们会更多地关注到CRI这个规范,而不会对这两个项目底层进行分析 - 毕竟,虽然提供了开放的接口,但目前绝大部分的k8s依然是以Docker容器作为具体实现的,并且这现象会持续相当一段时间。

我会侧重讲讲它们之间的联系。

CRI

CRI主要是针对的是Kubernetes中kubelet这个组件的,它用于在各个Node节点管理满足标准的OCI容器。

OCI是一个容器界的事实标准,主流的容器都满足该规范,我们在这里了解即可。

CRI最新的版本可以参考这个链接 - https://github.com/kubernetes/cri-api/blob/release-1.23/pkg/apis/runtime/v1alpha2/api.proto

CRI主要分为如下:

  1. RuntimeService 运行时服务
    1. PodSandbox 相关,即Pod中的根容器,一般也叫做pause容器;
    2. Container 相关,即普通的容器;
  2. ImageService 镜像服务

CRI里的内容很多,我这边分享个人阅读大型protobuffer文件的两个技巧:

  1. 弄懂高频词汇,如上面的Sandbox
  2. 聚焦核心的枚举enum

这里有两个枚举值得关注:

1
2
3
4
5
6
7
8
9
10
11
enum PodSandboxState {
SANDBOX_READY = 0;
SANDBOX_NOTREADY = 1;
}

enum ContainerState {
CONTAINER_CREATED = 0;
CONTAINER_RUNNING = 1;
CONTAINER_EXITED = 2;
CONTAINER_UNKNOWN = 3;
}

看到这两个定义,如果你对容器/Pod有一定的了解,能很快地联系到它们的生命周期管理了。

containerd

我们看看Docker与Kubernetes的分层:

  • Docker Engine -> containerd -> runc

  • Kubernetes(Kubelet组件) -> containerd -> runc

所以,containerd的作用很直观:对上层(Docker Engine/Kubernetes)屏蔽下层(runc等)的实现细节。

cri-o

LIGHTWEIGHT CONTAINER RUNTIME FOR KUBERNETES

从定义不难看出,它是面向Kubernetes的、更为轻量级的CRI。cri-o属于我们前面聊过的OCI项目之一。

对应上面的分层,cri-o封装的是runc这种具体的实现,让上层(Kubernetes)不需要关心下层具体运行容器的引擎。

小结

今天涉及的概念有很多,其实问题起源是 Docker没有捐献给CNCF基金会,为了摆脱不确定性,Kubernetes想解耦Docker这个强依赖。

无论是抽象出标准接口,还是通过分层设计,从理论上的确可以脱离了对Docker的依赖,但现实情况依旧有相当一段路要走,毕竟Docker的存量市场实在太过庞大。

2022-03-04 CNCF-CNI/Cilium

之前我们了解了CSI和CRI这两大块,今天我们将接触到Kubernetes另一个重要规范 - CNI,也就是Container Network Interface。

了解分布式系统的同学都深有体会,网络绝对是最复杂的因素,无论是拥塞、延迟、丢包等常规情况,还是像网络分区等复杂难题,都需要大量的学习成本。无疑,CNI的学习难度也是非常高的。而Cilium作为CNI的一种实现,我今天依然会简单带过。

CNI规范

官方链接 - https://github.com/containernetworking/cni

解决什么问题

CNI没有像CSI/CRI那样有一个明确的接口定义。要想了解它,我们先要理解它要解决的问题。

简单来说,就是在Kubernetes的容器环境里, 分配容器网络并保证互相联通

核心五个规范

  1. A format for administrators to define network configuration. 网络配置的格式
  2. A protocol for container runtimes to make requests to network plugins. 执行协议
  3. A procedure for executing plugins based on a supplied configuration. 基于网络配置的执行过程
  4. A procedure for plugins to delegate functionality to other plugins. 插件授权
  5. Data types for plugins to return their results to the runtime. 返回的格式

CNI插件

我们通常谈到CNI的插件,会存在歧义,主要有两种理解:

  1. 一种是涉及到CNI底层开发的插件,可参考 https://www.cni.dev/plugins/current/ , 主要为自研提供基础能力;
  2. 另一种是已经实现CNI的现有项目,如 Flannel、Calico、Canal 和 Weave 等

CNI项目对比

CNI的可选项目有很多,如市场上主流的Flannel和Calico,CNCF中的Cilium等。

对于绝大多数的用户,我们不会关心具体实现,更多地是希望找到一个最适合自己的。横向对比的网络资料有很多,我这里提供一张图作为参考。

链接 - https://itnext.io/benchmark-results-of-kubernetes-network-plugins-cni-over-10gbit-s-network-updated-august-2020-6e1b757b9e49

benchmark-cni

这里面的对比维度会让我们在选型时有所启发:

  • 配置
  • 性能(带宽)
  • 资源消耗
  • 安全特性

注意,表格里的快与慢、高与低都是相对值,在Kubernetes集群规模较大时才有明显差异。

小结

在落地Kubernetes时,我们不要盲目地追求速度快、性能高的方案,尤其是对规模小、没有资深运维经验的团队,应该优先实现最简单、最容易维护的方案。

基于CNI的容器网络解决方案,替换性会比较强,可以在后续有了足够的经验、遇到了相关的瓶颈后,再考虑针对性地迁移。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

go-tip

导语

Go语言的Goroutine特性广受好评,初学者也能快速地实现并发。但随着不断地学习与深入,有很多开发者都陷入了对goroutinechannelcontextselect等并发机制的迷惑中。

这里,我将结合一个具体示例,自顶向下地介绍这部分的知识,帮助大家形成体系。具体代码以下面这段为例:

1
2
3
4
5
6
7
8
// parent goroutine
func Foo() {
go SubFoo()
}

// children goroutine
func SubFoo() {
}

这里的Foo()父Goroutine,内部开启了一个子Goroutine - SubFoo()

Part1 - 父子Goroutine的生命周期管理

聚焦核心

父Goroutine子Goroutine 最重要的交集 - 是两者的生命周期管理。包括三种:

  1. 互不影响 - 两者完全独立、各自运行
  2. parent控制children - 父Goroutine结束时,子Goroutine也能随即结束
  3. children控制parent - 子Goroutine结束时,父Goroutine也能随即结束

这个生命周期的关系,重点体现的是两个协程之间的控制关系。

注意,这时不要过于关注具体的代码实现,如数据传递,容易绕晕。

1-互不影响

两个Goroutine互不影响的代码很简单,如同示例。

不过我们要注意一点,如果子goroutine需要context这个入参,尽量新建。更具体的内容我们看下一节。

2-parent控制children

下面是一个最常见的用法,也就是利用了context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// parent goroutine
func Foo() {
ctx, cancel := context.WithCancel(context.Background())
// 退出前执行,表示parent执行完了
defer cancel()

go SubFoo(ctx)
}

// children goroutine
func SubFoo(ctx context.Context) {
select {
case <-ctx.Done():
// parent完成后,就退出
return
}
}

当然,context并不是唯一的解法,我们也可以自建一个channel用来通知关闭。但综合考虑整个Go语言的生态,更建议大家尽可能地使用context,这里不扩散了。

延伸 - 如果1个parent要终止多个children时,context的这种方式依然适用,而channel就很麻烦了。

3-children控制parent

逻辑也比较直观,我们直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// parent goroutine
func Foo() {
var ch = make(chan struct{})
go SubFoo(ch)

select {
// 获取通知并退出
case <-ch:
return
}
}

// children goroutine
func SubFoo(ch chan<- struct{}) {
// 通知parent的channel
ch <- struct{}{}
}

情况3的延伸

如果1个parent产生了n个children时,又会有以下两种情况:

  1. n个children都结束了,才停止parent
  2. n个children中有m个结束,就停止parent

其中,前者的最常用的解决方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// parent goroutine
func Foo() {
var wg = new(sync.WaitGroup)
wg.Add(3)

go SubFoo(wg)
go SubFoo(wg)
go SubFoo(wg)

wg.Wait()
}

// children goroutine
func SubFoo(wg *sync.WaitGroup) {
defer wg.Done()
}

这两种延伸情况有很多种解法,有兴趣的可以自行研究,网上也有不少实现。

Par1小结

从生命周期入手,我们能在脑海中快速形成代码的基本结构:

  1. 互不影响 - 注意context独立
  2. parent控制children - 优先用context控制
  3. children控制parent - 一对一时用channel,一对多时用sync.WaitGroup等

但在实际的开发场景中,parent和children的处理逻辑会有很多复杂的情况,导致我们很难像示例那样写出优雅的select等方法,我们会在下节继续分析,但不会影响这里梳理出的框架。

Part2 - for+select的核心机制

一次性的select机制的代码比较简单,单次执行后即退出,讨论的意义不大。接下来,我将重点讨论for+select相关的代码实现。

for+select的核心机制

我们看一个来自官方的斐波那契数列的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
}
}
}

func main() {
c := make(chan int)
quit := make(chan int)
go func() {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacci(c, quit)
}

代码很长,我们聚焦于for+select这块,它实现了两个功能:

  1. c传递数据
  2. quit传递停止的信号

这时,如果你花时间去理解这两个channel的传递机制,容易陷入对select理解的误区;而我们应该从更高的维度,去看这两个case中获取到数据后的操作、即case中的执行逻辑,才能更好地理解整块代码。

分析select中的case

我们要注意到,在case里代码运行的过程中,整块代码是无法再回到select、去判断各case的(这里不讨论panic,return,os.Exit()等情况)。

以上面的代码为例,如果x, y = y, x+y函数的处理耗时,远大于x这个通道中塞入数据的速度,那么这个x的写入处将长期处于排队的阻塞状态。这时,不适合采用select这种模式。

所以,select适合IO密集型逻辑,而不适合计算密集型。也就是说,select中的每个case(包括default),应消耗尽量少的时间,快速回到for循环、继续等待。IO密集型常指文件、网络等操作,它消耗的CPU很少、更多的时间是在等待返回,它能更好地体现出runtime调度Goroutine的价值

Go 的 select这个关键词,可以结合网络模型中的select进行理解。

父子进程中的长逻辑处理

这时,如果我们的父子进程里,就是有那么一长段的业务逻辑,那代码该怎么写呢?我们来看看下面这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// children goroutine
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
LongLogic()
}
}
}

func LongLogic() {
// 如1累加到10000000
}

由于LongLogic()会花费很长的运行时间,所以当外部的context取消了,也就是父Goroutine发出通知可以结束了,这个子Goroutine是无法快速触发到<-ctx.Done()的,因为它还在跑LongLogic()里的代码。也就是说,子进程生命周期结束的时间点延长到LongLogic()之后了。

这个问题的原因在于违背了我们上面讨论的点,即在select的case里包含了计算密集型任务。

补充一下,case里包含长逻辑不代表程序一定有问题,但或多或少地不符合for+select+channel的设计理念。

两个长逻辑处理

这时,我们再来写个长进程处理,整个代码结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
LongLogic()
case <-dataCh2:
LongLogic()
}
}
}

这里,dataChdataCh2会产生竞争,也就是两个通道的 写长期阻塞、读都在等待LongLogic执行完成。给channel加个buffer可以减轻这个问题,但无法根治,运行一段时间依旧会阻塞。

改造思路

有了上面代码的基础,改造思路比较直观了,将LongLogic异步化,我们先通过新建协程来简单实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// children goroutine
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
go LongLogic()
case <-finishedCh:
fmt.Println("LongLogic finished")
}
}
}

func LongLogic() {
time.Sleep(time.Minute)
finishedCh <- struct{}{}
}

代码里要注意一个点,如果LongLogic()是一段需要CPU密集计算的代码,比如计算1累加到10000,它是没有办法通过channel等其余方式突然中止的。它具备一定的原子性 - 要么不跑,要么跑完,跑的过程中没有外部插手的地方

而如果硬要中断LongLogic(),那就往往只能杀掉整个进程。

Part2小结

我们记住for+select代码块设计的核心要领 - IO密集型。Go语言的goroutine特性,更多地是为了解决IO密集型程序的问题所设计的,对计算密集型的任务较其它语言没有太大优势。落到具体实践上,就是让每个case中代码的运行时间尽可能地短,快速回到for循环里的select去继续监听各个case中的channel

上面这段代码比较粗糙,在具体工程中会遇到很多问题,比如无限制地开启了大量的LongLogic()协程。我们会在下一节继续来看。

Part3 - 长耗时功能的优化

通过前面两篇的铺垫,我们对 父子Goroutine的生命周期管理for+select的核心机制 有了基本的了解,把问题聚焦到了耗时较长的处理函数中。

今天,我们再接着看看在具体工程中的优化点。

实时处理

我们先回顾上一讲的这段代码:

1
2
case <-dataCh:
go LongLogic()

直觉会认为go LongLogic()这里会很容易出现性能问题:当dataCh的数据写入速度很快时,有大量的LongLogic()还未结束、仍在程序内运行,导致CPU超负荷。

但是,如果这些代码编写的逻辑问题确实就是业务逻辑,即:程序确确实实需要实时处理这么多的数据,那我们该怎么做呢?

常规思路中引入 排队机制 确实是一个方案,但很容易破坏原始需求 - 实时计算处理,排队机制会导致延迟,这是业务无法接收的。在现实中,扩增资源是最直观的解决方案,最常见是利用Kubernetes平台的Pod水平扩容机制HPA,保证CPU使用率到达一定程度后自动扩容,而不用在程序中加上限制。

从本质上来说,这个问题是对实时计算资源的需求。

非实时处理 - 程序外优化

在实际工程中,我们其实往往对实时性要求没有那么高,所以排队等限流机制带来的延时可以接受的,也就是准实时。而综合考虑到研发代码质量的不确定性,迭代过程可能中会引入bug导致调用量暴增,这时限流机制能大幅提升程序的健壮性。

在程序外部,我们可以依赖消息队列进行削峰填谷,如:

  • 配置消息积压的告警来保证生产能力与消费能力的匹配
  • 配置限流参数来保证不要超过消费者程序的处理极限,避免雪崩

这里的消息队列在软件架构中是一个 分离生产与消费程序 的设计,有利于两侧程序的健壮性。在计算密集型的场景中,意义尤为重大,只需要针对计算密集型的消费者进行快速地扩缩容。

非实时处理 - 程序内优化

上面消息队列方案虽然很棒,但从系统来说引入了一个新的组件,在业务体量小的场景里,有一种杀鸡用牛刀的感觉,对部分没有消息队列的团队来说成本也较高。

那么,我们尝试在程序中做一下优化。首先,我们在上层要做一次抽象,将逻辑收敛到一个独立的package中(示例中为logic),方便后续优化

1
2
3
4
5
6
7
8
9
10
11
12
13
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
// logic包内部保证
logic.Run()
case result := <-logic.Finish():
fmt.Println("result", result)
}
}
}

而logic包中的大致框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package logic

var finishedCh = make(chan struct{})

func Run() {
// 在这里引入排队机制

go func() {
// long time process

<-finishedCh
}()
}

func Finish() <-chan struct{} {
return finishedCh
}

我们也可以在这里加一个error返回,在排队满时返回给调用方,由调用方决定怎么处理,如丢弃或重新排队等。排队机制的代码是业务场景决定的,我就不具体写了。

这种解法,可以类比到一个线程池管理。而更上层的for+select维度来看,类似于一个负责调度任务的master+多个负责执行任务的worker。

Part3小结

我们分别从三个场景分析了耗时较长的处理函数:

  • 实时处理 - 结合Paas平台进行资源扩容
  • 非实时处理 - 程序外优化 - 引入消息队列
  • 非实时处理 - 程序内优化 - 实现一个线程池控制资源

总结

本文分享的内容只是Go并发编程的冰山一角,希望能对大家有所启发,也欢迎与我讨论~

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

2022-02

2022-02-21 自顶向下地写出优雅的Goroutine(下)

通过前面两篇的铺垫,我们对 父子进程的生命周期管理select代码的核心机制 有了基本的了解。

今天我们再接着看看在具体工程中的优化点。注意,在上一篇,我们已经把问题聚焦到了耗时较长的处理函数中。

实时处理

我们先看回顾上一讲的这段代码:

1
2
case <-dataCh:
go LongLogic()

简单想一下,我们会觉得LongLogic()这里会很容易出现性能问题:当dataCh的数据写入速度很快时,有大量的LongLogic()还未结束、仍在程序内运行,导致CPU超负荷。

但是,如果这些代码编写的逻辑问题确实就是业务逻辑,即:程序确确实实需要实时处理这么多的数据,那我们该怎么做呢?

常规思路中引入 排队机制 确实是一个方案,但很容易破坏原始需求 - 实时计算处理,排队机制会导致延迟,那这就是业务无法接受的。在现实中,扩增资源是最直观的解决方案,最常见是利用Kubernetes平台的Pod水平扩容机制,保证CPU使用率到达一定程度后自动扩容,而不用在程序中加上限制。

这个问题的本质上是实时计算资源的需求。

非实时处理 - 程序外优化

在实际工程中,我们其实往往对实时性要求没有那么高,所以排队等限流机制带来的延时可以接受的。而综合考虑到研发代码质量的不确定性,迭代过程可能中会引入bug导致调用量暴增,这时限流机制能提升程序的健壮性。

在程序外部,我们可以依赖消息队列进行削峰填谷:

  • 配置消息积压的告警来保证生产者程序的监控
  • 配置限流参数来保证不要超过消费者程序的处理极限

在这里,消费队列在软件架构中是一个 分离生产与消费程序 的设计,有利于两侧程序的健壮性。在计算密集型的场景中,意义尤为重大。

非实时处理 - 程序内优化

上面消息队列方案虽然很棒,但从系统来说引入了一个新的组件,有时一种杀鸡用牛刀的感觉,对部分没有消息队列的团队来说也比较难以接受。

那么,我们尝试在程序中做一下优化。首先,我们在上层要做一次抽象,将逻辑收敛到一个独立的package中(示例中为logic),方便后续优化

1
2
3
4
5
6
7
8
9
10
11
12
13
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
// logic包内部保证
logic.Run()
case result := <-logic.Finish():
fmt.Println("result", result)
}
}
}

而logic包中的大致框架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package logic

var finishedCh = make(chan struct{})

func Run() {
// 在这里引入排队机制

go func() {
// long time process

<-finishedCh
}()
}

func Finish() <-chan struct{} {
return finishedCh
}

我们也可以在这里加一个error返回,在排队满时返回给调用方,由调用方决定怎么处理,如丢弃或重新排队等。排队机制的代码是业务场景决定的,我就不具体写了,本质上类似于一个线程池管理。

小结

从今天,我们分别从三个场景分析了耗时较长的处理函数:

  • 实时处理 - 结合Paas平台进行资源扩容
  • 非实时处理 - 程序外优化 - 引入消息队列
  • 非实时处理 - 程序内优化 - 程序内的线程池

到这里,我们自顶向下地写出优雅的Goroutine的三讲已经完成了,希望对大家有所启发,也欢迎向我提问。

2022-02-22 CNCF-Prometheus

看完了调度管理层与应用层的项目后,我们接下来了解可观察性和分析这块。提升可观察性和分析能力,非常有助于对整套系统的掌控。

今天的主角是CNCF中第二个毕业的项目 - Prometheus,它提供了软件系统核心的监控功能。我们今天就从核心架构入手,了解其特性。

架构

prom-architecture

这张图中的内容核心分为五块:

  • 指标收集端 - Exporters + Pushgateway
    • Exporters 长生命周期的进程,将指标保存在内存,重启后清零
    • Pushgateway 作为短生命周期指标的“中转站”
  • 服务端 - Prometheus Server
  • 服务发现 - Kubernetes等
    • 对接Kubernetes平台原生兼容
    • 对接非k8s平台,可以选择consul或者直接采用静态文件配置
  • 告警 - Alertmanager
  • 展示 - Prometheus web UI + Grafana等
    • web ui可以用来查看简单的指标
    • Grafana是最主流的指标展示工具,没有之一

文档写得比较粗糙,欢迎大家通过这个视频链接看看更详细的说明 https://www.bilibili.com/video/BV1PP4y1c7ps/

八大特性

  • 多维度数据Dimensional data - Prometheus implements a highly dimensional data model. Time series are identified by a metric name and a set of key-value pairs.
  • 强力的查询Powerful queries - PromQL allows slicing and dicing of collected time series data in order to generate ad-hoc graphs, tables, and alerts.
  • 很棒的可视化Great visualization - Prometheus has multiple modes for visualizing data: a built-in expression browser, Grafana integration, and a console template language.
  • 高效存储Efficient storage - Prometheus stores time series in memory and on local disk in an efficient custom format. Scaling is achieved by functional sharding and federation.
  • 简单操作Simple operation - Each server is independent for reliability, relying only on local storage. Written in Go, all binaries are statically linked and easy to deploy.
  • 精确告警Precise alerting - Alerts are defined based on Prometheus’s flexible PromQL and maintain dimensional information. An alertmanager handles notifications and silencing.
  • 很多客户端库Many client libraries - Client libraries allow easy instrumentation of services. Over ten languages are supported already and custom libraries are easy to implement.
  • 大量现有集成Many integrations - Existing exporters allow bridging of third-party data into Prometheus. Examples: system statistics, as well as Docker, HAProxy, StatsD, and JMX metrics.

小结

Prometheus的官方文档 - https://prometheus.io/docs/introduction/overview/ 提供了很多有价值的信息,尤其是原理和最佳实践。我也曾经实践过一套企业级的Prometheus平台,有机会的话会和大家分享分享。

2022-02-23 CNCF-Cortex/Thanos

今天,我将串讲两个基于Prometheus的扩展的项目:Cortex和Thanos。

为了让大家更好地了解到大型监控系统的方案,我将结合Prometheus自带的联邦方案和大家聊聊。

Prometheus的联邦模式

prometheus-federation

联邦模式是一种树状的级联模式,核心体现出了一个点:Prometheus本身就是一种Exporter,可以用来采集指标。

关于这个架构,我们还能发现以下特点:

  1. Prometheus高可用方案,是多个上层节点重复Pull下层数据,本质上仍然是单点保存全量数据
  2. Prometheus提供远程存储方案,但远程存储的能力很有限,往往只能支持异常后数据恢复
  3. Prometheus提供了record rule等指标加工能力,可以减少上层的数据存储
  4. 可以更好地保证网络的安全性,减少防火墙的配置

联邦模式基本能支持大多数Prometheus的场景,一般建议优先考虑。

Cortex

cortex-architecture

对标到上面的Prometheus联邦模式,Cortex核心是依赖远程写的接口。写完数据后,Cortex就与Prometheus完全没有依赖了。也就是说,Cortex是构建在Prometheus之上的一套解决方案。

上面的架构有很多细节上的实现,但我不想在这里聊得太细,主要考虑到:作为使用方,我们不需要过于关注Cortex的实现,毕竟它只依赖Prometheus远程写的接口,完全可以独立于Prometheus、快速迭代自身的架构。

所以,如果你想使用Cortex,可以看看官方的介绍文档 - https://cortexmetrics.io/,有什么特性是你特别关注的。

Thanos

Thanos提供了两种模式SidecarReceive,其中后者提出的时间不长,与Cortex的实现基本一致,我们就不细看了。我们重点看看边车的实现。

thanos-architecture

我们重点聚焦到Thanos和Prometheus的交互:

  1. 读 - 从Thanos传到Prometheus远程读的接口,再进行数据查询
  2. 写 - 由于是sidecar模式,两者共享Pod里的数据,所以Prometheus写入的数据可以由Thanos直接访问

从这两点来看,Thanos好像什么都没做,那它的意义在哪呢?其实,Thanos的核心是:依赖图中的对象存储,实现出的一套分布式的解决方案

我们上文提到,Prometheus本质上还是一个单体的架构,而Thanos提供的分布式方案,从理论上可以解决单点计算力的问题。所以,Thanos对标Prometheus和Cortex的差异性价值,非常依赖它在分布式上的表现。

小结

整体来说,关于Prometheus的扩展方案,我个人的倾向如下:

  1. 联邦模式:使用Prometheus的必要基础,有很多优化技巧,建议优先考虑;
  2. Cortex:对现有的Prometheus侵入小,适合快速解决问题,但长期来看很受限;
  3. Thanos:是对Prometheus从单体到分布式的一种改造,发展前景很棒,但遇到的问题也自然更多;

今天聊的这三种方案理解起来不难,我更希望对大家在软件架构上有所启发。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

2022-02

2022-02-14 CNCF-Argo

Argo是Kubernetes上最受欢迎的工作流引擎,已经有大量的用户群体与软件生态。围绕着Workflow这个关键词,我们来一起初步了解Argo

Workflow engine for Kubernetes

Argo Workflow

官方的介绍分为四点(前两点描述的是基本原理,后两者描述的是特定应用的价值):

  1. 工作流的每一个步骤都是一个容器;
  2. 以DAG(有向无环图)来分析工作流的依赖;
  3. 对计算密集型任务(如机器学习、大数据处理),能充分利用k8s集群的相对空闲的碎片时间;
  4. 结合CICD流水线,让应用能运行在云原生环境里快速迭代;

为什么使用Argo Workflow

Argo的工作流对标传统的CICD有很多亮点,但如果谈论其核心价值,主要集中在两点:

  1. 保证应用的整个生命周期都基于云原生生态,彻底抛弃原来的虚拟机等模式;
  2. 完全对接云原生,有利于充分利用Kubernetes实现更便捷的并行、扩缩容等操作;

我们就以一个经典的CICD Workflow的发展历程来看:

  1. 传统Jenkins为核心的CICD
    1. 提交代码到Gitlab
    2. 触发Jenkins编译任务,某VM服务器编译出二进制文件并发布
    3. 触发Jenkins部署任务,将二进制文件发布到对应机器并重新运行程序
  2. 改进版 - 容器化,将Gitlab/Jenkins/编译服务器等都改造到容器化平台中
  3. 云原生化 - 利用Argo Workflow

第二与第三阶段的区分并不清晰,我个人会从 配置是否集中化 这个特点进行分析。

目前很多大公司的CICD仍处于第二阶段,但它们沉淀出了不少类似于Argo工作流的能力。我们可以从以下三点进行思考:

  1. 工作流是和公司强相关的:往往依赖公司内的各种平台,如OA;
  2. 工作流的开发难度不高:只要规则清晰、要求严格,整体的开发量并不大,所以有能力、有资源的大公司,并不愿意太依赖开源生态;
  3. 云原生的工作流价值仍比较有限Argo体现出的价值,有不少类似的方案可以替代;

小结

Argo项目的用户在社区中日趋增长,这其实体现出了一个趋势 - 互联网进入精耕细作的阶段

在野蛮生长阶段遇到瓶颈时,公司会趋向于用扩增大量的人力或机器资源来解决问题;而在精耕细作阶段,随着Kubenetes为代表的基础平台能力的标准化,整个生态提供了丰富的能力集,技术人员更应重视遵循规范,把时间投入到合理的方向,来快速地迭代业务。

这时,以Argo为代表的工作流引擎,能帮助整个开发体系落地自动化的规范,自然越来越受到欢迎。

2022-02-15 谈谈对Go接口断言的误区

最近有好几个朋友和我聊到Go语言里的接口interface相关的使用方法,发现了一个常见的误区。今天,我分享一下我的思考,希望能给大家带来启发。

接口与实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 接口定义
type Order interface {
Pay() error
}

// 实现1
func orderImpl1 struct{
Id int64
}

func (order *orderImpl1)Pay() error {
return nil
}

// 实现2
func orderImpl2 struct{}

func (order *orderImpl2)Pay() error {
return nil
}

这是一个很常见的接口与实现的示例。

接口断言背后的真正问题

在代码中,我们经常会对抽象进行断言,来获取更详细的信息,例如:

1
2
3
4
5
6
7
8
func Foo() {
// 在这里是一个接口
var order Order
// 断言是orderImpl1,并打印其中内容
if o,ok := order.(orderImpl1); ok {
fmt.Println(o.Id)
}
}

这段代码很清晰,让我们层层递进,思考一下这段代码背后的真正逻辑:程序要使用 接口背后的具体实现(orderImpl1中的Id字段)。

这种做法,就和接口所要解决的问题背道而驰了:接口是为了屏蔽具体的实现细节,而这里的代码又回退成了具体实现。所以,这个现象的真正问题是:接口抽象得不够完全

解法1:新增获取方法

这个解法很直接,我们增加一个接口方法即可,如:

1
2
3
4
type Order interface {
Pay() error
GetId() int64
}

但是,如果要区分具体实现,即orderImpl2没有Id字段,我们最好采用一个error字段进行区分:

1
2
3
4
type Order interface {
Pay() error
GetId() (int64, error)
}

解法2:封装背后的真正逻辑

上面GetId这个方法,只是一个具体动作,按DDD的说法,这是一个贫血的模型。我们真正要关注的是 - 获取Id后真正的业务逻辑,将其封装成一个方法

比如说,我们要获取这个Id后,想要根据这个Id取消这个订单,那么完全可以封装到一个Cancel()函数中;

又比如说,我们仅仅想要打印具体实现的内部信息,那么完全可以实现一个Debug() string方法,将想要的内容都拼成字符串返回出来。

小结

今天讲的这个case在业务开发中非常常见,它是一种惯性思维解决问题的产物。我们无需苛求所有抽象都要到位,但心里一定要有明确的解决方案。

2022-02-16 CNCF-Flux

今天我们来看CNCF中另一款持续交付的项目 - Flux。相对于ArgoFlux的应用范围不广,但它的功能更加简洁、使用起来也更为便捷。

核心流程

gitops-toolkit

Flux的核心实现非常清晰,主要分为两块:

  1. Source controller用于监听Source的变化,如常见的github、gitlab、helm;
  2. 将部署任务,交由Kustomize controller 或 Helm controller进行实现;

这里有一个秀英语单词的技巧,在软件系统里经常会将定制化这个词,Customize用Kustomize代替。

核心概念

官方的核心概念如下:https://fluxcd.io/docs/concepts/

  1. GitOps的理念有很多说法,可以简单认为就是:围绕着Git而展开的一套CICD机制

GitOps is a way of managing your infrastructure and applications so that whole system is described declaratively and version controlled (most likely in a Git repository), and having an automated process that ensures that the deployed environment matches the state specified in a repository.

  1. Source源,包括期望状态与获取的途径。

A Source defines the origin of a repository containing the desired state of the system and the requirements to obtain it (e.g. credentials, version selectors).

  1. Reconciliation协调,重点是怎么协调、也就是Controller执行的逻辑,最常见的就是自己编写一个Operator。

Reconciliation refers to ensuring that a given state (e.g. application running in the cluster, infrastructure) matches a desired state declaratively defined somewhere (e.g. a Git repository).

小结

CICD相关软件目前的格局还不是很清晰,建议大家多花时间在选型上,尽可能地符合自己的业务场景,而不建议做过多的二次开发。Flux是一个非常轻量级的CD项目,对接起来很方便,很适合无历史包袱的研发团队快速落地。

2022-02-17 自顶向下地写出优雅的Goroutine(上)

Go语言的Goroutine特性广受好评,让初学者也能快速地实现并发。但随着不断地学习与深入,有很多开发者都陷入了对goroutinechannelcontextselect等并发机制的迷惑中。

那么,我将自顶向下地介绍这部分的知识,帮助大家形成体系。具体的代码以下面这段为例:

1
2
3
4
5
6
7
8
// parent goroutine
func Foo() {
go SubFoo()
}

// children goroutine
func SubFoo() {
}

这里的Foo()父Goroutine,内部开启了一个子Goroutine - SubFoo()

聚焦核心

父Goroutine子Goroutine 最重要的交集 - 是两者的生命周期管理。包括三种:

  1. 互不影响 - 两者完全独立
  2. parent控制children - 父Goroutine结束时,子Goroutine也能随即结束
  3. children控制parent - 子Goroutine结束时,父Goroutine也能随即结束

这个生命周期的关系,体现了一种控制流的思想。

注意,这个时候不要去关注具体的数据或代码实现,初学者容易绕晕。

1-互不影响

两个Goroutine互不影响的代码很简单,如同示例。

不过我们要注意一点,如果子goroutine需要context这个入参,尽量新建。这点我们看第二个例子就清楚了。

2-parent控制children

下面是一个最常见的用法,也就是利用了context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// parent goroutine
func Foo() {
ctx, cancel := context.WithCancel(context.Background())
// 退出前执行,表示parent执行完了
defer cancel()

go SubFoo(ctx)
}

// children goroutine
func SubFoo(ctx context.Context) {
select {
case <-ctx.Done():
// parent完成后,就退出
return
}
}

当然,context并不是唯一的解法,我们也可以自建一个channel用来通知关闭。但综合考虑整个Go语言的生态,更建议大家尽可能地使用context,这里不扩散了。

延伸 - 如果1个parent要终止多个children时,context的这种方式依然适用。

3-children控制parent

这部分的逻辑也比较直观:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// parent goroutine
func Foo() {
var ch = make(chan struct{})
go SubFoo(ch)

select {
// 获取通知并退出
case <-ch:
return
}
}

// children goroutine
func SubFoo(ch chan<- struct{}) {
// 通知parent的channel
ch <- struct{}{}
}

情况3的延伸

如果1个parent产生了n个children时,又会有以下两种情况:

  1. n个children都结束了,才停止parent
  2. n个children中有m个结束,就停止parent

其中,前者的最常用的解决方案如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// parent goroutine
func Foo() {
var wg = new(sync.WaitGroup)
wg.Add(3)

go SubFoo(wg)
go SubFoo(wg)
go SubFoo(wg)

wg.Wait()
}

// children goroutine
func SubFoo(wg *sync.WaitGroup) {
defer wg.Done()
}

关于这两个延伸情况更多的解法,就留给大家自己去思考了,它们有不止一种解法。

小结

从生命周期入手,我们能快速地形成代码的基本结构:

  1. 互不影响 - 注意context独立
  2. parent控制children - 优先用context控制
  3. children控制parent - 一对一时用channel,一对多时用sync.WaitGroup等

但在实际的开发场景中,parent和children的处理逻辑会有很多复杂的情况,导致我们很难像示例那样写出优雅的select等方法,我们会在下期继续分析,但不会影响我们今天梳理出的框架。

2022-02-18 自顶向下地写出优雅的Goroutine(中)

通过上一篇,我们通过生命周期管理了解了父子进程的大致模型。

今天,我们将更进一步,分析优雅的Goroutine的核心语法 - select。

了解select的核心意义

我们看一个官方的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import "fmt"

func fibonacci(c, quit chan int) {
x, y := 0, 1
for {
select {
case c <- x:
x, y = y, x+y
case <-quit:
fmt.Println("quit")
return
}
}
}

func main() {
c := make(chan int)
quit := make(chan int)
go func() {
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
quit <- 0
}()
fibonacci(c, quit)
}

代码很长,我们聚焦于select这块,它实现了两个功能:

  1. 传递数据
  2. 传递停止的信号

这时,如果你深入去理解这两个channel的用法,容易陷入对select理解的误区;而我们应该从更高的维度,去看这两个case中获取到数据后的操作,才能真正掌握。

分析select中的case

我们要注意到,在case里代码运行的过程中,整个goroutine都是忙碌的(除非调用panic,return,os.Exit()等函数退出)。

以上面的代码为例,如果x, y = y, x+y函数的处理耗时,远大于x这个通道中塞入数据的速度,那么这个x的写入处,将长期处于排队的阻塞状态。这时,不适合采用select这种模式。

所以说,select适合IO密集型逻辑,而不适合计算密集型。也就是说,select中的每个case,应尽量花费少的时间。IO密集型常指文件、网络等操作,它消耗的CPU很少、更多的时间在等待返回。

Go 的 select这个关键词,可以结合网络模型中的select进行理解。

父子进程中的长逻辑处理

这时,如果我们的父子进程里,就是有那么一长段的业务逻辑,那代码该怎么写呢?我们来看看下面这一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// children goroutine
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
LongLogic()
}
}
}

func LongLogic() {
// 如1累加到10000000
}

由于LongLogic()会花费很长的运行时间,所以当外部的context取消了,也就是父Goroutine发出通知可以结束了,这个子Goroutine是无法快速触发到<-ctx.Done()的,因为它还在跑LongLogic()里的代码。也就是说,子进程生命周期结束的时间点延长到LongLogic()之后了。

所以,根本原因在于违背了我们上面说的原则,即在select的case/default里包含了计算密集型任务。

case里包含长逻辑不代表程序一定有问题,但或多或少地不符合select+channel的设计理念。

两个长逻辑处理

这时,我们再来写个长进程处理,整个代码结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
LongLogic()
case <-dataCh2:
LongLogic()
}
}
}

这时,dataCh和dataCh2会产生竞争,也就是两个通道的 写长期阻塞、读都在等待LongLogic执行完成。给channel加个buffer可以减轻这个问题,但无法根治,运行一段时间依旧阻塞。

改造思路

有了上面代码的基础,改造思路比较直观了,将LongLogic异步化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// children goroutine
func SubFoo(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case <-dataCh:
go LongLogic()
case <-finishedCh:
fmt.Println("LongLogic finished")
}
}
}

func LongLogic() {
time.Sleep(time.Minute)
finishedCh <- struct{}{}
}

我们要注意一个点,如果LongLogic()是一段需要CPU密集计算的代码,比如计算1累加到10000,它是没有办法通过channel等其余方式突然中止的。它具备一定的原子性 - 要么不跑,要么跑完,没有Channel的插手的地方

而如果硬要中断LongLogic(),那就是杀掉整个进程。

小结

今天的内容是围绕着select这个关键词展开的,我们记住select代码块设计的核心要领 - IO密集型。Go语言的goroutine特性,更多地是为了解决IO密集型程序的问题所设计的编程语言,对计算密集型的任务较其它语言很难体现出其价值。

落到具体实践上,就是让每个case中代码的运行时间尽可能地短,快速回到for循环里的select去继续监听各个case中的channel。

上面这段代码比较粗糙,在具体工程中会遇到很多问题,比如无脑地开启了大量的LongLogic()协程。我们会放在最后一讲再来细谈。

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

2022-02

2022-02-07 CNCF-CloudEvents

今天,我们一起来看CloudEvents,并不是一款成熟的软件系统,而更像是一种协议与标准。不过,它提出的相关概念,对我们开发与设计软件系统时,很有参考意义。

官方定义

CloudEvents is a vendor-neutral specification for defining the format of event data.

顾名思义,CloudEvents项目旨在定义 云时代的事件。事件是一个很广的定义,在不同的软件系统里有不同的表现形式。

想要将所有软件系统里的事件进行标准化,这里面的工作量与难度可想而知,在很长一段时间内很难落地。在我看来,这个项目的意义是长期的 - 先提供一套切实可行的标准与SDK,再尝试结合云原生生态的在核心项目中落地,最后再大规模推广

核心概念

事件里涉及到了很多概念,我选择其中的核心概念,并将其分成了两类。

完整的内容可以参考: https://github.com/cloudevents/spec/blob/v1.0.1/spec.md#notations-and-terminology

数据类:

  1. Occurrence - 发生(客观事实)
  2. Data - 数据
  3. Context - 上下文
  4. Event - 事件

传输类:

  1. Producer - 生产者
  2. Intermediary - 中介
  3. Consumer - 消费者
  4. Event Format - 事件格式
  5. Message - 消息
  6. Protocol - 协议

关键字段

CloudEvents给出了规范的同时,也给出了多语言的SDK。我们可以参考它的命名方式,引入到自己的开发系统中。

必填字段:

  1. id - string
  2. source - URI-reference
  3. specversion - string
  4. type - string

保证 id+source 全局唯一

可选字段:

  1. datacontenttype - string
  2. dataschema - string
  3. subject -string
  4. time - timestamp,推荐RFC-3339

小结

CloudEvents 目前仍处于非常早期的阶段,有兴趣的朋友,可以尝试引入其SDK,将内部的RPC、MQ等通信数据统一起来。

从长期来看,将一个系统中的事件格式统一起来,对整个系统的帮助是很大的。比如说,我们完全可以将服务注册、服务发现等功能认为是一种事件,要求EtcdZookeeperConsul等均支持该方式,就能有利于相关功能的标准化。

2022-02-08 CNCF-NATS

作为CNCF中消息系统的核心项目,NATS受到了各大公司的青睐,近年来使用量也在逐步提升。有不少同学对消息系统的认识还比较模糊,今天我们就借NATS的核心模型,对消息系统有进一步的认识。

官网 - https://nats.io/

Github - https://github.com/nats-io/nats-server

三种消息传递模型

发布-订阅模式:类似于广播模式

publish-subscribe

请求-响应模式:对应关系可自行调整,请求者必须等待到响应才认为是成功

request-reply

队列-订阅模式:分布式系统中非常重要的消息队列功能,实现消息分发

queue-groups

分布式系统中的消息系统

了解上面三种消息传递模型后,你可能仍不清楚它们的适用场景。我建议大家深入地了解这三种模型的本质,这样更方便记忆与理解。其实,在分布式系统中,最核心的是 队列-订阅 模式,其余两种模式意义并不大。

  1. 发布订阅 只是 队列订阅 的一种特殊的广播模式;
  2. 请求响应 更多地应结合服务发现能力,在RPC框架中进行实现;

第一点的使用场景不多见,举个例子:

服务2有多个实例,本地内存里保存了一些信息;现在服务1要更新所有服务2中内存的信息,就需要采用发布-订阅模式,否则会导致服务间数据不一致。

如果服务2引入了分布式缓存,那就是队列-订阅模式。

那么,队列-订阅模式 对分布式系统来说有什么意义呢?这其实就是消息队列的价值,我这里列举最关键的两点:

  1. 削峰填谷:针对分布式系统中的性能问题,通过队列的形式,将高峰期的msg积压到Queue中,在低峰期时交给消费者处理。
  2. 解耦强依赖:从调用关系可以看到,其实Publisher是要将信息传递给Subscriber;但增加了Queue后,Publisher只与Queue交互,Subscriber也只与Queue交互。可以想象,即便Subscriber短暂地挂了,重启后依旧可以正常使用。

分布式的消息队列还有很多注意点,这里我就不一一列举了,更多的资料大家可以自行搜索。

小结

虽然从生产环境的应用范围来看,NATS仍与老牌的重量级消息队列Kafka有相当大的差距,尤其是在大数据的系统中。但对比RocketMQ、RabbitMQ等轻量级产品,NATS的优势已经越来越明显,尤其是在性能与多语言的SDK上,建议有条件的朋友可以尝试使用。

2022-02-09 CNCF-Helm

Helm在整个云原生平台中扮演了重要角色。值得注意的是,Helm自身的复杂度并不高,它更多依赖的是优秀的设计理念与当前包含大量软件的生态。

官方的定义很简洁,即Kubernetes中的包管理者,即一个公共的软件仓。

The Kubernetes Package Manager

使用Helm

类似于Dockerhub,Helm的一大特色就是使用起来非常简单,可快速地在Kubernetes环境中安装软件。

以Kubernetes中的证书管理为例,我们可以参考链接 - https://artifacthub.io/packages/helm/cert-manager/cert-manager ,可以快速地通过几个命令就能完成下载与部署。

我希望大家能注意到:低门槛是吸引用户的重要因素,但真正决定软件长期走向的,是它自身的核心功能。所以,Helm中的软件有三点需要特别关注:

  1. 契合Kubernetes平台:许多软件原生并不支持Kubernetes,需要做一定的改造;
  2. 保证常规功能:如安装时要判断依赖项、卸载时清理哪些数据、升降版本兼容性等等,都是很琐碎、又是很重要的事情;
  3. 人工维护问题:软件是高频迭代的,尤其是在云原生环境下,核心项目往往要大量的人力投入到 FAQ、配置参数说明、兼容性问题的处理;

这三点给Helm带来的是一种滚雪球效应,即越滚越大、越难以被替代;而这种雪球最终能支撑多大的市场,非常依赖Helm内部的核心设计,尤其是扩展性部分。

Charts

Helm称自己是Kubernetes平台中的包管理器,而这个包的格式被称为Charts,我们一起来看看一个官方示例:

1
2
3
4
5
6
7
8
9
10
11
wordpress/
Chart.yaml # A YAML file containing information about the chart
LICENSE # OPTIONAL: A plain text file containing the license for the chart
README.md # OPTIONAL: A human-readable README file
values.yaml # The default configuration values for this chart
values.schema.json # OPTIONAL: A JSON Schema for imposing a structure on the values.yaml file
charts/ # A directory containing any charts upon which this chart depends.
crds/ # Custom Resource Definitions
templates/ # A directory of templates that, when combined with values,
# will generate valid Kubernetes manifest files.
templates/NOTES.txt # OPTIONAL: A plain text file containing short usage notes

关键在于三个目录:

  1. charts - 保存当前chart的依赖子chart
  2. crds - 这是chart依赖Kubernetes实现软件运行的关键(CRD是k8s可扩展性的一大特色)
  3. templates - 用来绑定chart自定义参数

换一个视角,这三个文件夹体现了三种能力:

  1. charts - package能力复用
  2. crds - 自定义对接Paas平台(k8s)
  3. templates - 定制化参数

小结

Helm是Kubernetes使用人员需要非常重视的一个产品,它能快速地帮助我们安装与部署软件。

不过,我不建议大家去阅读它的相关源码,它的代码并不优秀;相反地,我更建议大家可以去尝试自己做一个chart(最好能结合自己开发的程序+依赖的中间件,如go程序+redis),这样既能结合Helm实现应用程序的快速部署,又能去实践Kubernetes的CRD。

2022-02-10 CNCF-Buildpacks

Buildpacks是一款对标Docker的镜像打包工具,虽然在CNCF中作为核心项目,但在目前的主流开发场景中用到的并不多。

我们不妨来思考一下Buildpacks与竞品的核心优势:

Buildpacks官网介绍自身的核心特性为3个:ControlComplianceMaintainability。我们今天挑选两个关键性的特征来聊一聊。

Control - Balanced control between App Devs and Operators.

平衡开发者与运维人员。这个也是Buildpacks对标Docker的最大优势。

刚熟悉Dockerfile的同学,会觉得体验很棒,只需要少数几行就能快速制作出一个镜像;但是,如果你是重度使用的用户,就会有不一样的体验:

  1. 多应用的Dockerfile中有大量重复、但又有少量定制化的内容(如依赖的软件)
  2. 由于定制化的内容存在,往往需要开发工程师编写Dockerfile

所以,维护Dockerfile成为了开发工程师很琐碎的工作,而Buildpacks则是希望将部分工作转移给运维人员。但在我看来,这个收益并不明显:

  1. 现状:大部分的公司会封装一些基础镜像,在基础镜像上的Dockerfile所需要的命令已经很少了,整体的复杂度不会很高;
  2. 工作平衡:平衡的意义并没有减少整体的工作量,两种角色的人数总量仍不会有大的变化;
  3. 责任明确:目前大型公司的运维人员越来越少,更强调的是开发人员自己管理应用的整个生命周期;

但换一个角度,Buildpacks理念是可以降低开发人员对Dockerfile这块的门槛,更专注于业务代码的开发。但是,编写Dockerfile这项技能本身难度不高,而且有利于研发自行排查问题,我个人是非常建议开发人员去学习的。

Maintainability - Perform upgrades with minimal effort and intervention.

这一点是Buildpacks的一大特色。

如果你对Docker的镜像底层有一定的了解,会清楚一个镜像就是一层层layer的堆叠;从最上层来看,就是一个完整的操作系统。但如果只对某个layer进行更改,就得销毁老容器、再起一个新的。而Buildpacks则提供了rebase的能力,也就是在运行中的容器中做到快速替换某个layer,而不需要整个重建。

举一个例子,当前运行的容器有层layer是设置环境变量(参考Dockerfile中的ENV指令),我们要进行增加或者更改参数,就能快速实现rebase。当然,rebase肯定是有不少限制条件的,尤其是rebase中的内容不能影响到程序的运行。

我们不妨发散地思考这个特性的价值:由于它核心解决了无需重启整个容器的作用,所以对启动成本比较大的程序,它的意义是很大的,尤其是Java程序。

小结

Buildpacks在社区中活跃度并不高,这也间接证明了Docker的统治地位,而它则需要一个合适的契机才可能得到大幅度的应用。这也提醒了我们,不要一味地追求新的技术,更应该结合现状理性分析。

2022-02-11 CNCF-Operator Framework

Operator Framework是为了降低Kubernetes中Operator开发门槛,而由CNCF社区提供的一套框架。由于这一整套的解决方案门槛很高,需要使用者对Kubernetes的原理有相当的基础,所以今天我们不会深入其细节,而是通过借由这个项目更好地理解Kubernetes。

Controller的工作原理

Operator本质上,是一种定制化的Controller;而控制器的核心思想,是根据期望状态与当前状态,管理k8s中的资源。我们这边可以结合下面这张图,来了解Controller的工作原理。

k8s-controller

  1. client-go是k8s提供的代码生成工具,相关的代码会自动生成;而controller-specific是自行开发的内容;
  2. 期望状态与当前状态的对比逻辑,决策的结果是 新增、更新、删除对应的资源,触发对应的callbacks;
  3. 具体的执行工作,交给Worker执行,而结果如果未达到预期,依然会再次触发整个流程;

关于k8s中的controller,源码分析可以参考我之前的一篇博客 https://junedayday.github.io/2021/02/18/k8s/k8s-012/

三大组件

  1. Operator SDK - 快速生成Operator相关代码
  2. Operator Lifecycle Manager - k8s中的生命周期管理
  3. Operator Metering - 监控

其中监控部分很重要,能帮助使用人员在复杂的K8s系统中排查问题。

小结

目前Operator Framework虽然在社区比较受欢迎,但使用者往往仅限于k8s的深度用户;而许多大型公司又往往会自行封装k8s,不能完美兼容Operator Framework,导致它的推广很受限。

我个人有三点建议:

  1. 优先去Helm里搜索成熟应用,不要自行开发Operator;
  2. 如果有切实的使用需求,优先去公开库 - https://operatorhub.io/ 搜索;
  3. k8s深度玩家可忽略以上两点~

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码

go-tip

go-zero概况

go-zero是当前处于CNCF孵化中的一个Goz语言框架项目,在Github上的star数目前达到14.3K。

作为一款起源于国内的项目,go-zero的中文资料比较齐全,对国内开发者相对友好。但前景如何,还需要进一步的观察。今天我们一起来了解这个项目。

阅读全文 »

2022-01

2022-01-24 CNCF-Linkerd

今天我们来看 Orchestration & Management 编排和管理 层最后一个核心项目 - Linkerd。从严格意义上来说,我们应称它为Linkerd2,区别于原来的1.0版本。

Linkerd是Service Mesh的第一个产品,但在Google的Istio入场后在功能与性能上完全超越。这一段的历史很有意思,大家可以自行搜索了解。

关于Service Mesh,我们已经聊过两款CNCF中的软件了 - Envoy/Contour,这个Linkerd是两者的结合。我们来看一下它的架构示意图,整体来说分为三块:

Linkerd2
  1. CLI - 客户端,对Linkerd2进行管理
  2. Control Plane 控制平面
    1. destination 获取各类信息,如服务发现、网络策略、性能和监控指标
    2. indentity 主要是TLS安全相关
    3. proxy-injector 是一种Kubernetes的Admission Controller,用于对初始化pod注入linkerd相关的信息
  3. Data Plane 数据平面
    1. linkerd-proxy 核心的功能实现,包括代理、路由、TLS、限流等等
    2. linkerd-init 是一种Kubernetes的Init Containter,用iptables的特性将Pod的流量都导向linkerd-proxy

Linkerd的架构非常清晰明了,与Kubernetes的特性紧密结合。我们也不难看到,它的核心能力非常依赖linkerd-proxy这个组件。linkerd-proxy采用了Rust语言编写,而对应的Envoy使用的是C++,从性能来看两者相差无几,更多的是语言生态上的选择不同。

我们再一起读一段Linkerd官方对Service Mesh的定义

A service mesh like Linkerd is a tools for adding observability, security, and reliability features to “cloud native” applications by transparently inserting this functionality at the platform layer rather than the application layer.

  • observability - 可观察性:logging、metrics、tracing

  • security - 安全性:TLS等特性

  • reliability - 可靠性:体现在对网络层的统一管理

从目前来看,Linkerd仍处于一个比较早期的阶段,对标Istio还有大量的功能缺失,我在短期内不太看好。不过它引入了Rust语言有可能吸引一批优秀的人才,成为突破口。

2022-01-25 Go1.18的两个教程

在1月初,我们已经一起看了Go官方对1.18的新特性讲解,想回顾的朋友可以点击这个链接:Go1.18概览。前几天,官方又发布了对泛型和Fuzzing的两个教程,我们再一起浏览下,查漏补缺。

Generics

1
2
3
4
5
6
7
func SumIntsOrFloats[K comparable, V int64 | float64](m map[K]V) V {
var s V
for _, v := range m {
s += v
}
return s
}
  • comparable 是个关键词,指的是支持操作符==!=
  • int64 | float64 则用简洁的语法表示了两种支持的类型

但第二点,如果支持的类型太多,就需要做一次抽象,如

1
2
3
4
5
6
7
type Number interface {
int64 | float64
}

func SumNumbers[K comparable, V Number](m map[K]V) V {
//
}

Go语言的泛型表示方法非常简单,其支持的能力也很有限。相对于C++与JAVA中的泛型,无疑逊色了很多。我们可以简单地归纳Go泛型的使用场景:用于 基础类型 的通用操作,如int/int32/int64/float64等这种重复性很高的基本运算。

作为一种标准,Go的泛型落地非常坎坷,短期内官方也不太可能在这块扩增新的特性,所以Go的泛型适用性会比较窄。

随着1.18的完全落地,我们可以在很多基础库中看到泛型的实践,到时候我们再可以根据具体case进行了解。

Fuzzing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}

相对于传统的单元测试,Fuzzing Test更强调一种 不确定性的输入 理念 - 由于输入的数据是随机的,输出往往是不确定的,那我们最好可以通过一定的操作,减少甚至消除输出的不确定性,才能保证测试的完备性:

比如说,示例中对字符串的反转,转变成了两个测试点:

  1. Reversing a string twice preserves the original value 即两次反转后成为原字符串,
  2. The reversed string preserves its state as valid UTF-8 字符串依然为UTF-8编码格式

从输入和输出来看,如果每个输入都对应枚举出一个输出,那就是单元测试;而Fuzzing Test的理念是尽可能地将输出做到可控,更方便地写各种测试。

在实际工程中,能用到Fuzzing特性的地方很少,更多的还是依赖简单地单元测试保障我们的代码质量。

2022-01-26 如何避免分布式事务

最近,有朋友和我交流分布式事务的实践心得,而我的建议是:尽量避免分布式事务

这里的避免并非完全的不要使用,毕竟像金融场景中,这还是一个必要的特性。但对于绝大多数系统,分布式事务带来的复杂度是非常高的,也需要很高的维护成本与理解成本,远超其收益,我不太建议大家刻意地使用这个技术。

举一个简单的case - 用户下了一个订单,经过如下步骤:

  1. 订单服务生成订单
  2. 库存服务扣去库存
  3. 付费服务完成扣款
  4. 用户积分服务增加积分

这时,最直观的解法是要有一套成熟的分布式事务的方案。但事实上,我更推荐在工程上采用下面两种解决方案,而其中的关键词就是 - 补偿

在MQ中重试

我们经常会利用MQ来解耦服务,那么自然会用它来驱动大量的消息。

例如,我们将扣款请求放到MQ里,扣款服务处理成功后通过另一个MQ通知成功。而当扣款服务出现问题时、也就是扣款失败,常见的有2种选择:

  1. 如果要求是必须成功的,消费时就不要返回成功,在服务中反复重试,即便MQ积压产生告警、再人工恢复;
  2. 如果允许失败,那就设置一个最大重试次数,超过最大重试次数则通知给对应的补偿服务;

利用trace-id+ELK

trace-id是分布式链路追踪的关键信息,用于串联信息;而ELK又通过日志收集系统,将这块收集到了一个系统。

我们可以在生成订单时,同时记录这个关键性的trace-id,然后调用各个服务。有任何一个服务失败,我们就将订单状态修改为失败或超时;而数据不一致的问题,就由对应的补偿服务,根据这些有问题的订单的trace-id去分析。

其实可以从这个方案延伸出类似的,比如直接将错误通过trace-id+信息发送给补偿服务,统一收集。

注意点

  1. 补偿不代表只能手动,我们可以在补偿服务内根据错误码,实现一定的自动化;
  2. 补偿更多体现的是一种最终一致性的思想,会有延时,我们要保证中间状态的数据不会污染系统;

在微服务+云原生时代,我们非常提倡 面向错误编程,正是为了能更好地面对各种不确定的异常case。分布式事务带来了大量的复杂度,目前也没有一套跨语言、跨组件的通用解决方案,目前主流几个方案对应用的侵入性很强,所以我不太建议大部分朋友在生产环境使用,而花更多时间学习相关理论、应付面试就行了。

2022-01-27 CNCF-TiKV

了解完核心的 调度与管理 相关的软件后,我们接下来开始接触 应用定义与开发 的相关软件,这部分与我们实际开发接触最为紧密,也更容易理解。

官方的定义为:

TiKV provides both raw and ACID-compliant transactional key-value API, which is widely used in online serving services, such as the metadata storage system for object storage service, the storage system for recommendation systems, the online feature store, etc.

也就是TiKV支持 简单的与满足ACID事务性的KV存储,被应用在各种存储系统上,如关系型数据库、非关系型数据库、分布式文件系统,最具有代表性的即同属一个公司的TiDB。按官方的定义,我们可以将它对标Redis。

我们结合TiKV的核心特性来看看。

Low and stable latency

RawKV’s average response time less than 1 ms (P99=10 ms).

延迟是IO相关的软件很重要的特性。但对于这个特性,我们要注意两点:

  1. 只针对简单KV,而不针对事务
  2. 真实延迟很依赖存储介质

从这点来看,在TiKV层面引入事务的特性前,需要我们要斟酌一下它对延迟的影响。

High scalabilit

With the Placement Driver and carefully designed Raft groups, TiKV excels in horizontal scalability and can easily scale to 100+ terabytes of data. Scale-out your TiKV cluster to fit the data size growth without any impact on the application.

强调了高扩展性,可支持100TB+的数据。

TiKV采用了Raft作为分布式一致性的协议,这一点与Etcd一致。关于Raft这块是目前工程化的主流,相对于Paxos更容易落地。不过,各家在实现Raft时都或多或少有一些变种,这块我们暂时不细聊。

Consistent distributed transactions

Similar to Google’s Spanner, TiKV (TxnKV mode) supports externally consistent distributed transactions.

支持一致性的分布式事务。

分布式事务对强一致性的业务非常有价值,但它的实现必然会带来一定的性能问题,尤其体现在延迟上。以金融服务为例,分布式事务能保证资金的一致性,不产生资损;但延迟问题又会带来一些异常case,所以需要做好权衡。

Adjustable consistency

In RawKV and TxnKV modes, you can customize the balance between consistency and performance.

对简单KV模式与事务性的KV模式,提供了可调节的一致性功能。

这就是一致性与性能上的权衡。关于这点,大家可以了解一下ACID与BASE对业务的价值。从我的观察来看,目前越来越多的服务倾向于最终一致性,主要有以下优点:

  1. 对外部服务来说视角清晰,更容易理解 - 从外部服务视角来看,本服务最终会趋于一致,而不需要关心各种异常的中间状态,这非常有助于微服务的边界划分;
  2. 服务更具健壮性 - 软件系统的不稳定因素很多,最终一致性可以更好地处理这些异常。

当然,对应的代价是该服务需要引入重试、幂等、异步校验、状态机、恢复日志等特性,自身的复杂度是比较高的。这些技术我也会在后面和大家分享。

2022-01-28 CNCF-Vitess

今天我们来聊聊一款和关系型数据库相关的产品 - VitessVitess的定位很简洁:

A database clustering system for horizontal scaling of MySQL

我们直接从架构图入手,来了解它是怎么实现 MySQL横向扩展 的。

我们关注最核心的两个模块:

VTTablet

A tablet is a combination of a mysqld process and a corresponding vttablet process, usually running on the same machine. Each tablet is assigned a tablet type, which specifies what role it currently performs.

一个Tablet对应到一个具体的MySQL实例,类似于sidecar模式。我之前基于VTTablet做过一定的二次开发,和大家分享一下我对这块的认识:

VTTablet最核心实现,是 模拟一个MySQL,与真正的MySQL进行连接。所以,可以体现如下的特点:

  1. 无侵入式 - 充分利用了MySQL集群间通信的协议,不会侵入原MySQL。这点能衍生出很多价值,例如兼容多版本的MySQL。
  2. 性能较优 - 通过MySQL内部通信的协议交互。
  3. 可扩展性强 - 从原先对大MySQL集群的维护,转变成了相对轻量级的VTTablet集群的维护

VTGate

VTGate is a lightweight proxy server that routes traffic to the correct VTTablet servers and returns consolidated results back to the client. It speaks both the MySQL Protocol and the Vitess gRPC protocol. Thus, your applications can connect to VTGate as if it is a MySQL Server.

VTGate是网关层的角色,主要分三块功能:

  1. 对外暴露出原生的MySQL协议与gRPC协议;
  2. 对内维护与VTTablet集群的连接;
  3. 核心依赖Topology服务中的数据,主要是VTTablet的状态数据和Admin的配置数据

小结

Vitess是用Go语言编写的软件,我比较推荐对数据库原理感兴趣的朋友去阅读VTTablet相关的源码,从中你可以了解到很多MySQL的关键性功能,会比直接阅读MySQL的C++简单很多。比如我曾经做过的:

  1. SQL解析 - 用于自研的查询平台
  2. Binlog同步 - 用于MySQL到异构数据库的同步平台

Github: https://github.com/Junedayday/code_reading

Blog: http://junes.tech/

Bilibili: https://space.bilibili.com/293775192

公众号: golangcoding

二维码