极客时间已完结课程限时免费阅读

32 | 让人又恨又爱的字符串操作

32 | 让人又恨又爱的字符串操作-极客时间

32 | 让人又恨又爱的字符串操作

讲述:温铭

时长10:16大小9.40M

你好,我是温铭。
上节课里,我带你熟悉了 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)
'
希望你积极思考和操作,并在留言区分享你的答案和感想。也欢迎你把这篇文章分享给你的朋友,一起学习和交流。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 1

提建议

上一篇
31 | 性能下降10倍的真凶:阻塞函数
下一篇
33 | 性能提升10倍的秘诀:必须用好 table
unpreview
 写留言

精选留言(10)

  • helloworld
    2019-08-19
    老师,代码在这里,就是用的你文章中的代码: 测试ngx.say(table)的性能比先table.concat拼接,再ngx.say打印拼接后的字符串的性能要低一倍的问题。 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, "") -- 测试1 --ngx.say(s) -- 测试1 用时:0.003000020980835 ngx.say(t) -- 测试 2 用时:0.01200008392334 ngx.update_time() ngx.say(ngx.now() - begin) 这次又测了一遍,貌似低了三四倍。
    展开
    共 1 条评论
    1
  • helloworld
    2019-08-07
    作业题: ngx.log(ngx.ERR, "Hello ", "world", "!") 另外我测试字符串10个a的拼接和1个a的拼接所用时间是一个数量级的,性能基本没变化。 还有一个就是我测试ngx.say(table)的性能比先table.concat拼接,再ngx.say打印拼接后的字符串的性能要低一倍,测几次都是这个结果,这个是什么原因呢
    展开

    作者回复: 能给下你测试的代码吗?

    共 2 条评论
    1
  • 宝仔
    2020-02-03
    思考题:如果新增的字符串是10个a,性能变化不大。但是如果是直接字符串拼接的方式,那性能差距就大了
  • 林中木
    2019-09-10
    温老师,请教一个很困扰的问题: 根据你的指点,要尽量少用 .. 字符串拼接,特别是在代码热区 但是我在处理数据库访问时,需要动态构建sql语句(在语句中插入变量),这应该是非常常见的使用场景,但这个需求,我目前感觉字符串拼接是最简单办法了,其他我真的想不到即简单又高性能的办法。 另外我现在想用OR做一个web项目,但做起来很痛苦啊,主要是没找到成熟的框架,需要自己造很多轮子,就比如说上面的数据库操作问题(没找到可以动态构建sql语句、连贯操作的类库),在web框架上有什么好的可以推荐吗? 等候你的高见! 恳请多多指点。
    展开

    作者回复: 可以先用火焰图或者其他工具分析下,看 sql 语句的拼接是否是系统的瓶颈,如果不是,自然没有优化的必要性。如果是的话,可以利用数据库的 `prepare` 语句来做优化,也可以用数组的方式来做拼接。 OpenResty 现在确实没有好用的框架,lor、香草这两个可以尝试下。

    共 2 条评论
    1
  • HelloBug
    2019-08-18
    温铭老师,发现需要GC 操作的会是比较耗时的,可以介绍下GC操作的场景吗?

    作者回复: 一般是在处理完比较大的对象之后,需要手工 GC 一下。比如字符串对象有几十兆,处理完得到结果之后,手工调用collectgarbage一下。

  • Shliesce
    2019-08-12
    作业题: local t = {} t[1] = "hello " t[2] = "world " t[3] = "!" ngx.log(ngx.ERR, t[1], t[2], t[3])
    展开

    作者回复: ngx.log(ngx.ERR, 'hello', ' world', '!') 是更简单的方式,你可以试下

  • wusiration
    2019-08-07
    作业题1:当字符串由"a"变成"aaaaaaaaaa"时,代码用时0.007000207901001,性能基本没有差别,因为指向的是同一个字符串,只不过是字符串的内容发生了变化; 作业题2: resty -e ' local helloworld = {"Hello", "World", "!"} helloworld = setmetatable(helloworld, { __tostring = function(t) return string.format("%s %s %s", t[1], t[2], t[3]) end }) ngx.log(ngx.WARN, helloworld) '
    展开

    作者回复: ngx.log是可以直接跟变参的,这一点文档中并没有写的很明确: ngx.log(ngx.WARN, 'hello ', 'world', '!') 这种是最简单的。

  • 许童童
    2019-08-07
    新增的字符串是 10 个 a 的长度,个人分析,不会有什么性能差异,因为10个a这个字符串一但分配在内存中,在下一次GC前,都是指向这个相同的内存地址,并没有开辟新的内存空间。
  • helloworld
    2019-08-07
    local ok, err = red:connect("127.0.0.1", 6379) if not ok then ngx.say("failed to connect: ", err) red:close() -- 老师,我看到有写项目代码里在这里加上这句,有必要吗? return end
    展开

    作者回复: 我觉得没必要,都没有连接上,close 也没啥意义。

  • Rye
    2019-08-07
    温老师,听说string.format的消耗大效 率低,不知道具体是什么原因,有什么好的字符串模板方案么?

    作者回复: 我都是用数组的方式自己拼接,比较笨的方法