gitbook/手把手带你写一个Web框架/docs/423976.md

230 lines
14 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 加餐|阶段答疑:这些代码里的小知识点你都知道吗?
你好,我是轩脉刃。
上节课国庆特别放送,我们围绕业务架构和基础架构,聊了聊这两种方向在工作上以及在后续发展上的区别,也讲了做系统架构设计的一些术。今天就回归课程,特别整理了关于课程的五个共性问题来解答一下。
## Q1、GitHub分支代码跑不起来怎么办
GitHub 中的每个分支代码都是可以跑起来的我本人亲测过了。出现这个问题可能是因为有的同学只使用go run main.go。
go run main.go 只会运行编译运行的指定文件而一旦当前目录下有其他文件就不会运行到了所以比如在geekbang/02 或者 geekbang/03 分支中,根目录下有其他文件,就不能运行了。**你需要使用 go build 先编译,然后使用./coredemo来运行**。
另外因为最近Go版本更新了有同学问到这个问题go mod 能指定 1.xx.x 版本么?比如想要把 go.mod 中指定 go 版本的go 1.17 修改为go 1.17.1,希望我的项目最低要求 1.17.1。但是 Goland 老是把版本号修改回 go 1.17,是不是我哪里设置有问题?
这是一个小知识点,不过估计不是每个人都知道。其实这里不是设置有问题,而是 go.mod 要求就是如此。
指定 go 版本的地方叫 go directive 。它的格式是:
```go
GoDirective = "go" GoVersion newline .
GoVersion = string | ident . /* valid release version; see above */
The version must be a valid Go release version: a positive integer followed by a dot and a non-negative integer (for example, 1.9, 1.14).
```
其中所谓的 valid release version 为必须是像 1.17 这样,前面是一个点,前面是正整数(其实现在也只能是 1后面是非负整数。
go 的版本形如 1.2.3-pre。
一般最多由两个点组成,其中 1 叫做 major version主版本非常大的改动的时候才会升级这个版本。而 2 叫做 minor version表示有一些接口级别的增加但是会保证向后兼容才会升级这个版本。而 3 叫做 patch version顾名思义一些不影响接口但是打了一些补丁和修复的版本。
而最后的 pre 叫做 pre-release suffix。可以理解是和 beta 版本一样的概念,在 release 版本出现之前,预先投放在市场的试用版本。
所以 **go mod 中的格式只能允许 major version 和 minor version**。它认为,使用者关注这两个版本号就行,这样能保证使用者在使用 golang 标准库的时候,源码接口并没有增加和修改,不管你使用什么 patch version你的业务代码都能跑起来。
## Q2、思维导图怎么画
从第一节课讲Go标准库的源码开始我们就频繁用到思维导图的方法。这个方法非常好用特别是复杂的逻辑跳转画完图之后也就对逻辑基本了解了。当时建议课后你也自己做一下留言区有同学画了自己的思维导图非常棒逻辑是正确的。
但是在画图的过程中,我们会出现新的问题,尤其是基础不那么扎实的同学,在不能一眼看出来每行代码都是干什么的,比如可能过多关注分支细节,不知道才能更好地剥离出主逻辑代码。
那怎么才能快速分辨什么是主线、什么是细节呢?
我提供一个思路,在使用思维导图的时候,对于比较复杂的逻辑,我们要**在头脑中模拟一下,要实现这个逻辑,哪些是关键步骤,然后用寻找这些关键步骤的方法来去源码中阅读**。
比如FileServer是用来实现静态文件服务器的首先我们先在头脑中有个模拟我要先对接上ServerHTTP方法然后要把判断请求路径要是请求的是文件那么我就把文件内容拷贝到请求输出中不就行了么。
那么是不是这样的呢,我们带着这种模拟查看代码就能找到代码的关键点有两个。
一是fileHandler我们能和ListenAndServe 连接起来它提供了ServeHTTP的方法 这个是请求处理的入口函数:
```go
// 返回了fileHandler方法
func FileServer(root FileSystem) Handler {
return &fileHandler{root}
}
func (f *fileHandler) ServeHTTP(w ResponseWriter, r *Request) {
// 请求的入口
serveFile(w, r, f.root, path.Clean(upath), true)
}
```
二是 FileServer 最本质的函数封装了io.CopyN基本逻辑是如果是读取文件夹就遍历文件夹内所有文件把文件名直接输出返回值如果是读取文件就设置文件的阅读指针使用io.CopyN读取文件内容输出返回值。这里如果需要多次读取文件创建Goroutine并为每个Goroutine创建阅读指针。
```go
func serveFile(w ResponseWriter, r *Request, fs FileSystem, name string, redirect bool) {
...
serveContent(w, r, d.Name(), d.ModTime(), sizeFunc, f)
}
func serveContent(w ResponseWriter, r *Request, name string, modtime time.Time, sizeFunc func() (int64, error), content io.ReadSeeker) {
...
if size >= 0 {
ranges, err := parseRange(rangeReq, size)
...
switch {
case len(ranges) == 1:
...
ra := ranges[0]
if _, err := content.Seek(ra.start, io.SeekStart); err != nil {
Error(w, err.Error(), StatusRequestedRangeNotSatisfiable)
return
}
sendSize = ra.length
code = StatusPartialContent
w.Header().Set("Content-Range", ra.contentRange(size))
case len(ranges) > 1:
...
go func() {
for _, ra := range ranges {
part, err := mw.CreatePart(ra.mimeHeader(ctype, size))
...
if _, err := content.Seek(ra.start, io.SeekStart); err != nil {
pw.CloseWithError(err)
return
}
if _, err := io.CopyN(part, content, ra.length); err != nil {
pw.CloseWithError(err)
return
}
}
mw.Close()
pw.Close()
}()
}
...
}
w.WriteHeader(code)
if r.Method != "HEAD" {
io.CopyN(w, sendContent, sendSize)
}
}
```
按照这种先模拟步骤再去对比源码寻找的方式,很好用,即使你的模拟步骤出了问题,也会引导你思考,为什么出了问题?哪里出了问题,促使你更仔细地查看源码。不妨试试。
## Q3、http.Server源码为什么是两层循环
第一节课用思维导图分析了一下http.Server的源码有同学问了这么一个问题go c.serve(connCtx) 里面为什么还有一个循环c指的是一个connection我理解不是每个连接处理一次就好了吗为啥还有一个for循环呢
这里其实有一个扩展知识, HTTP 的keep-alive机制。
**HTTP层有个keep-alive它主要是用于客户端告诉服务端这个连接我还会继续使用在使用完之后不要关闭**。这个设置会影响Web服务的哪几个方面呢
* 性能
这个设置首先会在性能上对客户端和服务器端性能上有一定的提升。很好理解的是少了TCP的三次握手和四次挥手第二次传递数据就可以通过前一个连接直接进行数据交互了。当然会提升服务性能了。
* 服务器TIME\_WAIT的时间
由于HTTP服务的发起方一般都是浏览器即客户端但是先执行完逻辑传输完数据的一定是服务端。那么一旦没有keep-alive机制服务端在传送完数据之后会率先发起连接断开的操作。
由于TCP的四次挥手机制先发起连接断开的一方会在连接断开之后进入到TIME\_WAIT的状态达到2MSL之久。
设想如果没有开启HTTP的keep-alive那么这个TIME\_WAIT就会留在服务端由于服务端资源是非常有限的**我们当然倾向于服务端不会同一时间hold住过多的连接这种TIME\_WAIT的状态应该尽量在客户端保持**。那么这个HTTP的keep-alive机制就起到非常重要的作用了。
所以基于这两个原因现在的浏览器发起Web请求的时候都会带上connection:keep-alive的头了。
而我们的Go服务器使用net/http 在启动服务的时候则会按照当前主流浏览器的设置默认开启keep-alive机制。服务端的意思就是只要浏览器端发送的请求头里要求我开启keep-alive我就可以支持。
所以在源码这段服务一个连接的conn.server中会看到有一个for循环在这个循环中循环读取请求。
```go
// 服务一个连接
func (c *conn) serve(ctx context.Context) {
    c.remoteAddr = c.rwc.RemoteAddr().String()
    ctx = context.WithValue(ctx, LocalAddrContextKey, c.rwc.LocalAddr())
    ...
// 循环读取,每次读取一次请求
    for {
        w, err := c.readRequest(ctx)
        ...
// 判断开启keep-alive
if !w.conn.server.doKeepAlives() {
return
}
...
}
}
```
那要关闭keep-alive怎么办呢你也可以在for循环中的w.conn.server.doKeepAlives 看到
它判断如果服务端的 disableKeepAlives 不是0则设置了关闭keep-alive就不进行for循环了。
```go
// 判断是否开启keep-alive
func (s *Server) doKeepAlives() bool {
// 如果没开keep-alive或者在shutdown过程中就返回false
return atomic.LoadInt32(&s.disableKeepAlives) == 0 && !s.shuttingDown()
}
```
## Q5、为什么context 作为函数的第一个参数?
context 作为第一个参数在实际工作中是非常有用的一个实践。不管是设计一个函数,还是设计一个结构体的方法或者服务,我们一旦养成了将第一个参数作为 context 的习惯,那么这个 context 在相互调用的时候,就会传递下去。这里会带来两大好处:
1. 链路通用内容传递。
在context 中,是可以通过 WithValue 方法,将某些字段封装在 context 里面,并且传递的。最常见的字段是 traceId、spanId。而在日志中带上这些 ID再将日志收集起来我们就能进行分析了。这也是现在比较流行的全链路分析的原理。
2. 链路统一设置超时。
我们在定义一个服务的时候,将第一个参数固定设置为 context就可以通过这个 context 进行超时设置,而这个超时设置,是由上游调用方来设置的,这样就形成了一个统一的超时设置机制。比如 A 设置了 5s 超时,自己使用了 1s传递到下游 B 服务的时候,设置 B 的 context 超时时长为 4s。这样全链路超时传递下去就能保持统一设置了。
## Q5、服务雪崩 case 有哪些?
在第二节课中我们完成了添加 Context 为请求设置超时时间,提到超时很有可能造成雪崩,有同学问到相关问题,引发了我对服务雪崩场景的思考,这里我也简单总结一下。
雪崩的顾名思义,一个服务中断导致其他服务也中断,进而导致大片服务都中断。这里我们最常见的雪崩原因有下列几个:
* 超时设置不合理
服务雪崩最常见的就是**下游服务没设置超时**,导致上游服务不可用,也是我们设置 Context 的原因。![](https://static001.geekbang.org/resource/image/04/db/049079366831da0c849c65b4672744db.jpg?wh=1920x1080)
比如像上图的A->B->C C 的超时不合理,导致 B 请求不中止而进而堆积B 服务逐渐不可用,同理导致 A 服务也不可用。而在微服务盛行的链式结构中这种影响面会更大。![](https://static001.geekbang.org/resource/image/66/78/664ef442085a4b46f64c193b37649878.jpg?wh=1920x1080)
按照前面的分析,除了 G 之外,其他的节点都会收到波及。
* 重试加大流量
我们在下游调用的时候,经常会使用重试机制来防止网络抖动问题,但是**重试机制一旦使用不合理**,也有可能导致下游服务的不可用。
理论上,越下层的服务可承受的 QPS 应该越高。在微服务链路中,有某个下游服务的 QPS比如上图中 C 的QPS没有预估正确当正常请求量上来C 先扛不住,而扛不住返回的错误码又会让上游服务不断增加重试机制,进一步加剧了下游服务的不可用,进而整个系统雪崩。
* 缓存雪崩
缓存雪崩顾名思义就是,**原本应该打在缓存中的请求全部绕开缓存,打到了 DB**,从而导致 DB 不可用,而 DB 作为一个下游服务节点,不可用会导致上游都出现雪崩效应(这里的 DB 也有可能是各种数据或者业务服务)。![](https://static001.geekbang.org/resource/image/b2/f8/b25dec592b78c1c0ef01bc90223fdcf8.jpg?wh=1920x1080)
为什么会出现缓存雪崩呢,我列了一下工作中经常遇到的缓存导致雪崩的原因,有如下三种:
1.被攻击
在平时写代码中我们日常使用这样的逻辑:“根据请求中的某个值建立 key 去缓存中获取,获取不到就去数据库中获取”。但是这种逻辑其实很容易被攻击者利用,攻击者**只需要建立大量非合理的 key**,就可以打穿缓存进入数据库进行请求。请求量只要足够大,就可以导致绕过缓存,让数据库不可用。
2.缓存瞬时失效
“通过第一个请求建立缓存,建立之后一段时间后失效”。这也是一个经常出现瞬时缓存雪崩的原因。因为有可能在第一次批量建立了缓存后,进行业务逻辑,而**后续并没有更新缓存时长**,那就可能导致批量在统一时间内缓存失效。缓存失效后大批量的请求会涌入后端数据库,导致数据库不可用。
3.缓存热 key
还有一种情况是缓存中的**某个 key 突然有大批量的请求涌**入,而缓存的分布式一般是按照 key 进行节点分布的。这样会导致某个缓存服务节点流量过于集中,不可用。而缓存节点不可用又会导致大批量的请求穿透缓存进入数据库,导致数据库不可用。
关于留言问题的回答,今天就暂时到这里了,之后也会收集做统一答疑。所以欢迎你继续留言给我。下节课见~