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.sayngx.printngx.logcosocket: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)
'

希望你积极思考和操作,并在留言区分享你的答案和感想。也欢迎你把这篇文章分享给你的朋友,一起学习和交流。