TrueAsync Server生产环境

生产环境

一个好的服务器与一个演示 demo 的区别不在于它如何工作。而在于它如何失败。所有人心情好的时候都能工作;生产环境从糟糕的日子到来那一刻才真正开始:一次流量高峰、一些慢客户端、一次在最不合时宜时刻的部署。这一章讲的全都是糟糕的日子。

超时:谁也别想永远等下去

从第一季我们领来了一条铁律:任何等待都必须有一个上限。我们来看看一个连接里到底有哪些等待。客户端发送请求,这是一处。取走响应,这是两处。在 keep-alive 上悬在两次请求之间,这是三处。每个阶段各有自己的上限:

php
$config
    ->setReadTimeout(30)       // 请求发送超过 30 秒?再见
    ->setWriteTimeout(60)      // 响应取走超过一分钟?也再见
    ->setKeepAliveTimeout(15); // 一个空闲连接最多活 15 秒

读超时不只是为了慢网络。有一种攻击有个绝妙的名字,slowloris:客户端一分钟发一个字节的请求,把连接永远占住。一百个这样的客户端,一个没有超时的服务器就完了。有了超时,它就只是一百个被丢弃的连接。

你从 SSE 那一章已经知道的那个唯一合理的例外:对于流,写超时要关掉,setWriteTimeout(0)

限额:拒绝要拒绝得廉价

现在讲过载。问题不是"会不会",问题是"何时"。而到了那一刻,服务器恰好有两条路。第一条路:来者不拒,堆起一个队列,慢下来,最后所有人都得到超时,包括那些本可以被服务好的人。第二条路:立刻告诉多余的人"忙",然后像什么都没发生一样服务其余的人。

第二条路这样配置:

php
$config
    ->setMaxConnections(10_000)          // 硬性连接上限
    ->setMaxInflightRequests(1_000)      // 并发处理的请求数
    ->setMaxBodySize(10 * 1024 * 1024);  // body 大于 10 MiB?413

仔细看 setMaxInflightRequests。认出来了吗?它是我们的老朋友,并发限制:POOL_MAX、group 上的 concurrency,现在到了整个服务器的层面。一个多余的请求会立刻得到 503 Retry-After: 1。立刻是关键词:客户端在一毫秒内得知真相,之后会重试,而不是在队列里挂上一分钟等一个超时。

还有第三道防线,最优雅的一道。服务器自己测量请求在队列里等了多久。等待时间在系统性地增长吗?那说明我们跟不上了,于是服务器暂时停止接受新连接,直到追上进度。这个算法叫 CoDel,但你以另一个名字认识它:它就是讲通道那一章里的背压,用到了 accept() 上。互联网是生产者,处理器是消费者,被减速的是生产者。只有一个设置:

php
$config->setBackpressureTargetMs(20); // 把队列里多长的等待时间视为正常

压缩

好消息:响应压缩已经在工作了。服务器通过 Accept-Encoding 与客户端协商,从可用的编解码器里挑选最好的:zstd、brotli、gzip。通常这里没什么要做的,但有三个杠杆值得知道:

php
$config
    ->setCompressionMinSize(1024)   // 别压缩小东西:开销比省下的还贵
    ->setZstdLevel(3)               // 各编解码器的速度/压缩比权衡
    ->setCompressionMimeTypes([...]); // 白名单:文本被压缩,jpeg 本就不再压缩

外加响应侧的一个杠杆:$res->setNoCompression(),用于那些压缩有害的端点。顺便说一句,SSE 的辅助方法自己会调用它;一个带缓冲的 gzip 会扼杀投递的一切即时性。

日志

一切安好的时候,服务器是沉默的。同意吧,我们更愿意从别处、而不是从用户那里听到麻烦:

php
use TrueAsync\LogSeverity;

$config
    ->setLogSeverity(LogSeverity::WARN)
    ->setLogStream(STDERR);

WARN 覆盖失败的 TLS 握手、被丢弃的客户端、被吞掉的异常。INFO 加上生命周期:启动、端口、worker。这个输出口是一个普通的 PHP 流:给 systemd 和 Docker 用的 stderr、一个文件,随你。

优雅地停下

还有最后一个糟糕的日子,它其实是个好日子:部署。一幅值得用油画描绘的画面:服务器有一千个活着的连接,其中一百个正写到数据库一半,而新版本来了。kill -9?那就享受一百个被中止的事务和一群上传坏掉的用户吧。

优雅的版本是这样的:

php
$config->setShutdownTimeout(30);

在收到 stop() 或 SIGTERM 时,服务器先停止接受新连接,让当前的连接把它们的一生过完,最多三十秒。没能赶上的会被取消。而在这里,作用域纪律在这一季里最后一次得到回报:取消协作式地穿过每个请求的树,finally 块释放资源,池回滚未关闭的事务。优雅关停不是作为一个功能被写出来的。它是从架构里掉出来的。

对于负载均衡器后面那些长寿的连接,还有一个点缀,setMaxConnectionAgeMs():一个超过给定年龄的连接会被优雅地关闭(Connection: close、GOAWAY),好让客户端在各机器间重新分布,而不是在一台上一挂就是好几年。

现在这才是生产环境。过载迎来的是一次快速拒绝,慢客户端撞上超时,一次部署不会把事务撕碎,而服务器自己会报告它的问题。整个系列只剩最后一道关卡:到目前为止,只有浏览器和 curl 跟我们的服务器说过话。是时候放进别的对话者了,邻近的那些服务,用它们的母语。