分布式系统下的可用性与可伸缩性
分布式系统下的可用性与可伸缩性 背景 最近我们组开展了一个针对异地多活场景的通用解决方案——单元化,旨在解决分布式系统的灾备问题,在调查中了解到了很多业务有趣的解决方案,记录在本文中,方便回顾。
写下本文时,我正就职于字节跳动的中台架构组,负责为字节的各个业务中台的共性问题提供解决方案,比如 异地多活、多租户治理 等。
为什么要讨论这个话题 通过输出本文,让自己将杂乱的相关知识进行整理 随着互联网市场逐渐饱和,未来各个软件都将会更加注重自己的服务质量,比如可用性、性能、可拓展性等等,所以将会有越来越多的团队关注自己的服务质量,其中首当其冲的就是可用性,希望我的一些经验和见解能够帮助到后来的人 概念对齐 在开始正文前,我们有必要先对齐几个本文涉及到的概念:可用性与可伸缩性。
可用性 可用性是软件系统在一定时间内持续可用的时间,比如IT服务(像一些云平台or基础设施)会对他们的用户承诺 3个9, 4个9 之类,可用性相关的SLA,它们是什么含义? 这里的几个9指的是在一年时间内,可用时间所占比的百分率,比如 3个9 指, 在一年时间内,99.9% 时间都是可用的,那么对应的服务中断时间就是:
3个9:(1-99.9%)36524=8.76小时,表示该系统在连续运行1年时间里最多可能的业务中断时间是8.76小时。 4个9:(1-99.99%)36524=0.876小时=52.6分钟,表示该系统在连续运行1年时间里最多可能的业务中断时间是52.6分钟。 5个9:(1-99.999%)36524*60=5.26分钟,表示该系统在连续运行1年时间里最多可能的业务中断时间是5.26分钟。
有人可能会有疑问,软件跑的好好的为啥会中断?中断的原因可能会有很多种:
服务更新:如果版本升级时没有实现平滑升级,那么会造成短暂的中断 机器故障:比如机器太老,需要裁撤,突然网卡坏了,机房散热器故障等等,如果你的服务没有健全的健康检查以及切流机制,往往就会带来灾难性的影响 通常来说,软件设计层面上的缺陷都是可以避免的,比如服务更新造成的中断。所以一般评估可用性都是在物理层面的影响范围上来看待,因为物理层面上的故障原因是不可控的,比如机器老化、自然灾害等。 按照范围来说,我们可以简单对软件的可用性进行一个分级
单实例:顾名思义,单个实例运行,最低的可用性级别,没法防范任何范围的故障 机房级别容灾:可以抵御机房级别的故障,通常来说都是多个实例分散在不同机房来实现 数据中心级别容灾:一般一个数据中心由多个机房组成,这个级别的容灾可以容忍一个数据中心故障 城市级别容灾:跟名字一样,能够容忍一个城市的所有数据中心出现灾害 国家级别容灾:不解释了。 这几个级别容灾能力从上往下依次递增,所需要的解决的问题和付出的代价也有很大不同,这里多说几句:
机房级别容灾 一般很少见,因为多数基础设施的提供商都不会暴露机房信息给你,而且这个粒度也偏细了 数据中心级别容灾 在各个云上可以类比成可用区(AZ:AvailiableZone),每个可用区都是独立的物理资源,以尽量做到故障隔离,但实际以我在腾讯游戏维护容器平台时的经验来看,其实很难做到 可伸缩性 可伸缩性其实比较容易理解,就是指一个服务能否扩缩容,其中又包含了两个方向:
垂直伸缩: 简单来说就是加资源,4CPU换8CPU,8G换16G,这在虚拟机时代是个比较重的操作,但是在容器环境下已经非常轻量了,但是受限于单台服务器的配置,你不可能无限大地垂直扩容 水平伸缩:需要架构支持,能够通过添加实例来分担负载,这里架构上要克服的问题就是当多个实例同时运行时,并发所带来的各种bad case。 这两种伸缩方式,前者是不需要软件进行任何修改的,天然支持;而后者是处理海量数据、请求时所必须实现的。 可伸缩性的评估可以按耗时:
不可伸缩:顾名思义,只能单实例运行,不具备可伸缩性 秒级:扩缩容能够在秒级别完成 分钟级:扩缩容能够在分钟级别完成 小时级:扩缩容能够在小时级别完成 天级:扩缩容能够在天级别完成 可能有同学疑问,为啥伸缩性只看时间,不看成本,比如是全自动,还是半自动或者纯人肉扩容,确实对于可伸缩性来说,自动化也很重要,但自动化是比较难量化的工作,时间是可以量化的。 另外换个角度看待这个问题,能在分钟级别甚至秒级别完成的扩缩容机制,它能靠人肉实现吗?
golang如何更优雅地处理Error
golang如何更优雅地处理Error 今天想要分享的是,golang中如何更优雅地处理错误。
怎么处理Error是golang中一个非常关键的事情,因为golang的设计导致代码中到处都是类似以下的代码
if err != nil { ... } 如果处理不得当,会导致代码膨胀得非常快且难以维护,比如:
if err != nil { metrics.Emit(...) log.Print(...) event.Emit(...) ... ... return err } 面对这样的代码,可能错误处理所占的代码行数都会多于逻辑代码,显然不是我们愿意看到的。 上面描述的代码膨胀现象只是错误处理中常出现的问题之一,接下来我们聊聊日常开发中该如何优雅地处理错误。
错误的处理方式 错误的传递方式无非两种:
返回 不返回 选择返回or不返回的场景无非几种:
当函数位于顶层,比如 API的接入层、conumer的handle 等,此时无法返回 发生的错误是 致命的,会影响到整个程序的运行,此时应该抛出panic,阻止程序发生更加不可控的事情 发生的错误是 预期的,比如查重动作中查询数据库的数据不存在时,得到了一个 NotFound 的错误 其他情况均应该返回错误 而面对错误发生时,常见有以下的处理方式:
记录(日志、metrics、事件) 做一些业务逻辑 直接返回 这里只对 记录 单独展开说下,其他两项暂时没什么需要注意的。
记录 记录的常见手段有三种:log, metrics, event。 通常来说我们只需要记录其中一种即可,有的同学可能会有疑问,明明这三种不同的技术都有不同的适用场景,为什么说通常了一种就够了。 首先我们要聊聊这三个记录的核心目标是什么:
log: 最传统的形式,是为了在程序运行时留下可追溯的信息,来辅助人类了解程序发生了什么,一般都会进行分级处理。 metrics: 由于不同的 metrics 体系,实现不同,这里只概述下,metrics是用于观测某个属性的趋势 event: 用于敏感操作的审计 or 广播变更 我在过往的工作中,见过某些同学,在某个很关键的场景出错后采用了以下的做法:
领域驱动设计(DDD)入门实践
领域驱动设计(DDD)入门实践 背景 我从 2016 年接触到 DDD 开始,到目前( 2022年 )为止,在各种项目中实践过 DDD,包括 医疗设备系统、用户社区、容器云,在这些系统中或深或浅都是用了 DDD 的一些概念模型,因此对 DDD 虽然谈不上熟练,也算是积累了一些宝贵的经验。 本文的出发点:
这些年利用 DDD 在解决软件复杂性上所获得的经验,希望能够帮助到更多小伙伴 国内互联网近些年都热衷于聊架构、算法、性能,却鲜有在工作中关注代码设计的氛围,希望通过本文能够让读者意识到到代码设计的价值所在。 为什么是DDD 我们先来回答这个问题,为什么本文不讲设计模式,不讲重构,不讲架构风格,而偏偏是 DDD。 答案是:实用性,为什么说实用呢,我们先来回顾下设计上最广为人知的 设计模式。出身的同学想必早就在学校学习过各种设计模式,策略模式,工厂模式,责任链模式等等,但是回顾一下你们的工作生涯中,你们有多少场景用到了这些模式,在整个项目中又占到了多少比例呢,如果你细细回顾下,我相信你使用了设计模式的代码内容在你的项目比重中一定很低。 究其原因在于,设计模式是面向局部场景的经验集合,而非系统性的方法论,在我了解的范围内,DDD 是最全面的设计理论,它能够覆盖到软件开发过程的大多数问题,而其他的一些设计理论大多都是面向特定问题的。
DDD是什么 领域驱动设计(Domain Driven Design)是一种设计理论,可以先看看 Wiki 的定义:
Domain-driven design (DDD) is a software design approach focusing on modelling software to match a domain according to input from that domain’s experts.
One concept is that the structure and language of software code (class names, class methods, class variables) should match the business domain.
Git的分支管理模型
Git的分支管理模型 Git的分支管理模型大致有三种:
GitFlow GithubFlow GitlabFlow 这里大致介绍下
GitFlow 分支分为:
master develop 各类开发分支(feat, chore, fix)等都是合往 develop,稳定的版本发布定期从 develop 合往 master。 这个开发流程十分简单,但是问题也很明显,它是基于版本发布的,不适用当下公司内部快速迭代的节奏。
GithubFlow 和你在 Github 上面维护代码是相同的方式,各个开发分支都通过 pr 合入到 master,然后自动部署到目标环境,这个流程在很多公司都得到了实践。 缺点在于如果你有多个环境要持续部署,那么需要维护多个环境分支,如何在多个分支间保持一致性就成了一个问题。
GitlabFlow GitlabFlow 融合了 GitFlow 和 GithubFlow 两种模式,它首先给出了在 GithubFlow 下多环境分支的解法:规定上下游。你永远只能从上游同步到下游。 详细的模型可以参考上文的链接,我这里不展开了,有点多。
个人思考 通过上面的一些标准 Flow,其实我们能看出一些问题了,在这些各类的flow上,其实有几点本质上的不同:
版本发布:你交付到客户的产品都是版本化的产物,比如客户端产品。这种方式讲究质量控制和版本规划,但是对于公司内部不断迭代的产品来说,很有可能会失控。 持续发布:在公司内部不断迭代的产品会很热爱这个方式,比如内部系统(中台,CRM等) Gitflow 其实只满足了 版本发布 的需求,而 GithubFlow 只考虑了持续发布的需求,因此才出现了 GitlabFlow 来结合两者的优势。 但是 GitlabFlow 其实只是一个理论指导,什么意思?就是说缺乏具体的规范,比如从上游合入到下游,到底用 merge 还是 cherry-pick ?怎么确定一个良好的上下游?
个人思考过后,沉淀一些经验在这里。 首先考虑自己产品形态是怎样的?
需不需要持续发布:如果你是一个开源的产品,没有需要持续发布的环境,那么你肯定没有这样的需求。但是如果你是个公司项目,先不考虑生产环境,你的测试环境一定是持续发布会更好。 需不需要版本发布:通常来公司内部系统其实不太注重版本,但是如果你的系统举足轻重,那么将生产环境作为版本发布来看会是一个不错的选择。 如果你需要持续发布,那么你需要管理 环境分支(env branch),类似 prod(duction), pre(-production), test等,这些分支应该只能通过 mr/pr 合入,当合入后则自动发布到对应环境。 如果你需要版本发布,那么你可能需要管理 发布分支(release branch),这个可以参考 GitFlow 中的定义,便于以后进行 hotfix。
给你的站点加上统计分析
最近比较好奇自己的个人网站、博客啥的,是不是真的有人看过,平时写的笔记能被搜索到吗、如果搜索不到要考虑做下SEO了,发现光从VPS的服务商那里根本获取不到啥有帮助的信息,所以调查了下目前网站统计接入的方式,记录下如何快速为自己的网站接入统计功能
mysql invalid connection 错误
mysql invalid connection 错误 最近团队有同学遇到了 mysql 会偶发"invalid connection" 错误,在之前从没遇到过,因此稍微调查一下。
原因 查看 github.com/go-sql-driver/mysql@1.6.0 的源码可以看出来会返回这个错误的地方不是很多,大致可以归类以下几个case:
TCP连接已关闭:这里的连接关闭是指被动关闭,通常对数据库的连接池都由 go 的 sql 包来管理,无论是 MaxIdleTime, MaxLifetime 都是它来管理生命周期,如果取出了已过期的连接,它会重新再去取,然后关闭掉已过期的连接,这是主动关闭,源码参考 sql。被动的关闭,指从server端发起的,比如连接空闲时间 > wait_timeout,再比如 mysql or mysql proxy重启或者故障,这些都导致 invalid connection。从代码来看,导致这个错误的另一种情况是对一个已经关闭的mysqlConn 进行操作,这个case我想不到什么情况下会出现,因为逻辑上来说当这个对象被调用 Close 后,将没有机会被调用,不知道要什么情况会被复用到。 读写异常: 查看 readPacket 和 writePacket 代码可以发现,如果在读写过程发生错误,且 client 端没有 cancel context,那么会返回 invalid connection,具体的原因可以在标准输出里查看,mysql会输出 [mysql] 前缀的日志,最常见的是读写超时。 当开启插值参数后,并发使用连接:正常 mysql driver走的 prepare + exec 的过程,会产生多一次的 rtt,有些情况我们可以通过dsn参数 interpolateParams=true 来开启插值(占位符替换),而减少一次rtt。但是插值的过程会使用 conn 上的buffer,如果并发访问导致 buffer 繁忙则会返回该错误。那么什么时候会在并发下使用同一条连接呢?答案是你当你使用了一些 session性质的功能,比如 transaction or user-level lock。 插值相关的参考资料:database/sql 一点深入理解
数据库知识小记
数据库知识小记 最近任务涉及到要在redis里面存储结构化数据,并且还要支持范围查询,同时方案设计时还需要考察各类数据库,因此调查了一下目前业界的数据库。 目前业界数据库种类繁多,根据使用方法不同可以分为:
SQL: Mysql, PostgresQL, SQLServer, Oracle NoSQL: 不同传统的SQL数据库,又分成了: 文档型数据库:CouchDB, MongoDB, ElasticSearch等 图数据库:JanusGraph, HugeGraph等 键值数据库: ETCD, Redis, memcached, Zookeeper, Consul 列族数据库:Cassandra, BigTable, HBase 时序型数据库:InfluxDB, OpenTSDB, Prometheus, m3db等等 NewSQL: Tidb 数据库太多,这里不一一枚举了,有比如kv下面有非常多的数据库,它们背后落盘的方式都不太相同,但是大致可以分为几种:
HASH:键值类数据常用,优点是易维护,查询快,缺点是范围查询太慢,节点的增删成本高,涉及到 Reshard 。 B树:很多存储都用了B树的方式去构建数据,典型的比如Mysql(B+),Mongo(B-),优点是增删成本低,缺点是查询速度不如 HASH, O1 vs Ologn LSM树:在机器磁盘上表现非常出色的写性能,用于降低存储成本,但是读上面牺牲比较大,和B树类似 同时在索引上也分为两类:
聚集索引(主索引):索引上直接存储了数据,查到后直接返回数据 非聚集索引(辅助索引):索引上仅保存了主键索引号,查到后仍然需要到主键索引上获取数据,这个过程被称为 回表,如果 查询的所有字段 都属于辅助索引则不再需要回表,这个现象被称为 覆盖索引(cover index),其原因是你用到的字段值都用于构建索引了,所以当然不需要回表查询其他数据 深度分页 所有存储都面临这个问题:如果一大批数据进行非常靠后的查询,比如千万级别的结果集,跳到最后几条,基本上现有存储的 skip 都是基于范围扫描实现的,比如 skip(100w).limit(1),扫描 100w01条数据后抛弃前 100w 行记录来实现,因此 skip 太大后会导致大量的无效扫描。
这个问题有几个解法
调整业务:绝大多数这种场景都是不合理的,当你的默认条件能够查询出上百万条数据时,更合理的操作是缩小数据范围,而不是真的让用户翻到最后一页,这里从交互上有几个处理方法 当超过预定的数据时(比如 1w),只返回 1w,并提示用户缩小范围 默认的搜索条件添加一些区分粒度大的条件,比如日期,可以有效控制默认页面的数据窗口 游标:游标就是在上次翻译的结果上做好标记,比如选取一个递增的字段,记录返回时的极值(通常选择主键ID),下次查询时携带上这个字段,然后用其作为查询条件来进行索引,而不是直接 skip。方案优点是易于实现,缺点是不支持随机翻页,只能查看下一页 or 上一页。 更换存储方案:通常这么大的统计需求一般都是建立 数仓 来实现了,不要直接基于 OLTP 的存储去实现。 隔离级别 先说说可能出现的几个问题:
M3db
背景 我们的云平台需要采集所有集群正在运行的容器的性能数据与业务指标,最开始采用的每个集群一个独立Promethues的配置,如下图:
结构比较简单,这里就不累述了。需要解释下的是 prom-nanny 这个组件是个啥,其实就是对社区版本 prometheus 的封装,它主要实现以下功能:
watch 用户配置的自定义告警规则,然后更新到配置文件中 更新配置文件后,调用 prometheus 接口热更新配置 以上则是以前老的监控体系下的拓扑图。很明显这里很多问题:
存储、采集、查询单点:这里 Prometheus 承担了三个责任,但是它仅有一个实例,一旦故障,我们将失去所有功能 无法水平扩容:由于只有一个实例,所以我们只能垂直扩容,而母机的配置是有上限的,你不可能无线扩下去,对于有的集群动则几千个节点,单一实例是不可能满足的。有的同学可能会提到 hashmod 、federation 和 remote_read,其实这三个功能可以解决采集和查询的水平扩容,但是解决不了存储的水平扩容,Prometheus 提出了 remote_write 来解决存储单点的问题 权限未分开:由于我们不但采集性能数据,还采集业务系统自行暴露的业务指标,因此存在一些敏感数据,但是 grafana 和 prometheus 都共用一个的情况下,权限都是共用的,这带来的一定的安全风险 方案对比 由于现在集群的指标都是通过 Prometheus 的 exporter 来暴露,所以方案已经限定以 Prometheus 为基础了。 基于这个前提,在作者进行方案选型时(2019年中),了解到的有以下几种解决方案:
Prometheus + influxdb Thanos cortex M3 这里其中第一个方案由于此前隔壁组的同事以前做过预研,发现性能存在较大缺陷,所以不在考虑范围内,最终他们选择了 Thanos。
这里列个对比图:
简述 优点 缺点 Thanos 开源社区很有名的集群方案,它的核心点在于将查询最近时间的查询请求路由到各个 Prom 分片进行查询,同时通过 SideCar 将数据同步到对象存储中,作为历史存储。 已经有了很多案例资料可参考,查询快,功能成熟 历史查询慢,由于是是从对象存储转换过来,很显然这里存在复杂的转换过程,同时对象存储的数据库也不会没有针对时序型数据的优化;同一 Prom 分片承担采集与查询的责任,会互相影响 cortex WeaveCloud 公司搞的开源项目,通过 Prometheus 的 remote_write 写入自研的组件后将其转换为块存储,然后提供兼容 PromQL 的查询组件来提供外部使用 虽然使用的块存储,但是它还利用了额外的存储来简历索引,同时提供了优化查询的缓存组件,可以是说是煞费苦心了;功能应该是我了解的方案中最为丰富的一个 太复杂,它的优点其实也是它的缺点,为了能优化查询的速度,让整个拓扑图的复杂度增高了,每多一个组件在分布式系统里面其实都是一种负担。 m3 这是 Uber 在2014 年开始自研的一个方案,他们在开始之前,也尝试了很多开源组件作为底层存储,发现都无法满足他们的要求,于是他们从底层存储开始自研了这套方案,有兴趣的同学可以看下 这篇文章,m3 的理念非常简单,直接通过 remote_write 写到转换组件,然后存入自研的时序数据库,并且提供兼容 PromQL 和 Graphite 语法的查询组件 拓扑简单,落地方便;由于自研的时序数据专门针对时序型数据进行了优化,所以无论短期还是历史数据查询效率和压缩率都非常高;已经在 Uber 内部经过大量数据的验证 案例太少,由于 2018 年才刚刚对外开源,网上关于 m3 实践的资料非常少,偶尔有几篇文章也只是在浅显地跑了起来而已,规模不可考;还没有稳定,版本还是0.