<audio id="audio" title="32 | 让人又恨又爱的字符串操作" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/33/28/332cb3b45ddd593a265452bfedb46c28.mp3"></audio>

你好，我是温铭。

上节课里，我带你熟悉了 OpenResty 中常见的阻塞函数，它们都是初学者经常犯错的地方。从今天开始，我们就要进入性能优化的核心部分了，这其中会涉及到很多优化的技巧，可以帮助你快速提升 OpenResty 代码的性能，所以千万不要掉以轻心。

在这个过程中，你需要多写一些测试代码，来体会这些优化技巧如何使用，并验证它们的有效性，做到心中有数，拿来即用。

## 性能优化技巧的背后

优化技巧都是属于“术”的部分，在此之前，我们不妨先来聊一下优化之“道”。

性能优化的技巧，会随着 LuaJIT 和 OpenResty 的版本迭代而发生变化，一些技巧可能直接被底层技术优化，不再需要我们掌握；同时，也另会有一些新的优化技巧产生。所以，掌握这些优化技巧背后的不变的理念，才是最为重要的。

下面，让我们先来看下，在 OpenResty 编程中，有关性能方面的几个重要理念。

### 理念一：处理请求要短、平、快

OpenResty 是一个 Web 服务器，所以经常会同时处理几千、几万甚至几十万的终端请求。想要在整体上达到最高性能，我们就一定要保证单个请求被快速地处理完成，并回收内存等各种资源。

- 这里提到的“短”，是指请求的生命周期要短，不要长时间占用资源而不释放；即使是长连接，也要设定一个时间或者请求次数的阈值，来定期地释放资源。
- 第二个字“平”，则是指在一个 API 中只做一件事情。要把复杂的业务逻辑拆散为多个 API，保持代码的简洁。
- 最后的“快”，是指不要阻塞主线程，不要有大量 CPU 运算。即使是不得不有这样的逻辑，也别忘了咱们上节课介绍的方法，要配合其他的服务去完成。

其实，这种架构上的考虑，不仅适合 OpenResty，在其他的开发语言和平台上也都是适用的，希望你能认真理解和思考。

### 理念二：避免产生中间数据

避免中间的无用数据，可以说是 OpenResty 编程中最为主要的优化理念。这里，我先给你举一个小例子，来讲解下什么是中间的无用数据。我们来看下面这段代码：

```
$ resty -e 'local s= &quot;hello&quot;
s = s .. &quot; world&quot;
s = s .. &quot;!&quot;
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 = &quot;&quot;
-- for 循环，使用 .. 进行字符串拼接
for i = 1, 100000 do
    s = s .. &quot;a&quot;
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] = &quot;a&quot;
end
-- 使用数组的 concat 方法拼接字符串
local s =  table.concat(t, &quot;&quot;)
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] = &quot;a&quot;
end
local s =  table.concat(t, &quot;&quot;)
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] = &quot;a&quot;
    index = index + 1
end
local s = table.concat(t, &quot;&quot;)
ngx.update_time()
print(ngx.now() - begin)
'

```

## 减少其他临时字符串

刚刚我们所讲的，字符串拼接造成的临时字符串，还是显而易见的，通过上面几个示例代码的提醒，相信你就不会再犯类似的错误了。但是，OpenResty 中还存在着一些更隐蔽的临时字符串的产生，它们就更不容易被发现了。比如下面我将讲到的这个字符串处理函数，是经常被用到的，你能想到它也会生成临时的字符串吗？

我们知道，`string.sub` 函数的作用是截取字符串的指定部分。正如我们前面所提到的，Lua 中的字符串是不可变的，那么截取出来的新字符串，就会涉及到 `lj_str_new` 和后续的 GC 操作。

```
resty -e 'print(string.sub(&quot;abcd&quot;, 1, 1))'

```

上面这段代码的作用，是获取字符串的第一个字符，并打印出来。自然，它不可避免会生成临时字符串。要完成同样的效果，还有别的更好的办法吗？

```
resty -e 'print(string.char(string.byte(&quot;abcd&quot;)))'

```

自然如此。看第二段代码，我们先用 `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] = &quot;a&quot;
    index = index + 1
end
local response = table.concat(t, &quot;&quot;)
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] = &quot;a&quot;
    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] = &quot;a&quot;
end
local s =  table.concat(t, &quot;&quot;)
ngx.update_time()
print(ngx.now() - begin)
'

```

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


