You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

206 lines
11 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 32 | 让人又恨又爱的字符串操作
你好,我是温铭。
上节课里,我带你熟悉了 OpenResty 中常见的阻塞函数,它们都是初学者经常犯错的地方。从今天开始,我们就要进入性能优化的核心部分了,这其中会涉及到很多优化的技巧,可以帮助你快速提升 OpenResty 代码的性能,所以千万不要掉以轻心。
在这个过程中,你需要多写一些测试代码,来体会这些优化技巧如何使用,并验证它们的有效性,做到心中有数,拿来即用。
## 性能优化技巧的背后
优化技巧都是属于“术”的部分,在此之前,我们不妨先来聊一下优化之“道”。
性能优化的技巧,会随着 LuaJIT 和 OpenResty 的版本迭代而发生变化,一些技巧可能直接被底层技术优化,不再需要我们掌握;同时,也另会有一些新的优化技巧产生。所以,掌握这些优化技巧背后的不变的理念,才是最为重要的。
下面,让我们先来看下,在 OpenResty 编程中,有关性能方面的几个重要理念。
### 理念一:处理请求要短、平、快
OpenResty 是一个 Web 服务器,所以经常会同时处理几千、几万甚至几十万的终端请求。想要在整体上达到最高性能,我们就一定要保证单个请求被快速地处理完成,并回收内存等各种资源。
* 这里提到的“短”,是指请求的生命周期要短,不要长时间占用资源而不释放;即使是长连接,也要设定一个时间或者请求次数的阈值,来定期地释放资源。
* 第二个字“平”,则是指在一个 API 中只做一件事情。要把复杂的业务逻辑拆散为多个 API保持代码的简洁。
* 最后的“快”,是指不要阻塞主线程,不要有大量 CPU 运算。即使是不得不有这样的逻辑,也别忘了咱们上节课介绍的方法,要配合其他的服务去完成。
其实,这种架构上的考虑,不仅适合 OpenResty在其他的开发语言和平台上也都是适用的希望你能认真理解和思考。
### 理念二:避免产生中间数据
避免中间的无用数据,可以说是 OpenResty 编程中最为主要的优化理念。这里,我先给你举一个小例子,来讲解下什么是中间的无用数据。我们来看下面这段代码:
```
$ resty -e 'local s= "hello"
s = s .. " world"
s = s .. "!"
print(s)
'
```
这段代码,我们对`s` 这个变量做了多次拼接操作,才得到了`hello world!` 对结果。但很显然,只有 `s` 的最终状态,也就是 `hello world!` 这个状态是有用的。而 `s` 的初始值和中间的赋值,都属于中间数据,应该尽量少生成。
因为这些临时数据,会带来初始化和 GC 的性能损耗。不要小看这些损耗,如果这出现在循环等热代码中,就会带来非常明显的性能下降了。稍后我也会用字符串的示例来讲解这一点。
## 字符串是不可变的!
现在,回到本节课的主题——字符串。这里,我着重强调,**在 Lua 中,字符串是不可变的**。
当然,这并不是说字符串不能做拼接、修改等操作,而是想告诉你,在你修改一个字符串的时候,其实并没有改变原来的字符串,而是产生了一个新的字符串对象,并改变了对字符串的引用。自然,如果原有字符串没有其他的任何引用,就会给 Lua 的 GC 给回收掉。
字符串不可变的好处显而易见,那就是节省内存。这样一来,同样内容的字符串在内存中就只有一份了,不同的变量都会指向同一个内存地址。
至于这样设计的缺点,那就是涉及到字符串的新增和 GC时每当你新增一个字符串LuaJIT 都得调用 `lj_str_new`,去查询这个字符串是否已经存在;没有的话,便需要再创建新的字符串。如果操作很频繁,自然就会对性能有非常大的影响。
我们来看一个具体的例子,类似这个例子中的字符串拼接操作,在很多 OpenResty 的开源项目中都会出现:
```
$ resty -e 'local begin = ngx.now()
local s = ""
-- for 循环,使用 .. 进行字符串拼接
for i = 1, 100000 do
s = s .. "a"
end
ngx.update_time()
print(ngx.now() - begin)
'
```
这段示例代码的作用,是对`s` 变量做十万次字符串拼接,并把运行时间打印出来。虽然例子有些极端,但却能很好地体现出性能优化前后的差异。未经优化时,这段代码在我的笔记本上跑了 0.4 秒钟,还是比较慢的。那么应该如何优化呢?
在前面的课程里,我其实已经给出了答案,那就是使用 table 做一层封装,去掉所有临时的中间字符串,只保留原始数据和最终结果。我们来看下具体的代码实现:
```
$ resty -e 'local begin = ngx.now()
local t = {}
-- for 循环,使用数组来保存字符串,每次都计算数组长度
for i = 1, 100000 do
t[#t + 1] = "a"
end
-- 使用数组的 concat 方法拼接字符串
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
```
你可以看到,我用 table 依次保存了每一个字符串,下标由 `#t + 1` 来决定,也就是用 table 的当前长度加 1最后使用 `table.concat` 函数,把数组的每一个元素进行拼接,直接得到最终结果。这样自然就跳过了所有的临时字符串,避免了 10 万次 `lj_str_new` 和 GC。
刚刚是我们对于代码的分析,那么优化的具体效果如何呢?很明显,优化后的代码耗时只有 0.007 秒,也就是说,性能提升了五十多倍。事实上,在实际的项目中,性能提升可能会更加明显,因为在这个示例中,我们每次只新增了一个字符 `a`
如果新增的字符串,是 10 个 `a` 的长度,性能差异会有多大呢?这是留给你的一个作业题,欢迎在留言中分享你运行的结果。
回到我们的优化工作上,刚刚这段 0.007 秒的代码,是否就已经足够好了呢?其实不然,它还有继续优化的空间。我们不妨再来修改一行代码,然后来看下效果:
```
$ resty -e 'local begin = ngx.now()
local t = {}
-- for 循环,使用数组来保存字符串,自己维护数组的长度
for i = 1, 100000 do
t[i] = "a"
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
```
这次,我把 `t[#t + 1] = "a"` ,改为了 `t[i] = "a"`,只修改了这么一行代码,却就可以避免十万次获取数组长度的函数调用。还记得我们之前在 table 章节中,提到的获取数组长度的操作吗?它的时间复杂度是 O(n),显然是一个比较昂贵的操作。所以,这里我们干脆自己维护数组下标,绕过了这个获取数组长度的操作。正所谓,惹不起就躲着走呗。
当然,这是比较简化的写法。我写的下面这段代码,则更加清楚地说明了,如何自己来维护数组下标,你可以参照理解:
```
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
```
## 减少其他临时字符串
刚刚我们所讲的字符串拼接造成的临时字符串还是显而易见的通过上面几个示例代码的提醒相信你就不会再犯类似的错误了。但是OpenResty 中还存在着一些更隐蔽的临时字符串的产生,它们就更不容易被发现了。比如下面我将讲到的这个字符串处理函数,是经常被用到的,你能想到它也会生成临时的字符串吗?
我们知道,`string.sub` 函数的作用是截取字符串的指定部分。正如我们前面所提到的Lua 中的字符串是不可变的,那么截取出来的新字符串,就会涉及到 `lj_str_new` 和后续的 GC 操作。
```
resty -e 'print(string.sub("abcd", 1, 1))'
```
上面这段代码的作用,是获取字符串的第一个字符,并打印出来。自然,它不可避免会生成临时字符串。要完成同样的效果,还有别的更好的办法吗?
```
resty -e 'print(string.char(string.byte("abcd")))'
```
自然如此。看第二段代码,我们先用 `string.byte` 获取到第一个字符的数字编码,再用 `string.char` 把数字转为对应的字符。这个过程中并没有生成任何临时的字符串。因此,使用 `string.byte` 来完成字符串相关的扫描和分析,是效率最高的。
## 利用 SDK 对 table 类型的支持
学会了减少临时字符串的方法后,你是不是跃跃欲试了呢?我们可以把上面示例代码的结果,作为响应体的内容输出给客户端。到这里,你可以暂停一下,先自己动手试着写写这段代码。
```
$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
local response = table.concat(t, "")
ngx.say(response)
'
```
能写出这段代码,你就已经超越了绝大部分 OpenResty 的开发者了。不过不要骄傲你依然有进步的空间。OpenResty 的 Lua API ,已经考虑到了这种利用 table 来做字符串拼接的情况,所以,在 `ngx.say`、`ngx.print` 、`ngx.log`、`cosocket:send` 等这些可能接受大量字符串的 API 中,它不仅接受 string 作为参数,也同时接受 table 作为参数:
```
resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
t[index] = "a"
index = index + 1
end
ngx.say(t)
'
```
在最后这段代码中,我们省略掉了 `local response = table.concat(t, "")` 这个字符串拼接的步骤,直接把 table 传给了 `ngx.say`。这样,就把字符串拼接的任务,从 Lua 层面转移到了 C 层面,又避免了一次字符串的查找、生成和 GC。对于比较长的字符串而言这又是一次不小的性能提升。
## 写在最后
学完这节课你应该也发现了OpenResty 的性能优化,很多都是在抠各种细节。所以,你需要对 LuaJIT 和 OpenResty 的 Lua API 了如指掌,才能达到最优的性能。这也提醒你,前面的内容如果有遗忘了,一定要及时复习巩固了。
最后,给你留一个作业题。我要求把 hello、world和感叹号这三个字符串写到错误日志中。你能写出一个不用字符串拼接的示例代码吗
另外,别忘了文中的另一个作业题,在下面的代码中,如果新增的字符串是 10 个 `a` 的长度,性能差异会有多大呢?
```
$ resty -e 'local begin = ngx.now()
local t = {}
for i = 1, 100000 do
t[#t + 1] = "a"
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'
```
希望你积极思考和操作,并在留言区分享你的答案和感想。也欢迎你把这篇文章分享给你的朋友,一起学习和交流。