mirror of
https://github.com/chai2010/advanced-go-programming-book.git
synced 2025-05-24 04:22:22 +00:00
fix
This commit is contained in:
parent
42f4b525fe
commit
2bc48ddcfc
@ -52,7 +52,7 @@ func main() {
|
|||||||
|
|
||||||
如果你过了30s还没有完成这个程序,请检查一下你自己的打字速度是不是慢了(开个玩笑 :D)。这个例子是为了说明在Go中写一个HTTP协议的小程序有多么简单。如果你面临的情况比较复杂,例如几十个接口的企业级应用,直接用`net/http`库就显得不太合适了。
|
如果你过了30s还没有完成这个程序,请检查一下你自己的打字速度是不是慢了(开个玩笑 :D)。这个例子是为了说明在Go中写一个HTTP协议的小程序有多么简单。如果你面临的情况比较复杂,例如几十个接口的企业级应用,直接用`net/http`库就显得不太合适了。
|
||||||
|
|
||||||
我们来看看开源社区中一个 kafka 监控项目中的做法:
|
我们来看看开源社区中一个Kafka监控项目中的做法:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
//Burrow: http_server.go
|
//Burrow: http_server.go
|
||||||
@ -69,7 +69,7 @@ func NewHttpServer(app *ApplicationContext) (*HttpServer, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
上面这段代码来自大名鼎鼎的linkedin公司的kafka监控项目 Burrow,没有使用任何router框架,只使用了`net/http`。只看上面这段代码似乎非常优雅,我们的项目里大概只有这五个简单的 URI,所以我们提供的服务就是下面这个样子:
|
上面这段代码来自大名鼎鼎的linkedin公司的Kafka监控项目 Burrow,没有使用任何router框架,只使用了`net/http`。只看上面这段代码似乎非常优雅,我们的项目里大概只有这五个简单的 URI,所以我们提供的服务就是下面这个样子:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
/
|
/
|
||||||
@ -150,7 +150,7 @@ func handleKafka(app *ApplicationContext, w http.ResponseWriter, r *http.Request
|
|||||||
|
|
||||||
根据我们的经验,简单地来说,只要你的路由带有参数,并且这个项目的API数目超过了10,就尽量不要使用`net/http`中默认的路由。在Go开源界应用最广泛的router是httpRouter,很多开源的router框架都是基于httpRouter进行一定程度的改造的成果。关于httpRouter路由的原理,会在本章节的router一节中进行详细的阐释。
|
根据我们的经验,简单地来说,只要你的路由带有参数,并且这个项目的API数目超过了10,就尽量不要使用`net/http`中默认的路由。在Go开源界应用最广泛的router是httpRouter,很多开源的router框架都是基于httpRouter进行一定程度的改造的成果。关于httpRouter路由的原理,会在本章节的router一节中进行详细的阐释。
|
||||||
|
|
||||||
再来回顾一下文章开头说的,开源界有这么几种框架,第一种是对httpRouter进行简单的封装,然后提供定制的middleware和一些简单的小工具集成比如gin,主打轻量,易学,高性能。第二种是借鉴其它语言的编程风格的一些MVC类框架,例如beego,方便从其它语言迁移过来的程序员快速上手,快速开发。还有一些框架功能更为强大,除了数据库schema设计,大部分代码直接生成,例如goa。不管哪种框架,适合开发者背景的就是最好的。
|
再来回顾一下文章开头说的,开源界有这么几种框架,第一种是对httpRouter进行简单的封装,然后提供定制的中间件和一些简单的小工具集成比如gin,主打轻量,易学,高性能。第二种是借鉴其它语言的编程风格的一些MVC类框架,例如beego,方便从其它语言迁移过来的程序员快速上手,快速开发。还有一些框架功能更为强大,除了数据库schema设计,大部分代码直接生成,例如goa。不管哪种框架,适合开发者背景的就是最好的。
|
||||||
|
|
||||||
本章的内容除了会展开讲解router和middleware的原理外,还会以现在工程界面临的问题结合Go来进行一些实践性的说明。希望能够对没有接触过相关内容的读者有所帮助。
|
本章的内容除了会展开讲解router和中间件的原理外,还会以现在工程界面临的问题结合Go来进行一些实践性的说明。希望能够对没有接触过相关内容的读者有所帮助。
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# 5.3 middleware 中间件
|
# 5.3 中间件
|
||||||
|
|
||||||
本章将对现在流行的Web框架中的中间件技术原理进行分析,并介绍如何使用中间件技术将业务和非业务代码功能进行解耦。
|
本章将对现在流行的Web框架中的中间件(middleware)技术原理进行分析,并介绍如何使用中间件技术将业务和非业务代码功能进行解耦。
|
||||||
|
|
||||||
## 5.3.1 代码泥潭
|
## 5.3.1 代码泥潭
|
||||||
|
|
||||||
@ -94,7 +94,7 @@ func helloHandler(wr http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
修改到这里,本能地发现我们的开发工作开始陷入了泥潭。无论未来对我们的这个 Web 系统有任何其它的非功能或统计需求,我们的修改必然牵一发而动全身。只要增加一个非常简单的非业务统计,我们就需要去几十个handler里增加这些业务无关的代码。虽然一开始我们似乎并没有做错,但是显然随着业务的发展,我们的行事方式让我们陷入了代码的泥潭。
|
修改到这里,本能地发现我们的开发工作开始陷入了泥潭。无论未来对我们的这个 Web 系统有任何其它的非功能或统计需求,我们的修改必然牵一发而动全身。只要增加一个非常简单的非业务统计,我们就需要去几十个handler里增加这些业务无关的代码。虽然一开始我们似乎并没有做错,但是显然随着业务的发展,我们的行事方式让我们陷入了代码的泥潭。
|
||||||
|
|
||||||
## 5.3.2 使用 middleware 剥离非业务逻辑
|
## 5.3.2 使用中间件剥离非业务逻辑
|
||||||
|
|
||||||
我们来分析一下,一开始在哪里做错了呢?我们只是一步一步地满足需求,把我们需要的逻辑按照流程写下去呀?
|
我们来分析一下,一开始在哪里做错了呢?我们只是一步一步地满足需求,把我们需要的逻辑按照流程写下去呀?
|
||||||
|
|
||||||
@ -125,7 +125,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
这样就非常轻松地实现了业务与非业务之间的剥离,魔法就在于这个timeMiddleware。可以从代码中看到,我们的`timeMiddleware()`也是一个函数,其参数为`http.Handler`,`http.Handler`的定义在`net/http`包中:
|
这样就非常轻松地实现了业务与非业务之间的剥离,魔法就在于这个`timeMiddleware`。可以从代码中看到,我们的`timeMiddleware()`也是一个函数,其参数为`http.Handler`,`http.Handler`的定义在`net/http`包中:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Handler interface {
|
type Handler interface {
|
||||||
@ -207,7 +207,7 @@ customizedHandler = logger(timeout(ratelimit(helloHandler)))
|
|||||||
|
|
||||||
功能实现了,但在上面的使用过程中我们也看到了,这种函数套函数的用法不是很美观,同时也不具备什么可读性。
|
功能实现了,但在上面的使用过程中我们也看到了,这种函数套函数的用法不是很美观,同时也不具备什么可读性。
|
||||||
|
|
||||||
## 5.3.3 更优雅的 middleware 写法
|
## 5.3.3 更优雅的中间件写法
|
||||||
|
|
||||||
上一节中解决了业务功能代码和非业务功能代码的解耦,但也提到了,看起来并不美观,如果需要修改这些函数的顺序,或者增删中间件还是有点费劲,本节我们来进行一些“写法”上的优化。
|
上一节中解决了业务功能代码和非业务功能代码的解耦,但也提到了,看起来并不美观,如果需要修改这些函数的顺序,或者增删中间件还是有点费劲,本节我们来进行一些“写法”上的优化。
|
||||||
|
|
||||||
@ -221,7 +221,7 @@ r.Use(ratelimit)
|
|||||||
r.Add("/", helloHandler)
|
r.Add("/", helloHandler)
|
||||||
```
|
```
|
||||||
|
|
||||||
通过多步设置,我们拥有了和上一节差不多的执行函数链。胜在直观易懂,如果我们要增加或者删除middleware,只要简单地增加删除对应的Use调用就可以了。非常方便。
|
通过多步设置,我们拥有了和上一节差不多的执行函数链。胜在直观易懂,如果我们要增加或者删除中间件,只要简单地增加删除对应的`Use()`调用就可以了。非常方便。
|
||||||
|
|
||||||
从框架的角度来讲,怎么实现这样的功能呢?也不复杂:
|
从框架的角度来讲,怎么实现这样的功能呢?也不复杂:
|
||||||
|
|
||||||
@ -252,39 +252,39 @@ func (r *Router) Add(route string, h http.Handler) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
注意代码中的middleware数组遍历顺序,和用户希望的调用顺序应该是"相反"的。应该不难理解。
|
注意代码中的`middleware`数组遍历顺序,和用户希望的调用顺序应该是"相反"的。应该不难理解。
|
||||||
|
|
||||||
|
|
||||||
## 5.3.4 哪些事情适合在 middleware 中做
|
## 5.3.4 哪些事情适合在中间件中做
|
||||||
|
|
||||||
以较流行的开源Go语言框架chi为例:
|
以较流行的开源Go语言框架chi为例:
|
||||||
|
|
||||||
```
|
```
|
||||||
compress.go
|
compress.go
|
||||||
=> 对 http 的 response body 进行压缩处理
|
=> 对http的响应体进行压缩处理
|
||||||
heartbeat.go
|
heartbeat.go
|
||||||
=> 设置一个特殊的路由,例如 /ping,/healthcheck,用来给 load balancer 一类的前置服务进行探活
|
=> 设置一个特殊的路由,例如/ping,/healthcheck,用来给负载均衡一类的前置服务进行探活
|
||||||
logger.go
|
logger.go
|
||||||
=> 打印 request 处理日志,例如请求处理时间,请求路由
|
=> 打印请求处理处理日志,例如请求处理时间,请求路由
|
||||||
profiler.go
|
profiler.go
|
||||||
=> 挂载 pprof 需要的路由,如 /pprof、/pprof/trace 到系统中
|
=> 挂载pprof需要的路由,如`/pprof`、`/pprof/trace`到系统中
|
||||||
realip.go
|
realip.go
|
||||||
=> 从请求头中读取 X-Forwarded-For 和 X-Real-IP,将 http.Request 中的 RemoteAddr 修改为得到的 RealIP
|
=> 从请求头中读取X-Forwarded-For和X-Real-IP,将http.Request中的RemoteAddr修改为得到的RealIP
|
||||||
requestid.go
|
requestid.go
|
||||||
=> 为本次请求生成单独的 requestid,可一路透传,用来生成分布式调用链路,也可用于在日志中串连单次请求的所有逻辑
|
=> 为本次请求生成单独的requestid,可一路透传,用来生成分布式调用链路,也可用于在日志中串连单次请求的所有逻辑
|
||||||
timeout.go
|
timeout.go
|
||||||
=> 用 context.Timeout 设置超时时间,并将其通过 http.Request 一路透传下去
|
=> 用context.Timeout设置超时时间,并将其通过http.Request一路透传下去
|
||||||
throttler.go
|
throttler.go
|
||||||
=> 通过定长大小的 channel 存储 token,并通过这些 token 对接口进行限流
|
=> 通过定长大小的channel存储token,并通过这些token对接口进行限流
|
||||||
```
|
```
|
||||||
|
|
||||||
每一个Web框架都会有对应的middleware组件,如果你有兴趣,也可以向这些项目贡献有用的middleware,只要合理一般项目的维护人也愿意合并你的Pull Request。
|
每一个Web框架都会有对应的中间件组件,如果你有兴趣,也可以向这些项目贡献有用的中间件,只要合理一般项目的维护人也愿意合并你的Pull Request。
|
||||||
|
|
||||||
比如开源界很火的gin这个框架,就专门为用户贡献的middleware开了一个仓库,见*图 5-9*:
|
比如开源界很火的gin这个框架,就专门为用户贡献的中间件开了一个仓库,见*图 5-9*:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*图 5-9 *
|
*图 5-9 *
|
||||||
|
|
||||||
如果读者去阅读gin的源码的话,可能会发现gin的middleware中处理的并不是`http.Handler`,而是一个叫`gin.HandlerFunc`的函数类型,和本节中讲解的`http.Handler`签名并不一样。不过实际上gin的handler也只是针对其框架的一种封装,middleware的原理与本节中的说明是一致的。
|
如果读者去阅读gin的源码的话,可能会发现gin的中间件中处理的并不是`http.Handler`,而是一个叫`gin.HandlerFunc`的函数类型,和本节中讲解的`http.Handler`签名并不一样。不过实际上gin的`handler`也只是针对其框架的一种封装,中间件的原理与本节中的说明是一致的。
|
||||||
|
|
||||||
|
@ -138,12 +138,12 @@ type FeatureSetParams struct {
|
|||||||
|
|
||||||
比如在前面的生成环境引入Web页面,只要让用户点点鼠标就能生成SDK,这些就靠读者自己去探索了。
|
比如在前面的生成环境引入Web页面,只要让用户点点鼠标就能生成SDK,这些就靠读者自己去探索了。
|
||||||
|
|
||||||
虽然我们成功地使自己的项目在入口支持了多种交互协议,但是还有一些问题没有解决。本节中所叙述的分层没有将middleware作为项目的分层考虑进去。如果我们考虑中间件的话,请求的流程是什么样的?见*图 5-18*所示。
|
虽然我们成功地使自己的项目在入口支持了多种交互协议,但是还有一些问题没有解决。本节中所叙述的分层没有将中间件作为项目的分层考虑进去。如果我们考虑中间件的话,请求的流程是什么样的?见*图 5-18*所示。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
*图 5-18 加入中间件后的控制流*
|
*图 5-18 加入中间件后的控制流*
|
||||||
|
|
||||||
之前我们学习的中间件是和http协议强相关的,遗憾的是在thrift中看起来没有和http中对等的解决这些非功能性逻辑代码重复问题的middleware。所以我们在图上写`thrift stuff`。这些`stuff`可能需要你手写去实现,然后每次增加一个新的thrift接口,就需要去写一遍这些非功能性代码。
|
之前我们学习的中间件是和http协议强相关的,遗憾的是在thrift中看起来没有和http中对等的解决这些非功能性逻辑代码重复问题的中间件。所以我们在图上写`thrift stuff`。这些`stuff`可能需要你手写去实现,然后每次增加一个新的thrift接口,就需要去写一遍这些非功能性代码。
|
||||||
|
|
||||||
这也是很多企业项目所面临的真实问题,遗憾的是开源界并没有这样方便的多协议中间件解决方案。当然了,前面我们也说过,很多时候我们给自己保留的http接口只是用来做debug,并不会暴露给外人用。这种情况下,这些非功能性的代码只要在thrift的代码中完成即可。
|
这也是很多企业项目所面临的真实问题,遗憾的是开源界并没有这样方便的多协议中间件解决方案。当然了,前面我们也说过,很多时候我们给自己保留的http接口只是用来做debug,并不会暴露给外人用。这种情况下,这些非功能性的代码只要在thrift的代码中完成即可。
|
||||||
|
@ -40,7 +40,7 @@ func main() {
|
|||||||
|
|
||||||
## 6.2.1 进程内加锁
|
## 6.2.1 进程内加锁
|
||||||
|
|
||||||
想要得到正确的结果的话,要把对 counter 的操作代码部分加上锁:
|
想要得到正确的结果的话,要把对counter的操作代码部分加上锁:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// ... 省略之前部分
|
// ... 省略之前部分
|
||||||
@ -129,11 +129,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
因为我们的逻辑限定每个 goroutine 只有成功执行了 Lock 才会继续执行后续逻辑,因此在 Unlock 时可以保证 Lock struct 中的 channel 一定是空,从而不会阻塞,也不会失败。
|
因为我们的逻辑限定每个goroutine只有成功执行了`Lock`才会继续执行后续逻辑,因此在`Unlock`时可以保证Lock结构体中的channel一定是空,从而不会阻塞,也不会失败。
|
||||||
|
|
||||||
在单机系统中,trylock 并不是一个好选择。因为大量的 goroutine 抢锁可能会导致 cpu 无意义的资源浪费。有一个专有名词用来描述这种抢锁的场景:活锁。
|
在单机系统中,trylock并不是一个好选择。因为大量的goroutine抢锁可能会导致CPU无意义的资源浪费。有一个专有名词用来描述这种抢锁的场景:活锁。
|
||||||
|
|
||||||
活锁指的是程序看起来在正常执行,但实际上 cpu 周期被浪费在抢锁,而非执行任务上,从而程序整体的执行效率低下。活锁的问题定位起来要麻烦很多。所以在单机场景下,不建议使用这种锁。
|
活锁指的是程序看起来在正常执行,但实际上CPU周期被浪费在抢锁,而非执行任务上,从而程序整体的执行效率低下。活锁的问题定位起来要麻烦很多。所以在单机场景下,不建议使用这种锁。
|
||||||
|
|
||||||
## 6.2.3 基于 Redis 的 setnx
|
## 6.2.3 基于 Redis 的 setnx
|
||||||
|
|
||||||
@ -220,11 +220,11 @@ current counter is 2028
|
|||||||
unlock success!
|
unlock success!
|
||||||
```
|
```
|
||||||
|
|
||||||
通过代码和执行结果可以看到,我们远程调用 setnx 实际上和单机的 trylock 非常相似,如果获取锁失败,那么相关的任务逻辑就不应该继续向前执行。
|
通过代码和执行结果可以看到,我们远程调用setnx实际上和单机的trylock非常相似,如果获取锁失败,那么相关的任务逻辑就不应该继续向前执行。
|
||||||
|
|
||||||
setnx 很适合在高并发场景下,用来争抢一些“唯一”的资源。比如交易撮合系统中卖家发起订单,而多个买家会对其进行并发争抢。这种场景我们没有办法依赖具体的时间来判断先后,因为不管是用户设备的时间,还是分布式场景下的各台机器的时间,都是没有办法在合并后保证正确的时序的。哪怕是我们同一个机房的集群,不同的机器的系统时间可能也会有细微的差别。
|
setnx很适合在高并发场景下,用来争抢一些“唯一”的资源。比如交易撮合系统中卖家发起订单,而多个买家会对其进行并发争抢。这种场景我们没有办法依赖具体的时间来判断先后,因为不管是用户设备的时间,还是分布式场景下的各台机器的时间,都是没有办法在合并后保证正确的时序的。哪怕是我们同一个机房的集群,不同的机器的系统时间可能也会有细微的差别。
|
||||||
|
|
||||||
所以,我们需要依赖于这些请求到达 Redis 节点的顺序来做正确的抢锁操作。如果用户的网络环境比较差,那也只能自求多福了。
|
所以,我们需要依赖于这些请求到达Redis节点的顺序来做正确的抢锁操作。如果用户的网络环境比较差,那也只能自求多福了。
|
||||||
|
|
||||||
## 6.2.4 基于 ZooKeeper
|
## 6.2.4 基于 ZooKeeper
|
||||||
|
|
||||||
@ -257,11 +257,11 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
基于ZooKeeper的锁与基于 Redis 的锁的不同之处在于 Lock 成功之前会一直阻塞,这与我们单机场景中的 mutex.Lock 很相似。
|
基于ZooKeeper的锁与基于Redis的锁的不同之处在于Lock成功之前会一直阻塞,这与我们单机场景中的mutex.Lock很相似。
|
||||||
|
|
||||||
其原理也是基于临时 sequence 节点和 watch API,例如我们这里使用的是 `/lock` 节点。Lock 会在该节点下的节点列表中插入自己的值,只要节点下的子节点发生变化,就会通知所有 watch 该节点的程序。这时候程序会检查当前节点下最小的子节点的 id 是否与自己的一致。如果一致,说明加锁成功了。
|
其原理也是基于临时sequence节点和watch API,例如我们这里使用的是`/lock`节点。Lock会在该节点下的节点列表中插入自己的值,只要节点下的子节点发生变化,就会通知所有watch该节点的程序。这时候程序会检查当前节点下最小的子节点的id是否与自己的一致。如果一致,说明加锁成功了。
|
||||||
|
|
||||||
这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。按照 Google 的 chubby 论文里的阐述,基于强一致协议的锁适用于 `粗粒度` 的加锁操作。这里的粗粒度指锁占用时间较长。我们在使用时也应思考在自己的业务场景中使用是否合适。
|
这种分布式的阻塞锁比较适合分布式任务调度场景,但不适合高频次持锁时间短的抢锁场景。按照Google的chubby论文里的阐述,基于强一致协议的锁适用于`粗粒度`的加锁操作。这里的粗粒度指锁占用时间较长。我们在使用时也应思考在自己的业务场景中使用是否合适。
|
||||||
|
|
||||||
## 6.2.5 基于 etcd
|
## 6.2.5 基于 etcd
|
||||||
|
|
||||||
@ -298,12 +298,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
etcd 中没有像 ZooKeeper 那样的 sequence 节点。所以其锁实现和基于 ZooKeeper 实现的有所不同。在上述示例代码中使用的 etcdsync 的 Lock 流程是:
|
etcd中没有像ZooKeeper那样的sequence节点。所以其锁实现和基于ZooKeeper实现的有所不同。在上述示例代码中使用的etcdsync的Lock流程是:
|
||||||
|
|
||||||
1. 先检查 `/lock` 路径下是否有值,如果有值,说明锁已经被别人抢了
|
1. 先检查`/lock`路径下是否有值,如果有值,说明锁已经被别人抢了
|
||||||
2. 如果没有值,那么写入自己的值。写入成功返回,说明加锁成功。写入时如果节点被其它节点写入过了,那么会导致加锁失败,这时候到 3
|
2. 如果没有值,那么写入自己的值。写入成功返回,说明加锁成功。写入时如果节点被其它节点写入过了,那么会导致加锁失败,这时候到 3
|
||||||
3. watch `/lock` 下的事件,此时陷入阻塞
|
3. watch `/lock`下的事件,此时陷入阻塞
|
||||||
4. 当 `/lock` 路径下发生事件时,当前进程被唤醒。检查发生的事件是否是删除事件(说明锁被持有者主动 unlock),或者过期事件(说明锁过期失效)。如果是的话,那么回到 1,走抢锁流程。
|
4. 当`/lock`路径下发生事件时,当前进程被唤醒。检查发生的事件是否是删除事件(说明锁被持有者主动 unlock),或者过期事件(说明锁过期失效)。如果是的话,那么回到 1,走抢锁流程。
|
||||||
|
|
||||||
## 6.2.6 Redlock
|
## 6.2.6 Redlock
|
||||||
|
|
||||||
@ -365,7 +365,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Redlock也是一种阻塞锁,单个节点操作对应的是 `set nx px` 命令,超过半数节点返回成功时,就认为加锁成功。
|
Redlock也是一种阻塞锁,单个节点操作对应的是`set nx px`命令,超过半数节点返回成功时,就认为加锁成功。
|
||||||
|
|
||||||
关于Redlock设计曾经在社区引起一场口水战,分布式专家各抒己见。不过这个不是我们要讨论的内容,相关链接在参考资料中给出。
|
关于Redlock设计曾经在社区引起一场口水战,分布式专家各抒己见。不过这个不是我们要讨论的内容,相关链接在参考资料中给出。
|
||||||
|
|
||||||
@ -373,12 +373,12 @@ Redlock也是一种阻塞锁,单个节点操作对应的是 `set nx px` 命令
|
|||||||
|
|
||||||
业务还在单机就可以搞定的量级时,那么按照需求使用任意的单机锁方案就可以。
|
业务还在单机就可以搞定的量级时,那么按照需求使用任意的单机锁方案就可以。
|
||||||
|
|
||||||
如果发展到了分布式服务阶段,但业务规模不大,比如 qps < 1000,使用哪种锁方案都差不多。如果公司内已有可以使用的 ZooKeeper、etcd或者Redis集群,那么就尽量在不引入新的技术栈的情况下满足业务需求。
|
如果发展到了分布式服务阶段,但业务规模不大,qps很小的情况下,使用哪种锁方案都差不多。如果公司内已有可以使用的ZooKeeper、etcd或者Redis集群,那么就尽量在不引入新的技术栈的情况下满足业务需求。
|
||||||
|
|
||||||
业务发展到一定量级的话,就需要从多方面来考虑了。首先是你的锁是否在任何恶劣的条件下都不允许数据丢失,如果不允许,那么就不要使用Redis的setnx的简单锁。
|
业务发展到一定量级的话,就需要从多方面来考虑了。首先是你的锁是否在任何恶劣的条件下都不允许数据丢失,如果不允许,那么就不要使用Redis的setnx的简单锁。
|
||||||
|
|
||||||
如果要使用Redlock,那么要考虑你们公司Redis的集群方案,是否可以直接把对应的Redis的实例的ip+port暴露给开发人员。如果不可以,那也没法用。
|
如果要使用Redlock,那么要考虑你们公司Redis的集群方案,是否可以直接把对应的Redis的实例的ip+port暴露给开发人员。如果不可以,那也没法用。
|
||||||
|
|
||||||
对锁数据的可靠性要求极高的话,那只能使用etcd或者ZooKeeper这种通过一致性协议保证数据可靠性的锁方案。但可靠的背面往往都是较低的吞吐量和较高的延迟。需要根据业务的量级对其进行压力测试,以确保分布式锁所使用的 etcd/ZooKeeper 集群可以承受得住实际的业务请求压力。需要注意的是,etcd 和 Zookeeper 集群是没有办法通过增加节点来提高其性能的。要对其进行横向扩展,只能增加搭建多个集群来支持更多的请求。这会进一步提高对运维和监控的要求。多个集群可能需要引入 proxy,没有 proxy 那就需要业务去根据某个业务 id 来做分片。如果业务已经上线的情况下做扩展,还要考虑数据的动态迁移。这些都不是容易的事情。
|
对锁数据的可靠性要求极高的话,那只能使用etcd或者ZooKeeper这种通过一致性协议保证数据可靠性的锁方案。但可靠的背面往往都是较低的吞吐量和较高的延迟。需要根据业务的量级对其进行压力测试,以确保分布式锁所使用的etcd/ZooKeeper集群可以承受得住实际的业务请求压力。需要注意的是,etcd和Zookeeper集群是没有办法通过增加节点来提高其性能的。要对其进行横向扩展,只能增加搭建多个集群来支持更多的请求。这会进一步提高对运维和监控的要求。多个集群可能需要引入proxy,没有proxy那就需要业务去根据某个业务id来做分片。如果业务已经上线的情况下做扩展,还要考虑数据的动态迁移。这些都不是容易的事情。
|
||||||
|
|
||||||
在选择具体的方案时,还是需要多加思考,对风险早做预估。
|
在选择具体的方案时,还是需要多加思考,对风险早做预估。
|
||||||
|
@ -1,17 +1,17 @@
|
|||||||
# 6.3 延时任务系统
|
# 6.3 延时任务系统
|
||||||
|
|
||||||
我们在做系统时,很多时候是处理实时的任务,请求来了马上就处理,然后立刻给用户以反馈。但有时也会遇到非实时的任务,比如确定的时间点发布重要公告。或者需要在用户做了一件事情的 X 分钟/Y 小时后,对其特定动作,比如通知、发券等等。
|
我们在做系统时,很多时候是处理实时的任务,请求来了马上就处理,然后立刻给用户以反馈。但有时也会遇到非实时的任务,比如确定的时间点发布重要公告。或者需要在用户做了一件事情的X分钟/Y小时后,对其特定动作,比如通知、发券等等。
|
||||||
|
|
||||||
如果业务规模比较小,有时我们也可以通过 db + 轮询来对这种任务进行简单处理,但上了规模的公司,自然会寻找更为普适的解决方案来解决这一类问题。
|
如果业务规模比较小,有时我们也可以通过数据库配合轮询来对这种任务进行简单处理,但上了规模的公司,自然会寻找更为普适的解决方案来解决这一类问题。
|
||||||
|
|
||||||
一般有两种思路来解决这个问题:
|
一般有两种思路来解决这个问题:
|
||||||
|
|
||||||
1. 实现一套类似 crontab 的分布式定时任务管理系统。
|
1. 实现一套类似crontab的分布式定时任务管理系统。
|
||||||
2. 实现一个支持定时发送消息的消息队列。
|
2. 实现一个支持定时发送消息的消息队列。
|
||||||
|
|
||||||
两种思路进而衍生出了一些不同的系统,但其本质是差不多的。都是需要实现一个定时器。定时器英文为 timer,在单机的场景下其实并不少见,例如我们在和网络库打交道的时候经常会写 `SetReadDeadline`,这实际上就是在本地创建了一个定时器,在到达指定的时间后,我们会收到定时器的通知,告诉我们时间已到。这时候如果读取还没有完成的话,就可以认为发生了网络问题,从而中断读取。
|
两种思路进而衍生出了一些不同的系统,但其本质是差不多的。都是需要实现一个定时器。定时器英文为timer,在单机的场景下其实并不少见,例如我们在和网络库打交道的时候经常会写`SetReadDeadline`,这实际上就是在本地创建了一个定时器,在到达指定的时间后,我们会收到定时器的通知,告诉我们时间已到。这时候如果读取还没有完成的话,就可以认为发生了网络问题,从而中断读取。
|
||||||
|
|
||||||
timer 的实现在工业界已经是有解的问题了。常见的就是时间堆和时间轮。
|
timer的实现在工业界已经是有解的问题了。常见的就是时间堆和时间轮。
|
||||||
|
|
||||||
## 6.3.1 timer 实现
|
## 6.3.1 timer 实现
|
||||||
|
|
||||||
@ -21,15 +21,15 @@ timer 的实现在工业界已经是有解的问题了。常见的就是时间
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
小顶堆的好处是什么呢?实际上对于定时器来说,如果堆顶元素比当前的时间还要大,那么说明堆内所有元素都比当前时间大。进而说明这个时刻我们还没有必要对时间堆进行任何处理。所以对于定时 check 来说,时间复杂度是 O(1) 的。
|
小顶堆的好处是什么呢?实际上对于定时器来说,如果堆顶元素比当前的时间还要大,那么说明堆内所有元素都比当前时间大。进而说明这个时刻我们还没有必要对时间堆进行任何处理。所以对于定时check来说,时间复杂度是O(1)的。
|
||||||
|
|
||||||
当我们发现堆顶的元素 < 当前时间时,那么说明可能已经有一批事件已经开始过期了,这时进行正常的弹出和堆调整操作就好。每一次堆调整的时间复杂度都是 O(LgN)。
|
当我们发现堆顶的元素小于当前时间时,那么说明可能已经有一批事件已经开始过期了,这时进行正常的弹出和堆调整操作就好。每一次堆调整的时间复杂度都是O(LgN)。
|
||||||
|
|
||||||
Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉堆,而是使用了扁平一些的四叉堆。在最近的版本中,还加了一些优化,我们先不说优化,先来看看四叉的小顶堆长什么样:
|
Go自身的timer就是用时间堆来实现的,不过并没有使用二叉堆,而是使用了扁平一些的四叉堆。在最近的版本中,还加了一些优化,我们先不说优化,先来看看四叉的小顶堆长什么样:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
小顶堆的性质,父节点比其 4 个子节点都小,子节点之间没有特别的大小关系要求。
|
小顶堆的性质,父节点比其4个子节点都小,子节点之间没有特别的大小关系要求。
|
||||||
|
|
||||||
四叉堆中元素超时和堆调整与二叉堆没有什么本质区别。
|
四叉堆中元素超时和堆调整与二叉堆没有什么本质区别。
|
||||||
|
|
||||||
@ -37,7 +37,7 @@ Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
用时间轮来实现 timer 时,我们需要定义每一个格子的“刻度”,可以将时间轮想像成一个时钟,中心有秒针顺时针转动。每次转动到一个刻度时,我们就需要去查看该刻度挂载的 tasklist 是否有已经到期的任务。
|
用时间轮来实现timer时,我们需要定义每一个格子的“刻度”,可以将时间轮想像成一个时钟,中心有秒针顺时针转动。每次转动到一个刻度时,我们就需要去查看该刻度挂载的tasklist是否有已经到期的任务。
|
||||||
|
|
||||||
从结构上来讲,时间轮和哈希表很相似,如果我们把哈希算法定义为:触发时间%时间轮元素大小。那么这就是一个简单的哈希表。在哈希冲突时,采用链表挂载哈希冲突的定时器。
|
从结构上来讲,时间轮和哈希表很相似,如果我们把哈希算法定义为:触发时间%时间轮元素大小。那么这就是一个简单的哈希表。在哈希冲突时,采用链表挂载哈希冲突的定时器。
|
||||||
|
|
||||||
@ -45,20 +45,20 @@ Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉
|
|||||||
|
|
||||||
## 6.3.2 任务分发
|
## 6.3.2 任务分发
|
||||||
|
|
||||||
有了基本的 timer 实现方案,如果我们开发的是单机系统,那么就可以撸起袖子开干了,不过本章我们讨论的是分布式,距离“分布式”还稍微有一些距离。
|
有了基本的timer实现方案,如果我们开发的是单机系统,那么就可以撸起袖子开干了,不过本章我们讨论的是分布式,距离“分布式”还稍微有一些距离。
|
||||||
|
|
||||||
我们还需要把这些“定时”或是“延时”(本质也是定时)任务分发出去。下面是一种思路:
|
我们还需要把这些“定时”或是“延时”(本质也是定时)任务分发出去。下面是一种思路:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
每一个实例每隔一小时,会去数据库里把下一个小时需要处理的定时任务捞出来,捞取的时候只要取那些 task_id % shard_count = shard_id 的那些 task 即可。
|
每一个实例每隔一小时,会去数据库里把下一个小时需要处理的定时任务捞出来,捞取的时候只要取那些task_id % shard_count = shard_id的那些task即可。
|
||||||
|
|
||||||
当这些定时任务被触发之后需要通知用户侧,有两种思路:
|
当这些定时任务被触发之后需要通知用户侧,有两种思路:
|
||||||
|
|
||||||
1. 将任务被触发的信息封装为一条 event 消息,发往消息队列,由用户侧对消息队列进行监听。
|
1. 将任务被触发的信息封装为一条event消息,发往消息队列,由用户侧对消息队列进行监听。
|
||||||
2. 对用户预先配置的回调函数进行调用。
|
2. 对用户预先配置的回调函数进行调用。
|
||||||
|
|
||||||
两种方案各有优缺点,如果采用 1,那么如果消息队列出故障会导致整个系统不可用,当然,现在的消息队列一般也会有自身的高可用方案,大多数时候我们不用担心这个问题。其次一般业务流程中间走消息队列的话会导致延时增加,定时任务若必须在触发后的几十毫秒到几百毫秒内完成,那么采用消息队列就会有一定的风险。如果采用 2,会加重定时任务系统的负担。我们知道,单机的 timer 执行时最害怕的就是回调函数执行时间过长,这样会阻塞后续的任务执行。在分布式场景下,这种忧虑依然是适用的。一个不负责任的业务回调可能就会直接拖垮整个定时任务系统。所以我们还要考虑在回调的基础上增加经过测试的超时时间设置,并且对由用户填入的超时时间做慎重的审核。
|
两种方案各有优缺点,如果采用1,那么如果消息队列出故障会导致整个系统不可用,当然,现在的消息队列一般也会有自身的高可用方案,大多数时候我们不用担心这个问题。其次一般业务流程中间走消息队列的话会导致延时增加,定时任务若必须在触发后的几十毫秒到几百毫秒内完成,那么采用消息队列就会有一定的风险。如果采用2,会加重定时任务系统的负担。我们知道,单机的timer执行时最害怕的就是回调函数执行时间过长,这样会阻塞后续的任务执行。在分布式场景下,这种忧虑依然是适用的。一个不负责任的业务回调可能就会直接拖垮整个定时任务系统。所以我们还要考虑在回调的基础上增加经过测试的超时时间设置,并且对由用户填入的超时时间做慎重的审核。
|
||||||
|
|
||||||
## 6.3.3 rebalance 和幂等考量
|
## 6.3.3 rebalance 和幂等考量
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉
|
|||||||
|
|
||||||
下面给出一种思路:
|
下面给出一种思路:
|
||||||
|
|
||||||
我们可以参考 elasticsearch 的设计,每份任务数据都有多个副本,这里假设两副本:
|
我们可以参考Elasticsearch的设计,每份任务数据都有多个副本,这里假设两副本:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@ -74,12 +74,12 @@ Go 自身的 timer 就是用时间堆来实现的,不过并没有使用二叉
|
|||||||
|
|
||||||
一个任务只会在持有主副本的节点上被执行。
|
一个任务只会在持有主副本的节点上被执行。
|
||||||
|
|
||||||
当有机器故障时,任务数据需要进行 rebalance 工作,比如 node 1 挂了:
|
当有机器故障时,任务数据需要进行数据再平衡的工作,比如节点1挂了:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
node 1 的数据会被迁移到 node 2 和 node 3 上。
|
节点1的数据会被迁移到节点2和节点3上。
|
||||||
|
|
||||||
当然,也可以用稍微复杂一些的思路,比如对集群中的节点进行角色划分,由协调节点来做这种故障时的任务重新分配工作,考虑到高可用,协调节点可能也需要有 1 ~ 2 个备用节点以防不测。
|
当然,也可以用稍微复杂一些的思路,比如对集群中的节点进行角色划分,由协调节点来做这种故障时的任务重新分配工作,考虑到高可用,协调节点可能也需要有1 ~ 2个备用节点以防不测。
|
||||||
|
|
||||||
之前提到我们会用 MQ 触发对用户的通知,在使用 MQ 时,很多 MQ 是不支持 exactly once 的语义的,这种情况下我们需要让用户自己来负责消息的去重或者消费的幂等处理。
|
之前提到我们会用消息队列触发对用户的通知,在使用消息队列时,很多队列是不支持`exactly once`的语义的,这种情况下我们需要让用户自己来负责消息的去重或者消费的幂等处理。
|
||||||
|
@ -169,7 +169,7 @@ if field_1 == 1 || field_2 == 2 {
|
|||||||
|
|
||||||
es的`Bool Query`方案,实际上就是用json来表达了这种程序语言中的Boolean Expression,为什么可以这么做呢?因为json本身是可以表达树形结构的,我们的程序代码在被编译器parse之后,也会变成AST,而AST抽象语法树,顾名思义,就是树形结构。理论上json能够完备地表达一段程序代码被parse之后的结果。这里的Boolean Expression被编译器Parse之后也会生成差不多的树形结构,而且只是整个编译器实现的一个很小的子集。
|
es的`Bool Query`方案,实际上就是用json来表达了这种程序语言中的Boolean Expression,为什么可以这么做呢?因为json本身是可以表达树形结构的,我们的程序代码在被编译器parse之后,也会变成AST,而AST抽象语法树,顾名思义,就是树形结构。理论上json能够完备地表达一段程序代码被parse之后的结果。这里的Boolean Expression被编译器Parse之后也会生成差不多的树形结构,而且只是整个编译器实现的一个很小的子集。
|
||||||
|
|
||||||
### 基于 client sdk 做开发
|
### 基于client SDK做开发
|
||||||
|
|
||||||
初始化:
|
初始化:
|
||||||
|
|
||||||
@ -383,6 +383,6 @@ select * from wms_orders where update_time >= date_sub(
|
|||||||
|
|
||||||
业界使用较多的是阿里开源的Canal,来进行binlog解析与同步。canal会伪装成MySQL的从库,然后解析好行格式的binlog,再以更容易解析的格式(例如json)发送到消息队列。
|
业界使用较多的是阿里开源的Canal,来进行binlog解析与同步。canal会伪装成MySQL的从库,然后解析好行格式的binlog,再以更容易解析的格式(例如json)发送到消息队列。
|
||||||
|
|
||||||
由下游的kafka消费者负责把上游数据表的自增主键作为es的document的id进行写入,这样可以保证每次接收到binlog时,对应id的数据都被覆盖更新为最新。MySQL的row格式的binlog会将每条记录的所有字段都提供给下游,所以实际上在向异构数据目标同步数据时,不需要考虑数据是插入还是更新,只要一律按id进行覆盖即可。
|
由下游的Kafka消费者负责把上游数据表的自增主键作为es的document的id进行写入,这样可以保证每次接收到binlog时,对应id的数据都被覆盖更新为最新。MySQL的row格式的binlog会将每条记录的所有字段都提供给下游,所以实际上在向异构数据目标同步数据时,不需要考虑数据是插入还是更新,只要一律按id进行覆盖即可。
|
||||||
|
|
||||||
这种模式同样需要业务遵守一条数据表规范,即表中必须有唯一主键id来保证我们进入es的数据不会发生重复。一旦不遵守该规范,那么就会在同步时导致数据重复。当然,你也可以为每一张需要的表去定制消费者的逻辑,这就不是通用系统讨论的范畴了。
|
这种模式同样需要业务遵守一条数据表规范,即表中必须有唯一主键id来保证我们进入es的数据不会发生重复。一旦不遵守该规范,那么就会在同步时导致数据重复。当然,你也可以为每一张需要的表去定制消费者的逻辑,这就不是通用系统讨论的范畴了。
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
## 6.5.2 基于洗牌算法的负载均衡
|
## 6.5.2 基于洗牌算法的负载均衡
|
||||||
|
|
||||||
考虑到我们需要随机选取每次发送请求的 endpoint,同时在遇到下游返回错误时换其它节点重试。所以我们设计一个大小和 endpoints 数组大小一致的索引数组,每次来新的请求,我们对索引数组做洗牌,然后取第一个元素作为选中的服务节点,如果请求失败,那么选择下一个节点重试,以此类推:
|
考虑到我们需要随机选取每次发送请求的endpoint,同时在遇到下游返回错误时换其它节点重试。所以我们设计一个大小和endpoints数组大小一致的索引数组,每次来新的请求,我们对索引数组做洗牌,然后取第一个元素作为选中的服务节点,如果请求失败,那么选择下一个节点重试,以此类推:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
var endpoints = []string {
|
var endpoints = []string {
|
||||||
@ -65,7 +65,7 @@ func request(params map[string]interface{}) error {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
我们循环一遍 slice,两两交换,这个和我们平常打牌时常用的洗牌方法类似。看起来没有什么问题。
|
我们循环一遍slice,两两交换,这个和我们平常打牌时常用的洗牌方法类似。看起来没有什么问题。
|
||||||
|
|
||||||
### 6.5.2.1 错误的洗牌导致的负载不均衡
|
### 6.5.2.1 错误的洗牌导致的负载不均衡
|
||||||
|
|
||||||
@ -75,13 +75,13 @@ func request(params map[string]interface{}) error {
|
|||||||
|
|
||||||
2. 洗牌不均匀,会导致整个数组第一个节点有大概率被选中,并且多个节点的负载分布不均衡。
|
2. 洗牌不均匀,会导致整个数组第一个节点有大概率被选中,并且多个节点的负载分布不均衡。
|
||||||
|
|
||||||
第一点比较简单,应该不用在这里给出证明了。关于第二点,我们可以用概率知识来简单证明一下。假设每次挑选都是真随机,我们假设第一个位置的 endpoint 在 len(slice) 次交换中都不被选中的概率是 ((6/7)*(6/7))^7 ≈ 0.34。而分布均匀的情况下,我们肯定希望被第一个元素在任意位置上分布的概率均等,所以其被随机选到的概率应该 ≈ 1/7 ≈ 0.14。
|
第一点比较简单,应该不用在这里给出证明了。关于第二点,我们可以用概率知识来简单证明一下。假设每次挑选都是真随机,我们假设第一个位置的endpoint在len(slice)次交换中都不被选中的概率是((6/7)*(6/7))^7 ≈ 0.34。而分布均匀的情况下,我们肯定希望被第一个元素在任意位置上分布的概率均等,所以其被随机选到的概率应该约等于1/7≈0.14。
|
||||||
|
|
||||||
显然,这里给出的洗牌算法对于任意位置的元素来说,有 30% 的概率不对其进行交换操作。所以所有元素都倾向于留在原来的位置。因为我们每次对shuffle数组输入的都是同一个序列,所以第一个元素有更大的概率会被选中。在负载均衡的场景下,也就意味着endpoints数组中的第一台机器负载会比其它机器高不少(这里至少是3倍以上)。
|
显然,这里给出的洗牌算法对于任意位置的元素来说,有30%的概率不对其进行交换操作。所以所有元素都倾向于留在原来的位置。因为我们每次对shuffle数组输入的都是同一个序列,所以第一个元素有更大的概率会被选中。在负载均衡的场景下,也就意味着endpoints数组中的第一台机器负载会比其它机器高不少(这里至少是3倍以上)。
|
||||||
|
|
||||||
### 6.5.2.2 修正洗牌算法
|
### 6.5.2.2 修正洗牌算法
|
||||||
|
|
||||||
从数学上得到过证明的还是经典的 fisher-yates 算法,主要思路为每次随机挑选一个值,放在数组末尾。然后在 n-1 个元素的数组中再随机挑选一个值,放在数组末尾,以此类推。
|
从数学上得到过证明的还是经典的fisher-yates算法,主要思路为每次随机挑选一个值,放在数组末尾。然后在n-1个元素的数组中再随机挑选一个值,放在数组末尾,以此类推。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func shuffle(indexes []int) {
|
func shuffle(indexes []int) {
|
||||||
@ -93,7 +93,7 @@ func shuffle(indexes []int) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
在 Go 的标准库中实际上已经为我们内置了该算法:
|
在Go的标准库中实际上已经为我们内置了该算法:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func shuffle(n int) []int {
|
func shuffle(n int) []int {
|
||||||
@ -102,13 +102,13 @@ func shuffle(n int) []int {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
在当前的场景下,我们只要用 rand.Perm 就可以得到我们想要的索引数组了。
|
在当前的场景下,我们只要用`rand.Perm`就可以得到我们想要的索引数组了。
|
||||||
|
|
||||||
## 6.5.3 ZooKeeper 集群的随机节点挑选问题
|
## 6.5.3 ZooKeeper 集群的随机节点挑选问题
|
||||||
|
|
||||||
本节中的场景是从 N 个节点中选择一个节点发送请求,初始请求结束之后,后续的请求会重新对数组洗牌,所以每两个请求之间没有什么关联关系。因此我们上面的洗牌算法,理论上不初始化随机库的种子也是不会出什么问题的。
|
本节中的场景是从N个节点中选择一个节点发送请求,初始请求结束之后,后续的请求会重新对数组洗牌,所以每两个请求之间没有什么关联关系。因此我们上面的洗牌算法,理论上不初始化随机库的种子也是不会出什么问题的。
|
||||||
|
|
||||||
但在一些特殊的场景下,例如使用ZooKeeper时,客户端初始化从多个服务节点中挑选一个节点后,是会向该节点建立长连接的。并且之后如果有请求,也都会发送到该节点去。直到该节点不可用,才会在 endpoints 列表中挑选下一个节点。在这种场景下,我们的初始连接节点选择就要求必须是“真”随机了。否则,所有客户端起动时,都会去连接同一个ZooKeeper的实例,根本无法起到负载均衡的目的。如果在日常开发中,你的业务也是类似的场景,也务必考虑一下是否会发生类似的情况。为rand库设置种子的方法:
|
但在一些特殊的场景下,例如使用ZooKeeper时,客户端初始化从多个服务节点中挑选一个节点后,是会向该节点建立长连接的。并且之后如果有请求,也都会发送到该节点去。直到该节点不可用,才会在endpoints列表中挑选下一个节点。在这种场景下,我们的初始连接节点选择就要求必须是“真”随机了。否则,所有客户端起动时,都会去连接同一个ZooKeeper的实例,根本无法起到负载均衡的目的。如果在日常开发中,你的业务也是类似的场景,也务必考虑一下是否会发生类似的情况。为rand库设置种子的方法:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
rand.Seed(time.Now().UnixNano())
|
rand.Seed(time.Now().UnixNano())
|
||||||
|
@ -8,25 +8,25 @@
|
|||||||
|
|
||||||
### 6.6.1.1 报表系统
|
### 6.6.1.1 报表系统
|
||||||
|
|
||||||
在一些偏 OLAP 或者离线的数据平台中,经过长期的叠代开发,整个系统的功能模块已经渐渐稳定。可变动的项只出现在数据层,而数据层的变动大多可以认为是 SQL 的变动,架构师们自然而然地会想着把这些变动项抽离到系统外部。比如本节所述的配置管理系统。
|
在一些偏OLAP或者离线的数据平台中,经过长期的叠代开发,整个系统的功能模块已经渐渐稳定。可变动的项只出现在数据层,而数据层的变动大多可以认为是SQL的变动,架构师们自然而然地会想着把这些变动项抽离到系统外部。比如本节所述的配置管理系统。
|
||||||
|
|
||||||
当业务提出了新的需求时,我们的需求是将新的 SQL 录入到系统内部,或者简单修改一下老的 SQL。不对系统进行上线,就可以直接完成这些修改。
|
当业务提出了新的需求时,我们的需求是将新的SQL录入到系统内部,或者简单修改一下老的SQL。不对系统进行上线,就可以直接完成这些修改。
|
||||||
|
|
||||||
### 6.6.1.2 业务配置
|
### 6.6.1.2 业务配置
|
||||||
|
|
||||||
大公司的平台部门服务众多业务线,在平台内为各业务线分配唯一 id。平台本身也由多个模块构成,这些模块需要共享相同的业务线定义(要不然就乱套了)。当公司新开产品线时,需要能够在短时间内打通所有平台系统的流程。这时候每个系统都走上线流程肯定是来不及的。另外需要对这种公共配置进行统一管理,同时对其增减逻辑也做统一管理。这些信息变更时,需要自动通知到业务方的系统,而不需要人力介入(或者只需要很简单的介入,比如点击审核通过)。
|
大公司的平台部门服务众多业务线,在平台内为各业务线分配唯一id。平台本身也由多个模块构成,这些模块需要共享相同的业务线定义(要不然就乱套了)。当公司新开产品线时,需要能够在短时间内打通所有平台系统的流程。这时候每个系统都走上线流程肯定是来不及的。另外需要对这种公共配置进行统一管理,同时对其增减逻辑也做统一管理。这些信息变更时,需要自动通知到业务方的系统,而不需要人力介入(或者只需要很简单的介入,比如点击审核通过)。
|
||||||
|
|
||||||
除业务线管理之外,很多互联网公司会按照城市来铺展自己的业务。在某个城市未开城之前,理论上所有模块都应该认为带有该城市 id 的数据是脏数据并自动过滤掉。而如果业务开城,在系统中就应该自己把这个新的城市 id 自动加入到白名单中。这样业务流程便可以自动运转。
|
除业务线管理之外,很多互联网公司会按照城市来铺展自己的业务。在某个城市未开城之前,理论上所有模块都应该认为带有该城市id的数据是脏数据并自动过滤掉。而如果业务开城,在系统中就应该自己把这个新的城市id自动加入到白名单中。这样业务流程便可以自动运转。
|
||||||
|
|
||||||
再举个例子,互联网公司的运营系统中会有各种类型的运营活动,有些运营活动推出后可能出现了超出预期的事件(比如公关危机),需要紧急将系统下线。这时候会用到一些开关来快速关闭相应的功能。或者快速将想要剔除的活动 id 从白名单中剔除。在 Web 章节中的 ab test 一节中,我们也提到,有时需要有这样的系统来告诉我们当前需要放多少流量到相应的功能代码上。我们可以像那一节中,使用远程 rpc 来获知这些信息,但同时,也可以结合分布式配置系统,主动地拉取到这些信息。
|
再举个例子,互联网公司的运营系统中会有各种类型的运营活动,有些运营活动推出后可能出现了超出预期的事件(比如公关危机),需要紧急将系统下线。这时候会用到一些开关来快速关闭相应的功能。或者快速将想要剔除的活动id从白名单中剔除。在Web章节中的AB测试一节中,我们也提到,有时需要有这样的系统来告诉我们当前需要放多少流量到相应的功能代码上。我们可以像那一节中,使用远程RPC来获知这些信息,但同时,也可以结合分布式配置系统,主动地拉取到这些信息。
|
||||||
|
|
||||||
## 6.6.2 使用 etcd 实现配置更新
|
## 6.6.2 使用 etcd 实现配置更新
|
||||||
|
|
||||||
我们使用 etcd 实现一个简单的配置读取和动态更新流程,以此来了解线上的配置更新流程。
|
我们使用etcd实现一个简单的配置读取和动态更新流程,以此来了解线上的配置更新流程。
|
||||||
|
|
||||||
### 6.6.2.1 配置定义
|
### 6.6.2.1 配置定义
|
||||||
|
|
||||||
简单的配置,可以将内容完全存储在 etcd 中。比如:
|
简单的配置,可以将内容完全存储在etcd中。比如:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
etcdctl get /configs/remote_config.json
|
etcdctl get /configs/remote_config.json
|
||||||
@ -50,7 +50,7 @@ cfg := client.Config{
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
直接用 etcd client 包中的结构体初始化,没什么可说的。
|
直接用etcd client包中的结构体初始化,没什么可说的。
|
||||||
|
|
||||||
### 6.6.2.3 配置获取
|
### 6.6.2.3 配置获取
|
||||||
|
|
||||||
@ -64,7 +64,7 @@ if err != nil {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
获取配置使用 etcd KeysAPI 的 Get 方法,比较简单。
|
获取配置使用etcd KeysAPI的`Get()`方法,比较简单。
|
||||||
|
|
||||||
### 6.6.2.4 配置更新订阅
|
### 6.6.2.4 配置更新订阅
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ go func() {
|
|||||||
}()
|
}()
|
||||||
```
|
```
|
||||||
|
|
||||||
通过订阅 config 路径的变动事件,在该路径下内容发生变化时,客户端侧可以收到变动通知,并收到变动后的字符串值。
|
通过订阅config路径的变动事件,在该路径下内容发生变化时,客户端侧可以收到变动通知,并收到变动后的字符串值。
|
||||||
|
|
||||||
### 6.6.2.5 整合起来
|
### 6.6.2.5 整合起来
|
||||||
|
|
||||||
@ -166,11 +166,11 @@ func main() {
|
|||||||
|
|
||||||
如果业务规模不大,使用本节中的例子就可以实现功能了。
|
如果业务规模不大,使用本节中的例子就可以实现功能了。
|
||||||
|
|
||||||
这里只需要注意一点,我们在更新配置时,进行了一系列操作:watch 响应,json 解析,这些操作都不具备原子性。当单个业务请求流程中多次获取 config 时,有可能因为中途 config 发生变化而导致单个请求前后逻辑不一致。因此,在使用类似这样的方式来更新配置时,需要在单个请求的生命周期内使用同样的配置。具体实现方式可以是只在请求开始的时候获取一次配置,然后依次向下透传等等,具体情况具体分析。
|
这里只需要注意一点,我们在更新配置时,进行了一系列操作:watch响应,json解析,这些操作都不具备原子性。当单个业务请求流程中多次获取config时,有可能因为中途config发生变化而导致单个请求前后逻辑不一致。因此,在使用类似这样的方式来更新配置时,需要在单个请求的生命周期内使用同样的配置。具体实现方式可以是只在请求开始的时候获取一次配置,然后依次向下透传等等,具体情况具体分析。
|
||||||
|
|
||||||
## 6.6.3 配置膨胀
|
## 6.6.3 配置膨胀
|
||||||
|
|
||||||
随着业务的发展,配置系统本身所承载的压力可能也会越来越大,配置文件可能成千上万。客户端同样上万,将配置内容存储在 etcd 内部便不再合适了。随着配置文件数量的膨胀,除了存储系统本身的吞吐量问题,还有配置信息的管理问题。我们需要对相应的配置进行权限管理,需要根据业务量进行配置存储的集群划分。如果客户端太多,导致了配置存储系统无法承受瞬时大量的 QPS,那可能还需要在客户端侧进行缓存优化,等等。
|
随着业务的发展,配置系统本身所承载的压力可能也会越来越大,配置文件可能成千上万。客户端同样上万,将配置内容存储在etcd内部便不再合适了。随着配置文件数量的膨胀,除了存储系统本身的吞吐量问题,还有配置信息的管理问题。我们需要对相应的配置进行权限管理,需要根据业务量进行配置存储的集群划分。如果客户端太多,导致了配置存储系统无法承受瞬时大量的QPS,那可能还需要在客户端侧进行缓存优化,等等。
|
||||||
|
|
||||||
这也就是为什么大公司都会针对自己的业务额外开发一套复杂配置系统的原因。
|
这也就是为什么大公司都会针对自己的业务额外开发一套复杂配置系统的原因。
|
||||||
|
|
||||||
@ -178,17 +178,17 @@ func main() {
|
|||||||
|
|
||||||
在配置管理过程中,难免出现用户误操作的情况,例如在更新配置时,输入了无法解析的配置。这种情况下我们可以通过配置校验来解决。
|
在配置管理过程中,难免出现用户误操作的情况,例如在更新配置时,输入了无法解析的配置。这种情况下我们可以通过配置校验来解决。
|
||||||
|
|
||||||
有时错误的配置可能不是格式上有问题,而是在逻辑上有问题。比如我们写 SQL 时少 select 了一个字段,更新配置时,不小心把丢掉了 json 字符串中的一个 field 而导致程序无法理解新的配置而进入诡异的逻辑。为了快速止损,最快且最有效的办法就是进行版本管理,并支持按版本回滚。
|
有时错误的配置可能不是格式上有问题,而是在逻辑上有问题。比如我们写SQL时少select了一个字段,更新配置时,不小心把丢掉了json字符串中的一个field而导致程序无法理解新的配置而进入诡异的逻辑。为了快速止损,最快且最有效的办法就是进行版本管理,并支持按版本回滚。
|
||||||
|
|
||||||
在配置进行更新时,我们要为每份配置的新内容赋予一个版本号,并将修改前的内容和版本号记录下来,当发现新配置出问题时,能够及时地回滚回来。
|
在配置进行更新时,我们要为每份配置的新内容赋予一个版本号,并将修改前的内容和版本号记录下来,当发现新配置出问题时,能够及时地回滚回来。
|
||||||
|
|
||||||
常见的做法是,使用 MySQL 来存储配置文件/字符串的不同版本内容,在需要回滚时,只要进行简单的查询即可。
|
常见的做法是,使用MySQL来存储配置文件/字符串的不同版本内容,在需要回滚时,只要进行简单的查询即可。
|
||||||
|
|
||||||
## 6.6.5 客户端容错
|
## 6.6.5 客户端容错
|
||||||
|
|
||||||
在业务系统的配置被剥离到配置中心之后,并不意味着我们的系统可以高枕无忧了。当配置中心本身宕机时,我们也需要一定的容错能力,至少保证在其宕机期间,业务依然可以运转。这要求我们的系统能够在配置中心宕机时,也能拿到需要的配置信息。哪怕这些信息不够新。
|
在业务系统的配置被剥离到配置中心之后,并不意味着我们的系统可以高枕无忧了。当配置中心本身宕机时,我们也需要一定的容错能力,至少保证在其宕机期间,业务依然可以运转。这要求我们的系统能够在配置中心宕机时,也能拿到需要的配置信息。哪怕这些信息不够新。
|
||||||
|
|
||||||
具体来讲,在给业务提供配置读取的 sdk 时,最好能够将拿到的配置在业务机器的磁盘上也缓存一份。这样远程配置中心不可用时,可以直接用硬盘上的内容来做兜底。当重新连接上配置中心时,再把相应的内容进行更新。
|
具体来讲,在给业务提供配置读取的SDK时,最好能够将拿到的配置在业务机器的磁盘上也缓存一份。这样远程配置中心不可用时,可以直接用硬盘上的内容来做兜底。当重新连接上配置中心时,再把相应的内容进行更新。
|
||||||
|
|
||||||
加入缓存之后务必需要考虑的是数据一致性问题,当个别业务机器因为网络错误而与其它机器配置不一致时,我们也应该能够从监控系统中知晓。
|
加入缓存之后务必需要考虑的是数据一致性问题,当个别业务机器因为网络错误而与其它机器配置不一致时,我们也应该能够从监控系统中知晓。
|
||||||
|
|
||||||
|
@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
作为收集数据的前置工作,有能力去写一个简单的或者复杂的爬虫,对于我们来说依然非常重要。
|
作为收集数据的前置工作,有能力去写一个简单的或者复杂的爬虫,对于我们来说依然非常重要。
|
||||||
|
|
||||||
## 基于 colly 的单机爬虫
|
## 基于colly的单机爬虫
|
||||||
|
|
||||||
有很多程序员比较喜欢在 v2ex 上讨论问题,发表观点,有时候可能懒癌发作,我们希望能直接命令行爬到 v2ex 在 Go tag 下的新贴,只要简单写一个爬虫即可。
|
有很多程序员比较喜欢在v2ex上讨论问题,发表观点,有时候可能懒癌发作,我们希望能直接命令行爬到v2ex在Go tag下的新贴,只要简单写一个爬虫即可。
|
||||||
|
|
||||||
《Go 语言编程》一书给出了简单的爬虫示例,经过了多年的发展,现在使用 Go 语言写一个网站的爬虫要更加方便,比如用 colly 来实现爬取 v2ex 前十页内容:
|
《Go 语言编程》一书给出了简单的爬虫示例,经过了多年的发展,现在使用Go语言写一个网站的爬虫要更加方便,比如用colly来实现爬取v2ex前十页内容:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
package main
|
package main
|
||||||
@ -69,40 +69,40 @@ func main() {
|
|||||||
|
|
||||||
## 分布式爬虫
|
## 分布式爬虫
|
||||||
|
|
||||||
想像一下,你们的信息分析系统运行非常之快。获取信息的速度成为了瓶颈,虽然可以用上 Go 语言所有优秀的并发特性,将单机的 CPU 和网络带宽都用满,但还是希望能够加快爬虫的爬取速度。在很多场景下,速度是有意义的:
|
想像一下,你们的信息分析系统运行非常之快。获取信息的速度成为了瓶颈,虽然可以用上Go语言所有优秀的并发特性,将单机的CPU和网络带宽都用满,但还是希望能够加快爬虫的爬取速度。在很多场景下,速度是有意义的:
|
||||||
|
|
||||||
1. 对于价格战期间的电商们来说,希望能够在对手价格变动后第一时间获取到其最新价格,再靠机器自动调整本家的商品价格。
|
1. 对于价格战期间的电商们来说,希望能够在对手价格变动后第一时间获取到其最新价格,再靠机器自动调整本家的商品价格。
|
||||||
2. 对于类似头条之类的 feed 流业务,信息的时效性也非常重要。如果我们慢吞吞地爬到的新闻是昨天的新闻,那对于用户来说就没有任何意义。
|
2. 对于类似头条之类的feed流业务,信息的时效性也非常重要。如果我们慢吞吞地爬到的新闻是昨天的新闻,那对于用户来说就没有任何意义。
|
||||||
|
|
||||||
所以我们需要分布式爬虫。从本质上来讲,分布式爬虫是一套任务分发和执行系统。而常见的任务分发,因为上下游存在速度不匹配问题,必然要借助消息队列。
|
所以我们需要分布式爬虫。从本质上来讲,分布式爬虫是一套任务分发和执行系统。而常见的任务分发,因为上下游存在速度不匹配问题,必然要借助消息队列。
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
上游的主要工作是根据预先配置好的起点来爬取所有的目标“列表页”,列表页的 html 内容中会包含有所有详情页的链接。详情页的数量一般是列表页的 10~100 倍,所以我们将这些详情页链接作为“任务”内容,通过 mq 分发出去。
|
上游的主要工作是根据预先配置好的起点来爬取所有的目标“列表页”,列表页的html内容中会包含有所有详情页的链接。详情页的数量一般是列表页的10~100倍,所以我们将这些详情页链接作为“任务”内容,通过消息队列分发出去。
|
||||||
|
|
||||||
针对页面爬取来说,在执行时是否偶尔会有重复其实不太重要,因为任务结果是幂等的(这里我们只爬页面内容,不考虑评论部分)。
|
针对页面爬取来说,在执行时是否偶尔会有重复其实不太重要,因为任务结果是幂等的(这里我们只爬页面内容,不考虑评论部分)。
|
||||||
|
|
||||||
本节我们来简单实现一个基于消息队列的爬虫,本节我们使用 nats 来做任务分发。实际开发中,应该针对自己的业务对消息本身的可靠性要求和公司的基础架构组件情况进行选型。
|
本节我们来简单实现一个基于消息队列的爬虫,本节我们使用nats来做任务分发。实际开发中,应该针对自己的业务对消息本身的可靠性要求和公司的基础架构组件情况进行选型。
|
||||||
|
|
||||||
### nats 简介
|
### nats 简介
|
||||||
|
|
||||||
nats 是 Go 实现的一个高性能分布式消息队列,适用于高并发高吞吐量的消息分发场景。早期的 nats 以速度为重,没有支持持久化。从 16 年开始,nats 通过 nats-streaming 支持基于日志的持久化,以及可靠的消息传输。为了演示方便,我们本节中只使用 nats。
|
nats是Go实现的一个高性能分布式消息队列,适用于高并发高吞吐量的消息分发场景。早期的nats以速度为重,没有支持持久化。从16年开始,nats通过nats-streaming支持基于日志的持久化,以及可靠的消息传输。为了演示方便,我们本节中只使用nats。
|
||||||
|
|
||||||
nats 的服务端项目是 gnatsd,客户端与 gnatsd 的通信方式为基于 tcp 的文本协议,非常简单:
|
nats的服务端项目是gnatsd,客户端与gnatsd的通信方式为基于tcp的文本协议,非常简单:
|
||||||
|
|
||||||
向 subject 为 task 发消息:
|
向subject为task发消息:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
以 workers 的 queue 从 tasks subject 订阅消息:
|
以workers的queue从tasks subject订阅消息:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
其中的 queue 参数是可选的,如果希望在分布式的消费端进行任务的负载均衡,而不是所有人都收到同样的消息,那么就要给消费端指定相同的 queue 名字。
|
其中的queue参数是可选的,如果希望在分布式的消费端进行任务的负载均衡,而不是所有人都收到同样的消息,那么就要给消费端指定相同的queue名字。
|
||||||
|
|
||||||
#### 基本消息生产
|
#### 基本消息生产
|
||||||
|
|
||||||
生产消息只要指定 subject 即可:
|
生产消息只要指定subject即可:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
nc, err := nats.Connect(nats.DefaultURL)
|
nc, err := nats.Connect(nats.DefaultURL)
|
||||||
@ -121,7 +121,7 @@ nc.Flush()
|
|||||||
|
|
||||||
直接使用nats的subscribe API并不能达到任务分发的目的,因为pub sub本身是广播性质的。所有消费者都会收到完全一样的所有消息。
|
直接使用nats的subscribe API并不能达到任务分发的目的,因为pub sub本身是广播性质的。所有消费者都会收到完全一样的所有消息。
|
||||||
|
|
||||||
除了普通的subscribe之外,nats还提供了queue subscribe的功能。只要提供一个queue group名字(类似kafka中的 consumer group),即可均衡地将任务分发给消费者。
|
除了普通的subscribe之外,nats还提供了queue subscribe的功能。只要提供一个queue group名字(类似Kafka中的consumer group),即可均衡地将任务分发给消费者。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
nc, err := nats.Connect(nats.DefaultURL)
|
nc, err := nats.Connect(nats.DefaultURL)
|
||||||
@ -132,7 +132,7 @@ if err != nil {
|
|||||||
|
|
||||||
// queue subscribe 相当于在消费者之间进行任务分发的分支均衡
|
// queue subscribe 相当于在消费者之间进行任务分发的分支均衡
|
||||||
// 前提是所有消费者都使用 workers 这个 queue
|
// 前提是所有消费者都使用 workers 这个 queue
|
||||||
// nats 中的 queue 概念上类似于 kafka 中的 consumer group
|
// nats 中的 queue 概念上类似于 Kafka 中的 consumer group
|
||||||
sub, err := nc.QueueSubscribeSync("tasks", "workers")
|
sub, err := nc.QueueSubscribeSync("tasks", "workers")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// log error
|
// log error
|
||||||
@ -151,7 +151,7 @@ for {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 结合 colly 的消息生产
|
#### 结合colly的消息生产
|
||||||
|
|
||||||
我们为每一个网站定制一个对应的collector,并设置相应的规则,比如v2ex,v2fx(虚构的),再用简单的工厂方法来将该collector和其host对应起来:
|
我们为每一个网站定制一个对应的collector,并设置相应的规则,比如v2ex,v2fx(虚构的),再用简单的工厂方法来将该collector和其host对应起来:
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
# 6.8 补充说明
|
# 6.8 补充说明
|
||||||
|
|
||||||
分布式是很大的领域,本章中的介绍只能算是对领域的管中窥豹。因为大型系统流量大,并发高,所以往往很多朴素的方案会变得难以满足需求。人们为了解决大型系统场景中的各种问题,而开发出了各式各样的分布式系统。有些系统非常简单,比如本章中介绍的分布式 id 生成器,而有一些系统则可能非常复杂,比如本章中的分布式搜索引擎(当然,本章中提到的 es 不是 Go 实现)。
|
分布式是很大的领域,本章中的介绍只能算是对领域的管中窥豹。因为大型系统流量大,并发高,所以往往很多朴素的方案会变得难以满足需求。人们为了解决大型系统场景中的各种问题,而开发出了各式各样的分布式系统。有些系统非常简单,比如本章中介绍的分布式id生成器,而有一些系统则可能非常复杂,比如本章中的分布式搜索引擎(当然,本章中提到的es不是Go实现)。
|
||||||
|
|
||||||
无论简单的或是复杂的系统,都会在特定的场景中体现出它们重要的价值,希望读者朋友可以多多接触开源,积累自己的工具箱,从而站在巨人们的肩膀之上。
|
无论简单的或是复杂的系统,都会在特定的场景中体现出它们重要的价值,希望读者朋友可以多多接触开源,积累自己的工具箱,从而站在巨人们的肩膀之上。
|
||||||
|
Loading…
x
Reference in New Issue
Block a user