我们已经介绍了大量有关 web 应用程序的内容,这些应用程序需要连接到数据源、呈现模板、利用 SSL/TLS、为单页应用程序构建 API 等等。
虽然基本原理很清楚,但您可能会发现,将基于这些指导原则构建的应用程序投入生产会导致一些快速问题,特别是在重载情况下。
在上一章中,我们通过解决 web 应用程序中一些最常见的安全问题,实现了一些最佳安全实践。让我们在本章中也这样做,针对性能和速度方面的一些最大问题应用最佳实践。
为此,我们将研究管道中一些最常见的瓶颈,并了解如何减少这些瓶颈,以使我们的应用程序在生产中发挥尽可能高的性能。
具体来说,我们将识别这些瓶颈,然后寻找反向代理和负载平衡,在应用程序中实现缓存,利用SPDY,并研究如何使用托管云服务,通过减少到达应用程序的请求数量来增强我们的速度计划。
在本章结束时,我们希望能够开发出能够帮助任何 Go 应用程序从我们的环境中挤出每一点性能的工具。
在本章中,我们将介绍以下主题:
- 识别瓶颈
- 实现反向代理
- 实现缓存策略
- 实现 HTTP/2
简单地说,您的应用程序有两种类型的瓶颈,一种是由开发和编程缺陷引起的,另一种是底层软件或基础设施固有的缺陷。
前者的答案很简单,找出设计的缺陷并加以修复。在坏代码周围添加补丁可以隐藏安全漏洞,或者延迟更大的性能问题被及时发现。
有时,这些问题源于缺乏压力测试;如果不应用人工负载,则不能保证在本地执行的代码可以扩展。缺乏这种测试有时会导致生产中意外停机。
但是,忽略作为问题来源的坏代码,让我们看看其他一些常见的问题:
- 磁盘 I/O
- 数据库访问
- 高内存/CPU 使用率
- 缺乏并发支持
当然,有数百个违规者,例如网络问题、某些应用程序中的垃圾收集开销、未压缩有效负载/头、非数据库死锁等等。
高内存和 CPU 使用率通常是结果而不是原因,但许多其他原因都是特定于某些语言或环境的。
对于我们的应用程序,我们可能在数据库层有一个弱点。因为我们不进行缓存,所以每个请求都会多次命中数据库。ACID 兼容数据库(如 MySQL/PostgreSQL)因负载不足而臭名昭著,对于不太严格的键/值存储和 NoSQL 解决方案,在同一硬件上这不会是一个问题。数据库一致性的成本在很大程度上决定了这一点,这是选择传统关系数据库的权衡之一。
正如我们现在所知道的,与许多语言不同,Go 配备了一个完整而成熟的 web 服务器平台net/http
。
最近,一些其他语言已经与小型玩具服务器一起交付,用于本地开发,但它们并不用于生产。事实上,许多人明确警告不要这样做。一些常见的是 WEBrick for Ruby、Python 的 SimpleHTTPServer 和 PHP 的-s。其中大多数都存在并发问题,使它们无法成为生产中可行的选择。
围棋的net/http
不同;默认情况下,它以开箱即用的方式处理这些问题。显然,这在很大程度上取决于底层硬件,但在紧要关头,您可以成功地在本地使用它。许多网站都在使用net/http
来提供大量的流量。
但即使是强大的底层 web 服务器也有一些固有的局限性:
- 它们缺少故障切换或分布式选项
- 它们的上游缓存选项有限
- 它们无法轻松地对传入流量进行负载平衡
- 他们无法轻松地集中精力进行集中式日志记录
这就是反向代理发挥作用的地方。反向代理代表一个或多个服务器接受所有传入流量,并通过应用前面的(和其他)选项和好处进行分发。另一个例子是 URL 重写,它更适用于可能没有内置路由和 URL 重写的底层服务。
在 web 服务器前抛出一个简单的反向代理有两大优点,例如 Go;它们是缓存选项以及在不影响底层应用程序的情况下提供静态内容的能力。
反向代理站点最流行的选项之一是 Nginx(发音为 Engine-X)。虽然 Nginx 本身就是一个 web 服务器,但它很早就因其轻量级和并发性而受到赞誉。它很快就成为了 web 应用程序在其他速度较慢或较重的 web 服务器(如 Apache)前进行前线防御的前端。近年来,情况发生了一些变化,因为 Apache 在并发选项和对事件和线程的替代方法的利用方面已经迎头赶上。以下是反向代理 Nginx 配置的示例:
server {
listen 80;
root /var/;
index index.html index.htm;
large_client_header_buffers 4 16k;
# Make site accessible from http://localhost/
server_name localhost
location / {
proxy_pass http://localhost:8080;
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
设置好后,确保您的 Go 应用程序正在端口8080
上运行,然后重新启动 Nginx。对http//:port 80
的请求将通过 Nginx 作为您的应用程序的反向代理提供。您可以通过查看标题或在浏览器中的开发者工具中进行检查:
请记住,我们希望尽可能支持 TLS/SSL,但在这里提供反向代理只是更改端口的问题。我们的应用程序应该在另一个端口上运行,为了清晰起见,可能是附近的端口,然后我们的反向代理将在端口443
上运行。
作为提醒,HTTP 或 HTTPS 的任何端口都是合法的。但是,如果未指定端口,浏览器会自动指向443
以实现安全连接。只需修改nginx.conf
和我们的应用程序常量即可:
server {
listen 443;
location / {
proxy_pass http://localhost:444;
让我们看看如何修改应用程序,如以下代码所示:
const (
DBHost = "127.0.0.1"
DBPort = ":3306"
DBUser = "root"
DBPass = ""
DBDbase = "cms"
PORT = ":444"
)
这允许我们通过前端代理传递 SSL 请求。
在许多 Linux 发行版上,您需要 SUDO 或 root 权限才能使用 1000 以下的端口。
有许多方法可以决定何时创建缓存项以及何时使缓存项过期,因此我们将研究一种更简单、更快的方法。但是如果你有兴趣进一步开发,你可以考虑其他缓存策略;其中一些可以提高资源利用率和性能。
在分配的资源(磁盘空间、内存)内保持缓存稳定性的一种常见策略是缓存过期的最近使用最少的(LRU系统。在此模型中,利用有关上次缓存访问时间(创建或更新)和缓存管理系统的信息可以删除列表中最早的条目。
这对性能有很多好处。首先,如果我们假设最近创建/更新的缓存条目是针对当前最流行的条目的,那么我们可以很快删除未被访问的条目;以便将资源释放出来,用于可能更频繁地访问的现有资源和新资源。
这是一个合理的假设,假设为缓存分配的资源不是无关紧要的。如果文件缓存的容量很大,或者 memcache 的内存很大,那么就最后一次访问而言,最旧的条目很可能不会被频繁使用。
有一个相关的和更细粒度的策略,称为“最少频繁使用”,它对缓存项本身的使用情况保持严格的统计。这不仅消除了对缓存数据的假设,还增加了统计数据维护的开销。
对于我们在这里的演示,我们将使用 LRU。
我们的第一种方法最适合描述为缓存的经典方法,但它不是没有问题的方法。我们将利用磁盘为各个端点(API 和 Web)创建基于文件的缓存。
那么,与文件系统中的缓存相关的问题是什么?在本章前面,我们提到了磁盘可能会引入自己的瓶颈。在这里,我们正在做一个权衡,以保护对数据库的访问,而不是在磁盘 I/O 方面可能遇到其他问题。
如果我们的缓存目录变得非常大,这会变得特别复杂。在这一点上,我们最终引入了更多的文件访问问题。
另一个缺点是我们必须管理缓存;因为文件系统不是短暂的,我们的可用空间是短暂的。我们需要手动使缓存文件过期。这将导致另一轮维护和另一个故障点。
尽管如此,这仍然是一个有用的练习,如果你愿意承担一些潜在的陷阱,它仍然可以被利用:
package cache
const (
Location "/var/cache/"
)
type CacheItem struct {
TTL int
Key string
}
func newCache(endpoint string, params ...[]string) {
}
func (c CacheItem) Get() (bool, string) {
return true, ""
}
func (c CacheItem) Set() bool {
}
func (c CacheItem) Clear() bool {
}
这将设置阶段来执行一些操作,例如基于端点和查询参数创建唯一密钥,检查是否存在缓存文件,如果不存在,则按照正常方式获取请求的数据。
在我们的应用程序中,我们可以简单地实现这一点。让我们在/page
端点前面放置一个文件缓存层,如图所示:
func ServePage(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
pageGUID := vars["guid"]
thisPage := Page{}
cached := cache.newCache("page",pageGUID)
前面的代码创建了一个新的CacheItem
。我们利用变量params
生成参考文件名:
func newCache(endpoint string, params ...[]string) CacheItem {
cacheName := endponit + "_" + strings.Join(params, "_")
c := CacheItem{}
return c
}
当我们有CacheItem
对象时,可以使用Get()
方法进行检查,如果缓存仍然有效,则返回true
,否则返回false
。我们利用文件系统信息来确定缓存项是否在其有效生存时间内:
valid, cachedData := cached.Get()
if valid {
thisPage.Content = cachedData
fmt.Fprintln(w, thisPage)
return
}
如果我们通过Get()
方法找到一个已有的项目,我们会检查它是否在集合TTL
中被更新:
func (c CacheItem) Get() (bool, string) {
stats, err := os.Stat(c.Key)
if err != nil {
return false, ""
}
age := time.Nanoseconds() - stats.ModTime()
if age <= c.TTL {
cache, _ := ioutil.ReadFile(c.Key)
return true, cache
} else {
return false, ""
}
}
如果代码有效且在 TTL 内,我们将返回true
并更新文件正文。否则,我们将允许传递到页面检索和生成。最后,我们可以设置缓存数据:
t, _ := template.ParseFiles("templates/blog.html")
cached.Set(t, thisPage)
t.Execute(w, thisPage)
然后将其另存为:
func (c CacheItem) Set(data []byte) bool {
err := ioutil.WriteFile(c.Key, data, 0644)
}
此函数有效地写入缓存文件的值。
我们现在有了一个工作系统,它将接受单个端点和无数查询参数,并创建一个基于文件的缓存库,如果数据没有更改,最终防止对数据库进行不必要的查询。
实际上,我们希望将此限制在大多数基于读取的页面上,并避免在任何写入或更新端点(尤其是在我们的 API 上)上进行盲缓存。
正如由于存储价格暴跌,文件系统缓存变得更受欢迎一样,我们在 RAM 中也看到了类似的变化,紧随硬盘之后。这里最大的优势是速度,显然,内存中的缓存速度非常快。
Memcache 及其分布式兄弟 Memcached 是从Brad Fitzpatrick发展而来的,它需要为 LiveJournal 和原型社交网络创建一个轻快的缓存。如果说这个名字让人耳熟能详,那是因为布拉德现在在谷歌工作,是围棋语言本身的重要贡献者。
作为我们的文件缓存系统的替代品,Memcached 也将起到类似的作用。唯一的主要变化是我们的键查找,这将不利于工作内存,而不是进行文件检查。
要将 memcache 与 Go 语言一起使用,请从Brad Fitz访问godoc.org/github.com/bradfitz/gomemcache/memcache,并使用go get
命令进行安装。
谷歌在过去五年中投资的一项更有趣、或许更高尚的计划是专注于加快网络速度。通过 PageSpeed 等工具,谷歌(Google)[T0]试图推动整个网络更快、更精简、更友好。
毫无疑问,这一举措并不完全是利他主义的。谷歌的业务是建立在广泛的网络搜索上的,而爬虫程序总是受制于他们抓取页面的速度。网页越快,爬行就越快、越全面;因此,更少的时间和更少的基础设施导致所需的资金更少。这里的底线是,一个更快的网站对谷歌有好处,就像它对创建和查看网站的人有好处一样。
但这是互利的。如果网站能够更快地符合谷歌的偏好,那么每个人都会从更快的网站中受益。
这就引出了 HTTP/2,这是 HTTP 的一个版本,它取代了 1999 年推出的 1.1,并且在很大程度上是大多数 Web 的实际方法。HTTP/2 还封装并实现了大量的 SPDY 协议,这是谷歌通过 Chrome 开发和支持的临时协议。
HTTP/2 和 SPDY 引入了大量优化,包括头压缩、非阻塞和多路复用请求处理。
如果您使用的是版本 1.6,net/http
支持 HTTP/2 开箱即用。如果您使用的是 1.5 版或更早版本,则可以使用实验包。
要在 Go 版本 1.6 之前使用 HTTP/2,请从godoc.org/golang.org/x/net/http2获取
在本章中,我们重点讨论了通过减少对底层应用程序的瓶颈(即数据库)的影响来提高应用程序总体性能的速效方法。
我们已经在文件级别实现了缓存,并描述了如何将其转换为基于内存的缓存系统。我们研究了 SPDY 和 HTTP/2,它们现在已经默认成为基础 Gonet/http
包的一部分。
这并不是我们生产高性能代码所需要的所有优化,而是一些最常见的瓶颈,这些瓶颈可以防止在开发中运行良好的应用程序在重载下在生产中表现出类似的行为。
这就是我们结束这本书的地方;希望你们都喜欢这次旅行!