From 7a02e928c2eff1a9ecdb47031a404da7466de044 Mon Sep 17 00:00:00 2001 From: Xargin Date: Tue, 21 Aug 2018 17:27:04 +0800 Subject: [PATCH] update mark --- ch5-web/ch5-02-router.md | 14 +++++------ ch5-web/ch5-03-middleware.md | 8 +++---- ch5-web/ch5-04-validator.md | 6 ++--- ch5-web/ch5-05-database.md | 6 ++--- ch5-web/ch5-06-ratelimit.md | 6 ++--- ch5-web/ch5-08-interface-and-web.md | 10 ++++---- ch5-web/ch5-09-gated-launch.md | 14 +++++------ .../{ch6-02-dist-id.md => ch6-01-dist-id.md} | 10 ++++---- ch6-cloud/{ch6-01-lock.md => ch6-02-lock.md} | 16 ++++++------- ch6-cloud/ch6-03-delay-job.md | 10 ++++---- ch6-cloud/ch6-05-load-balance.md | 12 +++++----- ch6-cloud/ch6-06-config.md | 24 +++++++++---------- ch6-cloud/ch6-09-scrawler.md | 9 +++++++ 13 files changed, 77 insertions(+), 68 deletions(-) rename ch6-cloud/{ch6-02-dist-id.md => ch6-01-dist-id.md} (98%) rename ch6-cloud/{ch6-01-lock.md => ch6-02-lock.md} (98%) diff --git a/ch5-web/ch5-02-router.md b/ch5-web/ch5-02-router.md index 74d8baa..baa9292 100644 --- a/ch5-web/ch5-02-router.md +++ b/ch5-web/ch5-02-router.md @@ -34,7 +34,7 @@ DELETE /user/starred/:owner/:repo 如果我们的系统也想要这样的 URI 设计,使用标准库的 mux 显然就力不从心了。 -## httprouter +## 5.2.1 httprouter 较流行的开源 golang web 框架大多使用 httprouter,或是基于 httprouter 的变种对路由进行支持。前面提到的 github 的参数式路由在 httprouter 中都是可以支持的。 @@ -103,7 +103,7 @@ r.PanicHandler = func(w http.ResponseWriter, r *http.Request, c interface{}) { 目前开源界最为流行(star 数最多)的 web 框架 [gin](https://github.com/gin-gonic/gin) 使用的就是 httprouter 的变种。 -## 原理 +## 5.2.2 原理 httprouter 和众多衍生 router 使用的数据结构被称为 radix tree,压缩字典树。读者可能没有接触过压缩字典树,但对字典树 trie tree 应该有所耳闻。下图是一个典型的字典树结构: @@ -117,7 +117,7 @@ httprouter 和众多衍生 router 使用的数据结构被称为 radix tree, 每个节点上不只存储一个字母了,这也是压缩字典树中“压缩”的主要含义。使用压缩字典树可以减少树的层数,同时因为每个节点上数据存储也比通常的字典树要多,所以程序的局部性较好(一个节点的 path 加载到 cache 即可进行多个字符的对比),从而对 CPU 缓存友好。 -## 压缩字典树创建过程 +## 5.2.3 压缩字典树创建过程 我们来跟踪一下 httprouter 中,一个典型的压缩字典树的创建过程,路由设定如下: @@ -136,7 +136,7 @@ GET /marketplace_listing/plans/ohyes 最后一条补充路由是我们臆想的,除此之外所有 API 路由均来自于 api.github.com。 -### root 节点创建 +### 5.2.3.1 root 节点创建 httprouter 的 Router struct 中存储压缩字典树使用的是下述数据结构: @@ -192,7 +192,7 @@ indices: 子节点索引,当子节点为非参数类型,即本节点的 wild 当然,PUT 路由只有唯一的一条路径。接下来,我们以后续的多条 GET 路径为例,讲解子节点的插入过程。 -### 子节点插入 +### 5.2.3.2 子节点插入 当插入 `GET /marketplace_listing/plans` 时,类似前面 PUT 的过程,GET 树的结构如图所示: ![get radix step 1](../images/ch6-02-radix-get-1.png) @@ -207,7 +207,7 @@ indices: 子节点索引,当子节点为非参数类型,即本节点的 wild 上面这种情况比较简单,新的路由可以直接作为原路由的子节点进行插入。实际情况不会这么美好。 -### 边分裂 +### 5.2.3.3 边分裂 接下来我们插入 `GET /search`,这时会导致树的边分裂。 @@ -219,7 +219,7 @@ indices: 子节点索引,当子节点为非参数类型,即本节点的 wild ![get radix step 4](../images/ch6-02-radix-get-4.png) -### 子节点冲突处理 +### 5.2.3.4 子节点冲突处理 在路由本身只有字符串的情况下,不会发生任何冲突。只有当路由中含有 wildcard(类似 :id) 或者 catchAll 的情况下才可能冲突。这一点在前面已经提到了。 diff --git a/ch5-web/ch5-03-middleware.md b/ch5-web/ch5-03-middleware.md index 995e6fd..0889976 100644 --- a/ch5-web/ch5-03-middleware.md +++ b/ch5-web/ch5-03-middleware.md @@ -2,7 +2,7 @@ 本章将对现在流行的 web 框架中的中间件技术原理进行分析,并介绍如何使用中间件技术将业务和非业务代码功能进行解耦。 -## 代码泥潭 +## 5.3.1 代码泥潭 先来看一段代码: @@ -94,7 +94,7 @@ func helloHandler(wr http.ResponseWriter, r *http.Request) { 修改到这里,本能地发现我们的开发工作开始陷入了泥潭。无论未来对我们的这个 web 系统有任何其它的非功能或统计需求,我们的修改必然牵一发而动全身。只要增加一个非常简单的非业务统计,我们就需要去几十个 handler 里增加这些业务无关的代码。虽然一开始我们似乎并没有做错,但是显然随着业务的发展,我们的行事方式让我们陷入了代码的泥潭。 -## 使用 middleware 剥离非业务逻辑 +## 5.3.2 使用 middleware 剥离非业务逻辑 我们来分析一下,一开始在哪里做错了呢?我们只是一步一步地满足需求,把我们需要的逻辑按照流程写下去呀? @@ -205,7 +205,7 @@ customizedHandler = logger(timeout(ratelimit(helloHandler))) 功能实现了,但在上面的使用过程中我们也看到了,这种函数套函数的用法不是很美观,同时也不具备什么可读性。 -## 更优雅的 middleware 写法 +## 5.3.3 更优雅的 middleware 写法 上一节中解决了业务功能代码和非业务功能代码的解耦,但也提到了,看起来并不美观,如果需要修改这些函数的顺序,或者增删 middleware 还是有点费劲,本节我们来进行一些“写法”上的优化。 @@ -253,7 +253,7 @@ func (r *Router) Add(route string, h http.Handler) { 注意代码中的 middleware 数组遍历顺序,和用户希望的调用顺序应该是"相反"的。应该不难理解。 -## 哪些事情适合在 middleware 中做 +## 5.3.4 哪些事情适合在 middleware 中做 以较流行的开源 golang 框架 chi 为例: diff --git a/ch5-web/ch5-04-validator.md b/ch5-web/ch5-04-validator.md index 756fb3f..2a50e99 100644 --- a/ch5-web/ch5-04-validator.md +++ b/ch5-web/ch5-04-validator.md @@ -6,7 +6,7 @@ 实际上这是一个语言无关的场景,需要进行字段校验的情况有很多,web 系统的 Form/json 提交只是一个典型的例子。我们用 go 来写一个类似上图的校验 demo。然后研究怎么一步步对其进行改进。 -## 重构请求校验函数 +## 5.4.1 重构请求校验函数 假设我们的数据已经通过某个 binding 库绑定到了具体的 struct 上。 @@ -69,7 +69,7 @@ func register(req RegisterReq) error{ 代码更清爽,看起来也不那么别扭了。这是比较通用的重构理念。虽然使用了重构方法使我们的 validate 过程看起来优雅了,但我们还是得为每一个 http 请求都去写这么一套差不多的 validate 函数,有没有更好的办法来帮助我们解除这项体力劳动?答案就是 validator。 -## 用 validator 解放体力劳动 +## 5.4.2 用 validator 解放体力劳动 从设计的角度讲,我们一定会为每个请求都声明一个 struct。前文中提到的校验场景我们都可以通过 validator 完成工作。还以前文中的 struct 为例。为了美观起见,我们先把 json tag 省略掉。 @@ -121,7 +121,7 @@ fmt.Println(err) // Key: 'RegisterReq.PasswordRepeat' Error:Field validation for 如果觉得这个 validator 提供的错误信息不够人性化,例如要把错误信息返回给用户,那就不应该直接显示英文了。可以针对每种 tag 进行错误信息定制,读者可以自行探索。 -## 原理 +## 5.4.3 原理 从结构上来看,每一个 struct 都可以看成是一棵树。假如我们有如下定义的 struct: diff --git a/ch5-web/ch5-05-database.md b/ch5-web/ch5-05-database.md index 94be84d..3815a26 100644 --- a/ch5-web/ch5-05-database.md +++ b/ch5-web/ch5-05-database.md @@ -2,7 +2,7 @@ 本节将对 db/sql 官方标准库作一些简单分析,并介绍一些应用比较广泛的开源 ORM 和 sql builder。并从企业级应用开发和公司架构的角度来分析哪种技术栈对于现代的企业级应用更为合适。 -## 从 database/sql 讲起 +## 5.5.1 从 database/sql 讲起 Go官方提供了 `database/sql` 包来给用户进行和数据库打交道的工作,实际上 `database/sql` 库就只是提供了一套操作数据库的接口和规范,例如抽象好的 sql 预处理(prepare),连接池管理,数据绑定,事务,错误处理等等。官方并没有提供具体某种数据库实现的协议支持。 @@ -105,7 +105,7 @@ func main() { 是的,所以社区才会有各种各样的 sql builder 和 orm 百花齐放。 -## 提高生产效率的 ORM 和 SQL Builder +## 5.5.2 提高生产效率的 ORM 和 SQL Builder 在 web 开发领域常常提到的 ORM 是什么?我们先看看万能的维基百科: @@ -179,7 +179,7 @@ orders := orderModel.GetList(where, limit, orderBy) 一旦你做的是高并发的 OLTP 在线系统,且想在人员充足分工明确的前提下最大程度控制系统的风险,使用 sql builder 就不合适了。 -## 脆弱的 db +## 5.5.3 脆弱的 db 无论是 ORM 还是 sql builder 都有一个致命的缺点,就是没有办法进行系统上线的事前 sql 审核。虽然很多 orm 和 sql builder 也提供了运行期打印 sql 的功能,但只在查询的时候才能进行输出。而 sql builder 和 ORM本身提供的功能太过灵活。使得你不可能通过测试枚举出所有可能在线上执行的 sql。例如你可能用 sql builder 写出下面这样的代码: diff --git a/ch5-web/ch5-06-ratelimit.md b/ch5-web/ch5-06-ratelimit.md index 5d665b7..94118a8 100644 --- a/ch5-web/ch5-06-ratelimit.md +++ b/ch5-web/ch5-06-ratelimit.md @@ -99,7 +99,7 @@ Transfer/sec: 5.51MB 不管我们的服务瓶颈在哪里,最终要做的事情都是一样的,那就是流量限制。 -## 常见的流量限制手段 +## 5.6.1 常见的流量限制手段 流量限制的手段有很多,最常见的:漏桶、令牌桶两种: @@ -142,7 +142,7 @@ func (tb *Bucket) WaitMaxDuration(count int64, maxWait time.Duration) bool {} 名称和功能都比较直观,这里就不再赘述了。相比于开源界更为有名的 google 的 Java 工具库 Guava 中提供的 ratelimiter,这个库不支持令牌桶预热,且无法修改初始的令牌容量,所以可能个别极端情况下的需求无法满足。但在明白令牌桶的基本原理之后,如果没办法满足需求,相信你也可以很快对其进行修改并支持自己的业务场景。 -## 原理 +## 5.6.2 原理 从功能上来看,令牌桶模型实际上就是对全局计数的加减法操作过程,但使用计数需要我们自己加读写锁,有小小的思想负担。如果我们对 Go 语言已经比较熟悉的话,很容易想到可以用 buffered channel 来完成简单的加令牌取令牌操作: @@ -255,7 +255,7 @@ cur = cur > cap ? cap : cur 在得到正确的令牌数之后,再进行实际的 Take 操作就好,这个 Take 操作只需要对令牌数进行简单的减法即可,记得加锁以保证并发安全。`github.com/juju/ratelimit` 这个库就是这样做的。 -## 服务瓶颈和 QoS +## 5.6.3 服务瓶颈和 QoS 前面我们说了很多 CPU-bound、IO-bound 之类的概念,这种性能瓶颈从大多数公司都有的监控系统中可以比较快速地定位出来,如果一个系统遇到了性能问题,那监控图的反应一般都是最快的。 diff --git a/ch5-web/ch5-08-interface-and-web.md b/ch5-web/ch5-08-interface-and-web.md index fc76121..338991e 100644 --- a/ch5-web/ch5-08-interface-and-web.md +++ b/ch5-web/ch5-08-interface-and-web.md @@ -10,7 +10,7 @@ 我们可以思考一下怎么缓解这个问题。 -## 业务系统的发展过程 +## 5.8.1 业务系统的发展过程 互联网公司只要可以活过三年,工程方面面临的首要问题就是代码膨胀。系统的代码膨胀之后,可以将系统中与业务本身流程无关的部分做拆解和异步化。什么算是业务无关呢,比如一些统计、反作弊、营销发券、价格计算、用户状态更新等等需求。这些需求往往依赖于主流程的数据,但又只是挂在主流程上的旁支,自成体系。 @@ -18,7 +18,7 @@ 通过拆解和异步化虽然解决了一部分问题,但并不能解决所有问题。随着业务发展,单一职责的模块也会变得越来越复杂,这是必然的趋势。一件事情本身变的复杂的话,这时候拆解和异步化就不灵了。我们还是要对事情本身进行一定程度的封装抽象。 -## 使用函数封装业务流程 +## 5.8.2 使用函数封装业务流程 最基本的封装过程,我们把相似的行为放在一起,然后打包成一个一个的函数,让自己杂乱无章的代码变成下面这个样子: @@ -52,7 +52,7 @@ func CreateOrder() { 在阅读业务流程代码时,我们只要阅读其函数名就能知晓在该流程中完成了哪些操作,如果需要修改细节,那么就继续深入到每一个业务步骤去看具体的流程。写得稀烂的业务流程代码则会将所有过程都堆积在少数的几个函数中,从而导致几百甚至上千行的函数。这种意大利面条式的代码阅读和维护都会非常痛苦。在开发的过程中,一旦有条件应该立即进行类似上面这种方式的简单封装。 -## 使用 interface 来做抽象 +## 5.8.3 使用 interface 来做抽象 业务发展的早期,是不适宜引入 interface 的,很多时候业务流程变化很大,过早引入 interface 会使业务系统本身增加很多不必要的分层,从而导致每次修改几乎都要全盘否定之前的工作。 @@ -156,7 +156,7 @@ func BusinessProcess(bi BusinessInstance) { 直接面向 interface 编程,而不用关心具体的实现了。如果对应的业务在迭代中发生了修改,所有的逻辑对平台方来说也是完全透明的。 -## interface 的优缺点 +## 5.8.4 interface 的优缺点 Go 被人称道的最多的地方是其 interface 设计的正交性,模块之间不需要知晓相互的存在,A 模块定义 interface,B 模块实现这个 interface 就可以。如果 interface 中没有 A 模块中定义的数据类型,那 B 模块中甚至都不用 import A。比如标准库中的 `io.Writer`: @@ -236,7 +236,7 @@ func main() { 所以 interface 也可以认为是一种编译期进行检查的保证类型安全的手段。 -## table-driven 开发 +## 5.8.5 table-driven 开发 熟悉开源 lint 工具的同学应该见到过圈复杂度的说法,在函数中如果有 if 和 switch 的话,会使函数的圈复杂度上升,所以有强迫症的同学即使在入口一个函数中有 switch,还是想要干掉这个 switch,有没有什么办法呢?当然有,用表驱动的方式来存储我们需要实例: diff --git a/ch5-web/ch5-09-gated-launch.md b/ch5-web/ch5-09-gated-launch.md index 7dd3ad3..afcad4e 100644 --- a/ch5-web/ch5-09-gated-launch.md +++ b/ch5-web/ch5-09-gated-launch.md @@ -11,7 +11,7 @@ 在对系统的旧功能进行升级迭代时,第一种方式用的比较多。新功能上线时,第二种方式用的比较多。当然,对比较重要的老功能进行较大幅度的修改时,一般也会选择按业务规则来进行发布,因为直接全量开放给所有用户风险实在太大。 -## 通过分批次部署实现灰度发布 +## 5.9.1 通过分批次部署实现灰度发布 假如服务部署在 15 个实例(可能是物理机,也可能是容器)上,我们把这 7 个实例分为三组,按照先后顺序,分别有 1-2-4-8 台机器,保证每次扩展时大概都是二倍的关系。 @@ -48,7 +48,7 @@ 如果有异常情况,首先要做的自然就是回滚了。 -## 通过业务规则进行灰度发布 +## 5.9.2 通过业务规则进行灰度发布 常见的灰度策略有多种,较为简单的需求,例如我们的策略是要按照千分比来发布,那么我们可以用用户 id、手机号、用户设备信息,等等,来生成一个简单的哈希值,然后再求模,用伪代码表示一下: @@ -64,7 +64,7 @@ func passed() bool { } ``` -### 可选规则 +### 5.9.2.1 可选规则 常见的灰度发布系统会有下列规则提供选择: @@ -134,11 +134,11 @@ func isTrue(phone string) bool { +--------+ ``` -## 如何实现一套灰度发布系统 +## 5.9.3 如何实现一套灰度发布系统 前面也提到了,提供给用户的接口大概可以分为和业务绑定的简单灰度判断逻辑。以及输入稍微复杂一些的哈希灰度。我们来分别看看怎么实现这样的灰度系统(函数)。 -### 业务相关的简单灰度 +### 5.9.3.1 业务相关的简单灰度 公司内一般都会有公共的城市名字和 id 的映射关系,如果业务只涉及中国国内,那么城市数量不会特别多,且 id 可能都在 10000 范围以内。那么我们只要开辟一个一万大小左右的 bool 数组,就可以满足需求了: @@ -207,7 +207,7 @@ func isPassed(rate int) bool { 注意初始化种子。 -### 哈希算法 +### 5.9.3.2 哈希算法 求哈希可用的算法非常多,比如 md5,crc32,sha1 等等,但我们这里的目的只是为了给这些数据做个映射,并不想要因为计算哈希消耗过多的 cpu,所以现在业界使用较多的算法是 murmurhash,下面是我们对这些常见的 hash 算法的简单 benchmark: @@ -287,7 +287,7 @@ ok _/Users/caochunhui/test/go/hash_bench 7.050s 可见 murmurhash 相比其它的算法有三倍以上的性能提升。 -#### 分布是否均匀 +### 5.9.3.3 分布是否均匀 对于哈希算法来说,性能是一方面的问题,另一方面还要考虑哈希后的值是否分布均匀。 diff --git a/ch6-cloud/ch6-02-dist-id.md b/ch6-cloud/ch6-01-dist-id.md similarity index 98% rename from ch6-cloud/ch6-02-dist-id.md rename to ch6-cloud/ch6-01-dist-id.md index 2f049d0..0a6620d 100644 --- a/ch6-cloud/ch6-02-dist-id.md +++ b/ch6-cloud/ch6-01-dist-id.md @@ -1,4 +1,4 @@ -# 6.2 分布式 id 生成器 +# 6.1 分布式 id 生成器 有时我们需要能够生成类似 MySQL 自增 ID 这样不断增大,同时又不会重复的 id。以支持业务中的高并发场景。比较典型的,电商促销时,短时间内会有大量的订单涌入到系统,比如每秒 10w+。明星出轨时,会有大量热情的粉丝发微博以表心意,同样会在短时间内产生大量的消息。 @@ -39,7 +39,7 @@ Twitter 的 snowflake 算法是这种场景下的一个典型解法。先来看 表示 timestamp 的 41 位,可以支持我们使用 69 年。当然,我们的时间毫秒计数不会真的从 1970 年开始记,那样我们的系统跑到 `2039/9/7 23:47:35` 就不能用了,所以这里的 timestamp 实际上只是相对于某个时间的增量,比如我们的系统上线是 2018-08-01,那么我们可以把这个 timestamp 当作是从 `2018-08-01 00:00:00.000` 的偏移量。 -## worker id 分配 +## 6.1.1 worker id 分配 timestamp,datacenter_id,worker_id 和 sequence_id 这四个字段中,timestamp 和 sequence_id 是由程序在运行期生成的。但 datacenter_id 和 worker_id 需要我们在部署阶段就能够获取得到,并且一旦程序启动之后,就是不可更改的了(想想,如果可以随意更改,可能被不慎修改,造成最终生成的 id 有冲突)。 @@ -64,9 +64,9 @@ mysql> select last_insert_id(); 考虑到集群中即使有单个 id 生成服务的实例挂了,也就是损失一段时间的一部分 id,所以我们也可以更简单暴力一些,把 worker_id 直接写在 worker 的配置中,上线时,由部署脚本完成 worker_id 字段替换。 -## 开源实例 +## 6.1.2 开源实例 -### 标准 snowflake 实现 +### 6.1.2.1 标准 snowflake 实现 `github.com/bwmarrin/snowflake` 是一个相当轻量化的 snowflake 的 Go 实现。其文档指出: @@ -122,7 +122,7 @@ func main() { Epoch 就是本节开头讲的起始时间,NodeBits 指的是机器编号的位长,StepBits 指的是自增序列的位长。 -### sonyflake +### 6.1.2.2 sonyflake sonyflake 是 Sony 公司的一个开源项目,基本思路和 snowflake 差不多,不过位分配上稍有不同: diff --git a/ch6-cloud/ch6-01-lock.md b/ch6-cloud/ch6-02-lock.md similarity index 98% rename from ch6-cloud/ch6-01-lock.md rename to ch6-cloud/ch6-02-lock.md index e511b45..7800298 100644 --- a/ch6-cloud/ch6-01-lock.md +++ b/ch6-cloud/ch6-02-lock.md @@ -1,4 +1,4 @@ -# 6.1 分布式锁 +# 6.2 分布式锁 在单机程序并发或并行修改全局变量时,需要对修改行为加锁以创造临界区。为什么需要加锁呢?可以看看这段代码: @@ -38,7 +38,7 @@ func main() { 959 ``` -## 进程内加锁 +## 6.2.1 进程内加锁 想要得到正确的结果的话,要把对 counter 的操作代码部分加上锁: @@ -68,7 +68,7 @@ println(counter) 1000 ``` -## trylock +## 6.2.2 trylock ```go package main @@ -135,7 +135,7 @@ func main() { 活锁指的是程序看起来在正常执行,但实际上 cpu 周期被浪费在抢锁,而非执行任务上,从而程序整体的执行效率低下。活锁的问题定位起来要麻烦很多。所以在单机场景下,不建议使用这种锁。 -## 基于 redis 的 setnx +## 6.2.3 基于 redis 的 setnx ```go package main @@ -227,7 +227,7 @@ setnx 很适合在高并发场景下,用来争抢一些“唯一”的资源 所以,我们需要依赖于这些请求到达 redis 节点的顺序来做正确的抢锁操作。如果用户的网络环境比较差,那也只能自求多福了。 -## 基于 zk +## 6.2.4 基于 zk ```go package main @@ -264,7 +264,7 @@ func main() { 这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。按照 Google 的 chubby 论文里的阐述,基于强一致协议的锁适用于 `粗粒度` 的加锁操作。这里的粗粒度指锁占用时间较长。我们在使用时也应思考在自己的业务场景中使用是否合适。 -## 基于 etcd +## 6.2.5 基于 etcd ```go package main @@ -307,7 +307,7 @@ etcd 中没有像 zookeeper 那样的 sequence 节点。所以其锁实现和基 3. watch `/lock` 下的事件,此时陷入阻塞 4. 当 `/lock` 路径下发生事件时,当前进程被唤醒。检查发生的事件是否是删除事件(说明锁被持有者主动 unlock),或者过期事件(说明锁过期失效)。如果是的话,那么回到 1,走抢锁流程。 -## redlock +## 6.2.6 redlock ```go package main @@ -370,7 +370,7 @@ redlock 也是一种阻塞锁,单个节点操作对应的是 `set nx px` 命 关于 redlock 的设计曾经在社区引起一场口水战,分布式专家各抒己见。不过这个不是我们要讨论的内容,相关链接在参考资料中给出。 -## 如何选择 +## 6.2.7 如何选择 业务还在单机就可以搞定的量级时,那么按照需求使用任意的单机锁方案就可以。 diff --git a/ch6-cloud/ch6-03-delay-job.md b/ch6-cloud/ch6-03-delay-job.md index aa1824e..63901b3 100644 --- a/ch6-cloud/ch6-03-delay-job.md +++ b/ch6-cloud/ch6-03-delay-job.md @@ -13,9 +13,9 @@ timer 的实现在工业界已经是有解的问题了。常见的就是时间堆和时间轮。 -## timer 实现 +## 6.3.1 timer 实现 -### 时间堆 +### 6.3.1.1 时间堆 最常见的时间堆一般用小顶堆实现,小顶堆其实就是一种特殊的二叉树: @@ -91,7 +91,7 @@ Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉 四叉堆中元素超时和堆调整与二叉堆没有什么本质区别。 -### 时间轮 +### 6.3.1.2 时间轮 ![timewheel](../images/ch6-timewheel.png) @@ -101,7 +101,7 @@ Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉 除了这种单层时间轮,业界也有一些时间轮采用多层实现,这里就不再赘述了。 -## 任务分发 +## 6.3.2 任务分发 有了基本的 timer 实现方案,如果我们开发的是单机系统,那么就可以撸起袖子开干了,不过本章我们讨论的是分布式,距离“分布式”还稍微有一些距离。 @@ -118,7 +118,7 @@ Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉 两种方案各有优缺点,如果采用 1,那么如果消息队列出故障会导致整个系统不可用,当然,现在的消息队列一般也会有自身的高可用方案,大多数时候我们不用担心这个问题。其次一般业务流程中间走消息队列的话会导致延时增加,定时任务若必须在触发后的几十毫秒到几百毫秒内完成,那么采用消息队列就会有一定的风险。如果采用 2,会加重定时任务系统的负担。我们知道,单机的 timer 执行时最害怕的就是回调函数执行时间过长,这样会阻塞后续的任务执行。在分布式场景下,这种忧虑依然是适用的。一个不负责任的业务回调可能就会直接拖垮整个定时任务系统。所以我们还要考虑在回调的基础上增加经过测试的超时时间设置,并且对由用户填入的超时时间做慎重的审核。 -## rebalance 和幂等考量 +## 6.3.3 rebalance 和幂等考量 当我们的任务执行集群有机器故障时,需要对任务进行重新分配。按照之前的求模策略,对这台机器还没有处理的任务进行重新分配就比较麻烦了。如果是实际运行的线上系统,还要在故障时的任务平衡方面花更多的心思。 diff --git a/ch6-cloud/ch6-05-load-balance.md b/ch6-cloud/ch6-05-load-balance.md index 7f86bf2..31e167d 100644 --- a/ch6-cloud/ch6-05-load-balance.md +++ b/ch6-cloud/ch6-05-load-balance.md @@ -2,7 +2,7 @@ 本节将会讨论常见的分布式系统负载均衡手段。 -## 常见的负载均衡思路 +## 6.5.1 常见的负载均衡思路 如果我们不考虑均衡的话,现在有 n 个 endpoint,我们完成业务流程实际上只需要从这 n 个中挑出其中的一个。有几种思路: @@ -16,7 +16,7 @@ 我们来看一个生产环境的负载均衡案例。 -## 基于洗牌算法的负载均衡 +## 6.5.2 基于洗牌算法的负载均衡 考虑到我们需要随机选取每次发送请求的 endpoint,同时在遇到下游返回错误时换其它节点重试。所以我们设计一个大小和 endpoints 数组大小一致的索引数组,每次来新的请求,我们对索引数组做洗牌,然后取第一个元素作为选中的服务节点,如果请求失败,那么选择下一个节点重试,以此类推: @@ -68,7 +68,7 @@ func request(params map[string]interface{}) error { 我们循环一遍 slice,两两交换,这个和我们平常打牌时常用的洗牌方法类似。看起来没有什么问题。 -### 错误的洗牌导致的负载不均衡 +### 6.5.2.1 错误的洗牌导致的负载不均衡 真的没有问题么?实际上还是有问题的。这段简短的程序里有两个隐藏的隐患: @@ -80,7 +80,7 @@ func request(params map[string]interface{}) error { 显然,这里给出的洗牌算法对于任意位置的元素来说,有 30% 的概率不对其进行交换操作。所以所有元素都倾向于留在原来的位置。因为我们每次对 shuffle 数组输入的都是同一个序列,所以第一个元素有更大的概率会被选中。在负载均衡的场景下,也就意味着 endpoints 数组中的第一台机器负载会比其它机器高不少(这里至少是 3 倍以上)。 -### 修正洗牌算法 +### 6.5.2.2 修正洗牌算法 从数学上得到过证明的还是经典的 fisher-yates 算法,主要思路为每次随机挑选一个值,放在数组末尾。然后在 n-1 个元素的数组中再随机挑选一个值,放在数组末尾,以此类推。 @@ -105,7 +105,7 @@ func shuffle(n int) []int { 在当前的场景下,我们只要用 rand.Perm 就可以得到我们想要的索引数组了。 -## zk 集群的随机节点挑选问题 +## 6.5.3 zk 集群的随机节点挑选问题 本节中的场景是从 N 个节点中选择一个节点发送请求,初始请求结束之后,后续的请求会重新对数组洗牌,所以每两个请求之间没有什么关联关系。因此我们上面的洗牌算法,理论上不初始化随机库的种子也是不会出什么问题的。 @@ -117,7 +117,7 @@ rand.Seed(time.Now().UnixNano()) 之所以会有上面这些结论,是因为某个使用较广泛的开源 zk 库的早期版本就犯了上述错误,直到 2016 年早些时候,这个问题才被修正。 -## 负载均衡算法效果验证 +## 6.5.4 负载均衡算法效果验证 我们这里不考虑加权负载均衡的情况,既然名字是负载“均衡”。那么最重要的就是均衡。我们把开篇中的 shuffle 算法,和之后的 fisher yates 算法的结果进行简单地对比: diff --git a/ch6-cloud/ch6-06-config.md b/ch6-cloud/ch6-06-config.md index 7e8affb..5bee294 100644 --- a/ch6-cloud/ch6-06-config.md +++ b/ch6-cloud/ch6-06-config.md @@ -4,15 +4,15 @@ 所以我们的目标还是尽量避免采用或者绕过上线的方式,对线上程序做一些修改。比较典型的修改内容就是程序的配置项。 -## 场景举例 +## 6.6.1 场景举例 -### 报表系统 +### 6.6.1.1 报表系统 在一些偏 OLAP 或者离线的数据平台中,经过长期的叠代开发,整个系统的功能模块已经渐渐稳定。可变动的项只出现在数据层,而数据层的变动大多可以认为是 SQL 的变动,架构师们自然而然地会想着把这些变动项抽离到系统外部。比如本节所述的配置管理系统。 当业务提出了新的需求时,我们的需求是将新的 SQL 录入到系统内部,或者简单修改一下老的 SQL。不对系统进行上线,就可以直接完成这些修改。 -### 业务配置 +### 6.6.1.2 业务配置 大公司的平台部门服务众多业务线,在平台内为各业务线分配唯一 id。平台本身也由多个模块构成,这些模块需要共享相同的业务线定义(要不然就乱套了)。当公司新开产品线时,需要能够在短时间内打通所有平台系统的流程。这时候每个系统都走上线流程肯定是来不及的。另外需要对这种公共配置进行统一管理,同时对其增减逻辑也做统一管理。这些信息变更时,需要自动通知到业务方的系统,而不需要人力介入(或者只需要很简单的介入,比如点击审核通过)。 @@ -20,11 +20,11 @@ 再举个例子,互联网公司的运营系统中会有各种类型的运营活动,有些运营活动推出后可能出现了超出预期的事件(比如公关危机),需要紧急将系统下线。这时候会用到一些开关来快速关闭相应的功能。或者快速将想要剔除的活动 id 从白名单中剔除。在 web 章节中的 ab test 一节中,我们也提到,有时需要有这样的系统来告诉我们当前需要放多少流量到相应的功能代码上。我们可以像那一节中,使用远程 rpc 来获知这些信息,但同时,也可以结合分布式配置系统,主动地拉取到这些信息。 -## 使用 etcd 实现配置更新 +## 6.6.2 使用 etcd 实现配置更新 我们使用 etcd 实现一个简单的配置读取和动态更新流程,以此来了解线上的配置更新流程。 -### 配置定义 +### 6.6.2.1 配置定义 简单的配置,可以将内容完全存储在 etcd 中。比如: @@ -40,7 +40,7 @@ etcdctl get /configs/remote_config.json } ``` -### 新建 etcd client +### 6.6.2.2 新建 etcd client ```go cfg := client.Config{ @@ -52,7 +52,7 @@ cfg := client.Config{ 直接用 etcd client 包中的结构体初始化,没什么可说的。 -### 配置获取 +### 6.6.2.3 配置获取 ```go resp, err = kapi.Get(context.Background(), "/path/to/your/config", nil) @@ -66,7 +66,7 @@ if err != nil { 获取配置使用 etcd KeysAPI 的 Get 方法,比较简单。 -### 配置更新订阅 +### 6.6.2.4 配置更新订阅 ```go kapi := client.NewKeysAPI(c) @@ -82,7 +82,7 @@ go func() { 通过订阅 config 路径的变动事件,在该路径下内容发生变化时,客户端侧可以收到变动通知,并收到变动后的字符串值。 -### 整合起来 +### 6.6.2.5 整合起来 ```go package main @@ -168,13 +168,13 @@ func main() { 这里只需要注意一点,我们在更新配置时,进行了一系列操作:watch 响应,json 解析,这些操作都不具备原子性。当单个业务请求流程中多次获取 config 时,有可能因为中途 config 发生变化而导致单个请求前后逻辑不一致。因此,在使用类似这样的方式来更新配置时,需要在单个请求的生命周期内使用同样的配置。具体实现方式可以是只在请求开始的时候获取一次配置,然后依次向下透传等等,具体情况具体分析。 -## 配置膨胀 +## 6.6.3 配置膨胀 随着业务的发展,配置系统本身所承载的压力可能也会越来越大,配置文件可能成千上万。客户端同样上万,将配置内容存储在 etcd 内部便不再合适了。随着配置文件数量的膨胀,除了存储系统本身的吞吐量问题,还有配置信息的管理问题。我们需要对相应的配置进行权限管理,需要根据业务量进行配置存储的集群划分。如果客户端太多,导致了配置存储系统无法承受瞬时大量的 QPS,那可能还需要在客户端侧进行缓存优化,等等。 这也就是为什么大公司都会针对自己的业务额外开发一套复杂配置系统的原因。 -## 配置版本管理 +## 6.6.4 配置版本管理 在配置管理过程中,难免出现用户误操作的情况,例如在更新配置时,输入了无法解析的配置。这种情况下我们可以通过配置校验来解决。 @@ -184,7 +184,7 @@ func main() { 常见的做法是,使用 MySQL 来存储配置文件/字符串的不同版本内容,在需要回滚时,只要进行简单的查询即可。 -## 客户端容错 +## 6.6.5 客户端容错 在业务系统的配置被剥离到配置中心之后,并不意味着我们的系统可以高枕无忧了。当配置中心本身宕机时,我们也需要一定的容错能力,至少保证在其宕机期间,业务依然可以运转。这要求我们的系统能够在配置中心宕机时,也能拿到需要的配置信息。哪怕这些信息不够新。 diff --git a/ch6-cloud/ch6-09-scrawler.md b/ch6-cloud/ch6-09-scrawler.md index 09537fe..05c9eb2 100644 --- a/ch6-cloud/ch6-09-scrawler.md +++ b/ch6-cloud/ch6-09-scrawler.md @@ -1,5 +1,14 @@ # 6.9 分布式爬虫 +互联网时代的信息爆炸是很多人倍感头痛的问题,应接不暇的新闻、信息、视频,无孔不入地侵占着我们的碎片时间。但另一方面,在我们真正需要数据的时候,却感觉数据并不是那么容易获取的。比如我们想要分析现在人在讨论些什么,关心些什么。甚至有时候,可能我们只是暂时没有时间去一一阅览心仪的小说,但又想能用技术手段把它们存在自己的资料库里。哪怕是几个月或一年后再来回顾。再或者我们想要把互联网上这些稍纵即逝的有用信息保存起来,例如某个非常小的论坛中聚集的同好们的高质量讨论,在未来某个时刻,即使这些小众的聚集区无以为继时,依然能让我们从硬盘中翻出当初珍贵的观点来。 + +除去情怀需求,互联网上有大量珍贵的开放资料,近年来深度学习如雨后春笋一般火热起来,但机器学习很多时候并不是苦于我的模型是否建立得合适,我的参数是否调整得正确,而是苦于最初的起步阶段:没有数据。 + +所以这些作为收集数据的前置工作,有能力去写一个简单的或者复杂的爬虫,对于我们来说依然非常重要。 + ## 基于 colly 的单机爬虫 ## 分布式爬虫 + +想像一下,你们的信息分析系统运行非常之快。获取信息的速度成为了瓶颈,虽然可以用上 Go 语言所有优秀的并发特性,将单机的 CPU 和网络带宽都用满,但对于价格战期间的电商们来说,还是希望能够在对手价格变动后第一时间获取到其最新价格,再靠机器自动调整本家的商品价格。 +