内容简介:从1.8开始,Go标准库中的net/http支持了服务T是类似微博的点赞功能,当用户点赞某条微博的时候,一方面要给点赞数+1,另一方面要通知post的作者“XXX赞了你的微博”,同时还要有策略通知点赞人的粉丝“你关注的XXX点赞了这条微博”……当然这些功能不是一个事务,而且也不是同步的,应该异步来做。所以,最终的流程可能是:如果不做gracefulShutdown,在中途的任何一个步骤时,进程被杀掉,都可能造成一些问题。当然就这个例子来说,往小了说也不是什么大事,这些问题都可以忍受。但往大了说,大V通过点赞
从1.8开始,Go标准库中的net/http支持了 GracefulShutdown
,使得进程可以把现有请求都处理完之后再退出,从而最大限度地减少不一致性给服务端带来的负担。如果不做GracefulShutdown,有哪些不一致性呢?简单举个例子:
服务T是类似微博的点赞功能,当用户点赞某条微博的时候,一方面要给点赞数+1,另一方面要通知post的作者“XXX赞了你的微博”,同时还要有策略通知点赞人的粉丝“你关注的XXX点赞了这条微博”……当然这些功能不是一个事务,而且也不是同步的,应该异步来做。所以,最终的流程可能是:
db.IncrPostNumber() mqA.Send(messageA) mqB.Send(messageB) //...
如果不做gracefulShutdown,在中途的任何一个步骤时,进程被杀掉,都可能造成一些问题。当然就这个例子来说,往小了说也不是什么大事,这些问题都可以忍受。但往大了说,大V通过点赞让粉丝看到某条微博,这也是收费的。结果广告主给了钱却看不到效果,甚至发现根本没有粉丝看到,这是要让你退钱的!
所以做GracefulShutdown,不论对什么业务系统来说,都是很有必要的。但是本文我们不讨论GracefulShutdown,而是讨论一个更进一步的话题,Graceful Restart。
GracefulShutdown和Graceful Restart是什么区别呢?从名字上大概就能看出,一个是优雅退出,一个是优雅重启。优雅退出上面也说了,重点是保证进程退出前处理完当下所有的请求。而优雅重启要求更高,它的目标是在进程重启时整个过程要平滑,不要让用户感受到任何异样,不要有任何downtime,也就是停机时间,保证进程持续可用。因此,gracefulShutdown只是实现gracefulRestart的一个必要部分,gracefulRestart还要求更多。
一种GracefulRestart的方法是,通过部署系统配合nginx来完成。由于大部分业务系统都是挂在nginx之后通过nginx进行反向代理的,因此在重启某台机器的进程A时,可以把该机器IP从nginx的upstream中摘除掉,等一段时间比如1分钟,该进程差不多也处理完了所以请求,实际上已经处于空闲状态了。这时就可以kill掉该进程并重启,等重启成功之后,再把该机器的IP加回到nginx对应的upstream中去。
这种方式是语言、平台无关的一种技术方案,但是缺点也很明显:
- 首先就是复杂,需要部署系统和网关(nginx)恰到好处地配合。开发人员点击部署时,部署系统需要通知nginx摘掉某个upstream的某个IP;然后等进程重启成功之后,部署系统需要通知nginx在某个upstream中加上某个IP。这一整套系统的开发测试还是有一定复杂性的。
- 其次是等待时间的未知性。当把机器A摘掉以后过多久进程才能处理完请求?10秒?1分钟?谁也不知道…间隔短了,会出问题,因为部分请求被卡断了;间隔长了,上线又慢,而且你还是不能确定是否请求都处理完了(其实基本上没问题,但是理论上无法保证)。
- 另一个问题是压力陡增。对于大公司动辄几百台的集群,摘一两台无关紧要。但是对于小公司,比如某个服务只有两台机器,并且每台机器压力都挺大。这时如果直接摘一台,所有流量到另一台机器上,使得那台机器承受不住,那么可能会导致整个服务不可用。
因此这里引出第二种实现方式——fd继承
FD继承
fd(file descriptor)也就是文件描述符,是Unix*系统上最常见的概念,everything is file。我们基于一个非常基础的知识点:
进程T fork 出子进程时,子进程会继承父进程T打开的fd。
进程T大概的处理流程类似于:
int sock_fd = createSocketBindTo(":80");
int ok = listen(sock_fd, backlog);
do {
int connect_sock = accept(sock_fd, &SockStruct, &Addr);
process(connect_sock);
}
也就是:
- 构建监听某个端口的socket
- 不断从该socket中读取连接,并处理
这里你可以发现,如果想要accept到连接,我们只需要socket就够了,bind listen这些都是准备工作。如果父进程把这些工作都做了,子进程似乎可以直接从继承过来的socket上读取数据。
这里先不说具体实现细节,但是大体思路其实就是上面说的,非常简单。进程通过环境变量或者args来判断是应该先Listen再accpet,还是直接用继承来的socket进行accept。
这里有个问题,子进程如果在该socket上accept,主进程也accept,那么对同一个socket进行accept操作并发安全吗?答案是——安全,这是glibc为我们保证的,正如malloc这类函数调用一样。
下面是一个简单的代码示例:
package main
import (
"context"
"flag"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"syscall"
)
var (
upgrade bool
ln net.Listener
server *http.Server
)
func init() {
flag.BoolVar(&upgrade, "upgrade", false, "user can't use this")
}
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "hello world from pid:%d, ppid: %d\n", os.Getpid(), os.Getppid())
}
func main() {
flag.Parse()
http.HandleFunc("/", hello)
server = &http.Server{Addr:":8999",}
var err error
if upgrade {
fd := os.NewFile(3, "")
ln,err = net.FileListener(fd)
if err != nil {
fmt.Printf("fileListener fail, error: %s\n", err)
os.Exit(1)
}
fd.Close()
} else {
ln, err = net.Listen("tcp", server.Addr)
if err != nil {
fmt.Printf("listen %s fail, error: %s\n", server.Addr, err)
os.Exit(1)
}
}
go func() {
err := server.Serve(ln)
if err != nil && err != http.ErrServerClosed{
fmt.Printf("serve error: %s\n", err)
}
}()
setupSignal()
fmt.Println("over")
}
func setupSignal() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGUSR2, syscall.SIGINT, syscall.SIGTERM)
sig := <-ch
switch sig {
case syscall.SIGUSR2:
err := forkProcess()
if err != nil {
fmt.Printf("fork process error: %s\n", err)
}
err = server.Shutdown(context.Background())
if err != nil {
fmt.Printf("shutdown after forking process error: %s\n", err)
}
case syscall.SIGINT,syscall.SIGTERM:
signal.Stop(ch)
close(ch)
err := server.Shutdown(context.Background())
if err != nil {
fmt.Printf("shutdown error: %s\n", err)
}
}
}
func forkProcess() error {
flags := []string{"-upgrade"}
cmd := exec.Command(os.Args[0], flags...)
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
l,_ := ln.(*net.TCPListener)
lfd,err := l.File()
if err != nil {
return err
}
cmd.ExtraFiles = []*os.File{lfd,}
return cmd.Start()
}
代码中很关键的两行:
fd := os.NewFile(3, "") ln,err = net.FileListener(fd) fd.Close()
3是什么?3其实就是从父进程继承过来的socket fd。虽然子进程可以默认继承父进程绝大多数的文件描述符(除了文件锁之类的),但是golang的标准库os/exec只默认继承stdin stdout stderr这三个。需要让子进程继承的fd需要在fork之前手动放到ExtraFiles中。由于有了stdin 0 stdout 1 stderr 2,因此其它fd的序号从3开始。
还有一个可能比较让人困惑的问题是, fd.Close()
是干什么的,Close它会有什么影响。这个问题直接的答案是,没有任何影响,只是为了防止资源泄漏。具体可以看看 net.FileListerner
的文档,相关的知识点有点多,可以google fcntl和dup2关键字。
当子进程运行起来后,就可以调用server实现好的Shutdown方法,来关停主进程了。
这种方法代来的一个问题是,当主进程fork出子进程,然后主进程退出后,子进程的父进程就变成了1(孤儿进程)。如果使用supervisor等 工具 来监听服务的话,就会遇到问题(主进程退出了立刻又被supervisor拉起来,然后端口冲突了)。这时候就需要使用linux pidfile。
RE_USEPORT
还有第三种可以做到不停机重启的办法,那便是使用 Linux 内核的新特性reuseport。以前,如果多个进程或者线程同时监听一个端口,只有一个可以成功,其它都会返回端口被占用的错误。
新内核支持通过setsockopt对socket进行设置,使得多个进程或者线程可以同时监听一个端口,内核来进行负载均衡。
利用多进程模型加上 reuseport 库的支持,很容易就可以实现不停机重启。
但是,reuseport也不是万能的灵丹妙药,它也有自己的问题,在连接建立非常频繁的场景下,由于内核使用的算法的局限性,它的性能会下降很多。当然,这和不停机重启没有任何关系,只是顺便一提,如果仅仅使用reuseport特性实现gracefulRestart,应该不会遇到这样的问题。
nginx高版本也使用了reuseport,关于它的性能问题,可以参见 这篇文章
到底是通过继承fd还是reuseport来实现graceful restart,相关的比较可以参见 https://gravitational.com/blog/golang-ssh-bastion-graceful-restarts/ ,不过结论基本上认为继承fd更靠谱(当然这篇文章得出的结论也受限于当时golang本身标准库实现的局限性,使得没办法对Conn进行setsockopt,因为Conn不是一个socket对象而是一个runtime.NetPoller)
More
现在开源社区有不少相关的实现,比如:
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Twenty Lectures on Algorithmic Game Theory
Tim Roughgarden / Cambridge University Press / 2016-8-31 / USD 34.99
Computer science and economics have engaged in a lively interaction over the past fifteen years, resulting in the new field of algorithmic game theory. Many problems that are central to modern compute......一起来看看 《Twenty Lectures on Algorithmic Game Theory》 这本书的介绍吧!