最近做了一个基于天猫精灵的智能音箱技能开发,上线之后,用户反馈响应很慢,有时候要等好几秒才能得到响应,体验很差。我花了一周时间,做了一系列的性能调优,最终把平均响应时间从原来的2.5秒降到了0.5秒,降了80%,用户体验大大提升。

今天就来分享一下这次性能调优的过程和经验,包括问题定位、优化思路、具体的优化措施、踩过的坑,以及最终的效果,希望能给做智能音箱开发或者类似IoT后端开发的朋友一些参考。

一、项目背景和问题描述

先说说项目背景吧。我们做的是一个基于天猫精灵的智能音箱技能,用户可以通过语音和音箱交互,查询信息、控制设备、玩游戏等。后端是用Node.js写的,部署在阿里云的ECS上,数据库用的是MySQL,缓存用的是Redis,整体架构不复杂。

上线之后,一开始用户量不大,感觉还可以,但是随着用户量的增长,越来越多的用户反馈响应很慢,有时候问一句话,要等好几秒才能得到回答,甚至有时候会超时,音箱提示"网络不太好,请稍后再试",体验很差。

我们看了监控,发现平均响应时间确实很长,平均2.5秒,P99甚至达到了5秒以上,而天猫精灵的超时时间是8秒,虽然大部分请求不会超时,但是2.5秒的响应时间,对于语音交互来说,确实太慢了,用户说完话,要等两秒多才听到回答,感觉很卡顿,体验很差。

领导很重视这个问题,让我负责性能调优,要求把响应时间降到1秒以内。我接了这个任务,开始了为期一周的性能调优工作。

二、第一步:性能监控和问题定位

做性能调优,第一步不是上来就改代码,而是要先做性能监控,找到瓶颈在哪里,定位问题,然后针对性地优化,不然就是瞎改,可能改了半天,效果不大,甚至越改越差。

我首先做的就是加性能监控,在代码的关键节点加埋点,记录每个环节的耗时,比如:

  • 接收请求到解析参数的时间
  • 调用自然语言理解(NLU)的时间
  • 数据库查询的时间
  • 缓存读取的时间
  • 调用第三方接口的时间
  • 业务逻辑处理的时间
  • 组装响应和返回的时间

然后,我用了APM(应用性能监控)工具,比如阿里云的ARMS,还有Node.js的性能分析工具,比如clinic.js、0x等,对应用进行性能分析,看看CPU、内存、事件循环延迟等情况。

加了监控之后,跑了几天,收集了大量的数据,分析之后,发现了几个主要的瓶颈:

1. 第三方接口调用慢,占了大部分时间

这是最大的瓶颈,我们的技能需要调用好几个第三方接口,比如天气查询、新闻查询、股票查询等,这些第三方接口的响应时间很慢,平均要1秒多,有的甚至要2秒,而且不稳定,有时候快有时候慢,拖慢了整个响应时间。

而且,这些第三方接口调用,很多是串行的,比如先调天气,再调新闻,再调股票,一个接一个,加起来就3秒多了,这是最大的问题。

2. 数据库查询慢,没有用好索引

第二个瓶颈是数据库查询,我们的数据库有一些查询很慢,特别是一些复杂的查询,关联了好几个表,没有用好索引,一次查询要几百毫秒,甚至1秒多。而且,有些查询可以用缓存的,但是没有用,每次都查数据库,增加了数据库的压力,也拖慢了响应时间。

3. 缓存命中率低,缓存策略不合理

第三个瓶颈是缓存,我们虽然用了Redis做缓存,但是缓存策略不合理,很多可以缓存的数据没有缓存,或者缓存时间太短,导致缓存命中率很低,只有30%左右,大部分请求还是要查数据库或者调用第三方接口,没有起到缓存的作用。

而且,缓存的key设计也不合理,有些key太复杂,有些key没有考虑到参数的变化,导致缓存失效频繁,命中率低。

4. 代码层面的问题,同步阻塞、重复计算等

第四个瓶颈是代码层面的问题,Node.js是单线程事件驱动的,但是我们的代码里有一些同步阻塞的操作,比如同步读文件、同步加密解密、大的JSON序列化反序列化等,这些会阻塞事件循环,导致所有请求都变慢。

还有一些重复计算,比如同一个数据,在一次请求里计算了好几次,没有复用;还有一些不必要的日志输出,大量的debug日志,也会影响性能。

5. 服务器配置和部署的问题

第五个瓶颈是服务器配置和部署的问题,我们的应用部署在一台2核4G的ECS上,随着用户量的增长,CPU和内存使用率都很高,经常达到80%以上,有时候甚至100%,服务器资源不够,也会导致响应变慢。

而且,我们只部署了一个实例,没有做负载均衡,也没有做多实例部署,所有请求都打到这一台服务器上,压力很大,也没有高可用,服务器挂了就全挂了。

找到了这几个主要的瓶颈之后,我就开始针对性地优化,一个一个来,先优化影响最大的,再优化影响小的。

三、优化措施一:第三方接口调用优化(效果最明显)

第三方接口调用慢,是最大的瓶颈,占了响应时间的60%以上,所以我首先优化这个。

1. 串行改并行,用Promise.all并发调用

原来的代码,第三方接口调用是串行的,一个接一个,比如:

const weather = await getWeather(city);
const news = await getNews();
const stock = await getStock(code);

这样,三个接口加起来就是3秒多。我改成了并行调用,用Promise.all,三个接口同时调用:

const [weather, news, stock] = await Promise.all([
    getWeather(city),
    getNews(),
    getStock(code)
]);

这样,三个接口同时调用,总耗时就是最慢的那个接口的时间,从3秒多降到了1秒多,效果非常明显。

当然,不是所有接口都能并行,有些接口有依赖关系,比如第二个接口需要第一个接口的返回值,这种就不能并行,只能串行。但是对于没有依赖关系的接口,一定要并行,能大大缩短响应时间。

2. 加缓存,第三方接口结果缓存

第三方接口调用慢,而且很多结果是可以缓存的,比如天气,半个小时内的天气是一样的,不用每次都调接口;新闻,5分钟内的新闻是一样的;股票,实时性要求高一点,但是也可以缓存几秒。

我给这些第三方接口的结果都加了缓存,用Redis缓存,根据接口的实时性要求,设置不同的缓存时间:

  • 天气:缓存30分钟
  • 新闻:缓存5分钟
  • 股票:缓存10秒
  • 其他不常变的数据:缓存1小时甚至1天

加了缓存之后,大部分请求都直接从缓存取数据,不用调第三方接口了,响应时间大大缩短,而且也减轻了第三方接口的压力,减少了因为第三方接口不稳定导致的失败。

3. 加超时和降级,避免第三方接口拖垮整个应用

第三方接口不稳定,有时候会很慢,甚至超时,如果不做处理,一个慢的第三方接口,会拖慢整个请求,甚至拖垮整个应用,因为Node.js是单线程的,一个请求卡住了,会影响其他请求。

我给所有的第三方接口调用都加了超时时间,比如设置超时时间为500毫秒,超过500毫秒就认为超时,不再等了,直接返回降级结果,比如默认值、缓存的旧值、或者提示用户"暂时无法查询,请稍后再试"。

加了超时和降级之后,即使第三方接口慢或者挂了,也不会影响整个应用的响应时间,最多就是这个功能用不了,其他功能还是正常的,用户体验好了很多。

4. 接口预取和预热,提前调用接口

对于一些常用的接口,比如热门城市的天气、热门新闻,我做了预取和预热,用定时任务,提前调用这些接口,把结果缓存起来,用户请求的时候,直接从缓存取,不用等接口调用,响应时间几乎为0。

比如,我用一个定时任务,每10分钟调用一次全国主要城市的天气,把结果缓存起来,用户查询这些城市的天气的时候,直接从缓存取,非常快。新闻也是,每5分钟拉一次最新新闻,缓存起来,用户请求的时候直接返回。

预取和预热,对于热点数据非常有效,能大大降低响应时间,减轻第三方接口的压力。

通过这几个优化,第三方接口调用的平均时间从1.5秒降到了0.2秒,效果非常明显,这也是整个性能调优中,效果最明显的一部分。

四、优化措施二:数据库优化

第二个瓶颈是数据库查询慢,我做了以下优化:

1. 加索引,优化慢查询

首先是加索引,我把数据库的慢查询日志打开,找出所有执行时间超过100毫秒的SQL,然后一个一个分析,看是不是没有索引,或者索引没用对,然后加上合适的索引。

比如,有一个查询,是根据用户ID查询用户的历史记录,原来没有加索引,每次都是全表扫描,要几百毫秒,加了索引之后,只要几毫秒,提升非常明显。

还有一些复杂的关联查询,关联了好几个表,我分析了执行计划,给关联字段和查询条件都加上了合适的索引,查询速度大大提升。

加索引是数据库优化最基本也是最有效的手段,但是要注意,索引不是越多越好,索引会增加写的开销,也会占用存储空间,要根据查询场景,加合适的索引,不要盲目加索引。

2. 优化SQL语句,避免复杂查询和全表扫描

除了加索引,我还优化了SQL语句,把一些复杂的查询拆分成简单的查询,避免多表关联和子查询,因为复杂的查询,即使加了索引,也可能很慢,而且不好维护。

比如,原来有一个查询,关联了5个表,还有子查询,执行要1秒多,我把它拆成了3个简单的查询,先查主表,再根据主表的结果查关联表,然后在代码里组装数据,虽然查询次数多了,但是每个查询都很快,加起来反而比原来的复杂查询快,而且代码更清晰,更好维护。

还有一些查询,用了SELECT *,查询了所有字段,但是实际上只用到了几个字段,我改成了只查询需要的字段,减少了数据传输,也提高了查询速度。

还有一些查询,用了LIKE '%xxx%'前置模糊查询,这种用不了索引,会全表扫描,我根据业务场景,改成了后置模糊查询LIKE 'xxx%',或者用全文索引,或者用搜索引擎(比如Elasticsearch)来做模糊查询,大大提升了查询速度。

3. 数据库连接池优化

原来的数据库连接池配置不合理,连接数太少,只有10个连接,并发高的时候,请求要等待数据库连接,导致响应变慢。我把连接池的连接数调整到了合适的大小,根据服务器的配置和数据库的处理能力,设置为50个连接,同时设置了合理的空闲超时、连接存活时间等参数,避免连接泄漏和连接超时。

调整了连接池之后,数据库连接的等待时间大大减少,并发处理能力提升了很多。

4. 读写分离,主从复制

我们的数据库是单机的,读和写都在一个库上,随着用户量的增长,数据库的压力越来越大,读请求很多,影响了写请求的性能。我做了读写分离,搭建了主从复制,主库负责写,从库负责读,读请求都打到从库上,大大减轻了主库的压力,读的性能也提升了很多。

读写分离对于读多写少的应用,效果非常明显,我们的应用就是读多写少,大部分都是查询,所以做了读写分离之后,数据库的整体性能提升了很多。

通过这几个优化,数据库查询的平均时间从300毫秒降到了30毫秒,效果也很明显。

五、优化措施三:缓存优化

第三个瓶颈是缓存命中率低,我做了以下优化:

1. 合理设计缓存key,提高缓存命中率

原来的缓存key设计不合理,有些key太复杂,包含了很多不必要的参数,导致缓存命中率低。我重新设计了缓存key,只包含影响结果的必要参数,去掉了不必要的参数,比如用户的设备信息、请求ID等,这些不影响结果的参数,不要放到缓存key里,不然每个请求的key都不一样,缓存根本命中不了。

比如,查询天气的缓存key,原来包含了用户ID、设备类型、请求时间等,这些都不影响天气结果,我改成了只包含城市和日期,这样同一个城市同一天的天气,所有用户都共享同一个缓存,命中率大大提升。

2. 合理设置缓存时间,根据数据的实时性要求

原来的缓存时间设置不合理,有些不常变的数据,缓存时间太短,导致频繁失效;有些实时性要求高的数据,缓存时间太长,导致数据不新鲜。我根据数据的实时性要求,重新设置了缓存时间:

  • 基本不变化的数据(比如用户基本信息、配置信息):缓存1天甚至7天
  • 变化不频繁的数据(比如文章列表、分类信息):缓存1小时
  • 有一定实时性要求的数据(比如新闻、天气):缓存5-30分钟
  • 实时性要求高的数据(比如股票、实时状态):缓存几秒或者不缓存

合理设置缓存时间,既能保证数据的新鲜度,又能提高缓存命中率,减轻后端压力。

3. 缓存预热和热点数据缓存

对于热点数据,我做了缓存预热,用定时任务提前把热点数据加载到缓存里,避免缓存失效的时候,大量请求同时打到数据库,导致数据库压力过大,也就是"缓存雪崩"。

比如,热门文章、热门城市的天气、热门新闻等,我都做了缓存预热,提前加载到缓存里,并且在缓存快失效的时候,提前刷新缓存,保证缓存一直有效,不会出现大量请求同时打数据库的情况。

4. 缓存穿透和缓存击穿的处理

我还做了缓存穿透和缓存击穿的处理:

  • 缓存穿透:查询不存在的数据,缓存里没有,每次都查数据库,我用了布隆过滤器,或者把不存在的结果也缓存起来(缓存空值),避免频繁查数据库。
  • 缓存击穿:热点key失效的时候,大量请求同时打数据库,我用了互斥锁,只让一个请求去查数据库,其他请求等待,或者用了逻辑过期,缓存不设置物理过期,只设置逻辑过期,发现逻辑过期了,异步去更新缓存,不影响读请求。

通过这几个优化,缓存命中率从原来的30%提升到了85%以上,大部分请求都直接从缓存取数据,不用查数据库和调第三方接口,响应时间大大缩短。

六、优化措施四:代码层面优化

第四个瓶颈是代码层面的问题,我做了以下优化:

1. 去掉同步阻塞操作,改成异步

Node.js是单线程事件驱动的,同步阻塞操作会阻塞事件循环,影响所有请求的性能。我把代码里的同步阻塞操作都改成了异步,比如同步读文件改成异步读文件,同步加密解密改成异步,或者用worker_threads放到子线程里处理,避免阻塞主线程。

比如,原来有一个同步加密的操作,每次要几十毫秒,会阻塞事件循环,我改成了异步加密,或者用缓存,把加密结果缓存起来,不用每次都加密,大大减少了阻塞时间。

2. 减少重复计算,复用计算结果

我发现代码里有很多重复计算,同一个数据,在一次请求里计算了好几次,比如用户信息,在好几个地方都查询了一次,我把这些重复计算都去掉了,计算一次之后,把结果存起来,后面直接用,不用重复计算。

比如,用户信息,在请求开始的时候查询一次,存到请求上下文里,后面需要用的时候,直接从上下文取,不用再查数据库,大大减少了数据库查询和计算时间。

3. 减少不必要的日志输出

原来的代码里有大量的debug日志,每个请求都输出很多日志,包括请求参数、返回结果、中间变量等,日志量很大,写日志也会影响性能,特别是同步写日志,会阻塞事件循环。

我把日志级别调整了,生产环境只输出info和warn以上级别的日志,去掉了不必要的debug日志,并且把日志改成了异步写,用winston等日志库,异步写日志,不阻塞事件循环。

调整了日志之后,不仅性能提升了,磁盘空间也省了很多,日志也更清晰了,排查问题更容易了。

4. 代码逻辑优化,简化复杂逻辑

我还对一些复杂的业务逻辑做了优化,简化了逻辑,去掉了不必要的判断和循环,提高了代码的执行效率。比如,有些嵌套了好几层的if-else,我改成了提前返回,或者用策略模式,简化了逻辑,提高了执行效率,也提高了代码的可读性和可维护性。

还有一些循环,原来用了for循环嵌套,时间复杂度很高,我改成了用Map或者Object来做映射,把时间复杂度从O(n²)降到了O(n),大大提升了执行效率。

通过这几个优化,代码层面的执行时间从原来的200毫秒降到了50毫秒,效果也不错。

七、优化措施五:服务器和部署优化

第五个瓶颈是服务器配置和部署的问题,我做了以下优化:

1. 升级服务器配置

原来的服务器是2核4G的,随着用户量的增长,资源不够用了,我把服务器升级到了4核8G的,CPU和内存都翻倍了,服务器的处理能力大大提升,响应时间也缩短了。

当然,升级服务器是最简单粗暴的方式,成本也会增加,但是在用户量增长的情况下,适当升级服务器是必要的,不能只靠优化代码,硬件资源也是很重要的。

2. 多实例部署+负载均衡

原来只部署了一个实例,所有请求都打到这一个实例上,压力很大,也没有高可用。我做了多实例部署,部署了3个实例,前面用Nginx做负载均衡,把请求分发到不同的实例上,每个实例的压力都小了很多,整体的并发处理能力也提升了很多,而且有了高可用,一个实例挂了,其他实例还能正常工作,不会全挂。

多实例部署+负载均衡,对于提升并发处理能力和高可用,效果非常明显,是生产环境必备的。

3. Node.js进程管理,用PM2集群模式

Node.js是单线程的,一个进程只能用一个CPU核心,为了充分利用多核CPU,我用了PM2的集群模式,启动了和CPU核心数相同的进程,每个进程处理一部分请求,充分利用多核CPU的性能,大大提升了并发处理能力。

PM2还提供了进程守护、自动重启、日志管理、监控等功能,非常方便,是Node.js生产环境部署的必备工具。

4. 静态资源CDN加速

虽然我们的应用主要是接口,但是也有一些静态资源,比如图片、文档等,我把这些静态资源都放到了CDN上,用CDN加速,用户访问静态资源的时候,直接从最近的CDN节点获取,不用打到我们的服务器,减轻了服务器的压力,也提升了静态资源的访问速度。

通过这几个优化,服务器的处理能力大大提升,并发量从原来的100QPS提升到了500QPS以上,而且响应时间也更稳定了。

八、优化效果和总结

经过一周的性能调优,做了以上这些优化措施,最终的效果非常明显:

  • 平均响应时间:从2.5秒降到了0.5秒,降了80%
  • P99响应时间:从5秒降到了1.2秒
  • 缓存命中率:从30%提升到了85%
  • 并发处理能力:从100QPS提升到了500QPS
  • 错误率:从2%降到了0.1%以下

用户反馈响应快了很多,再也没有卡顿和超时的情况了,体验大大提升,领导也很满意。

总结一下这次性能调优的经验:

1. 做性能调优,先监控定位,再针对性优化,不要瞎改。 一定要先找到瓶颈在哪里,然后针对性地优化,不然改了半天,可能效果不大,甚至越改越差。

2. 第三方接口调用,往往是最大的瓶颈,要重点优化。 串行改并行、加缓存、加超时降级、预取预热,这些都是很有效的优化手段。

3. 数据库优化,加索引是最基本也是最有效的,但是要合理加,不要盲目加。 同时要优化SQL语句,避免复杂查询和全表扫描,读写分离、连接池优化也很重要。

4. 缓存是性能优化的利器,合理使用缓存,能大大提升性能。 但是要注意缓存的设计,key要合理,时间要合适,还要处理缓存穿透、缓存击穿、缓存雪崩等问题。

5. 代码层面的优化也不能忽视,同步阻塞、重复计算、大量日志、复杂逻辑,都会影响性能。 要养成写高性能代码的习惯,从细节处优化。

6. 服务器和部署优化,是提升整体性能和高可用的保障。 适当升级服务器、多实例部署、负载均衡、进程管理、CDN加速,这些都是生产环境必备的。

7. 性能优化是一个持续的过程,不是一次就能搞定的。 要持续监控,持续优化,随着用户量的增长和业务的变化,不断调整优化策略。

九、写在最后

智能音箱天猫精灵性能调优:我把响应时间降了80%。

这次性能调优,虽然只花了一周时间,但是学到了很多,也积累了很多经验。性能优化是后端开发的基本功,也是很有挑战性的工作,看到响应时间一点点降下来,用户体验一点点提升,还是很有成就感的。

很多人做性能优化,一上来就改代码,加机器,其实这是不对的,做性能优化,第一步一定是监控和定位,找到瓶颈在哪里,然后针对性地优化,这样才能事半功倍,用最小的代价,获得最大的提升。

而且,性能优化不是一劳永逸的,是一个持续的过程,要持续监控,持续优化,随着业务的发展和用户量的增长,不断调整优化策略,才能保证系统一直保持良好的性能。

希望我的这次性能调优经验,能给大家一些参考和启发,大家在做性能优化的时候,少走弯路,少踩坑,用最小的代价,获得最大的提升。

最后,用一句话结尾:"性能优化,没有最好,只有更好;没有银弹,只有持续监控、持续优化。"愿我们都能写出高性能的代码,搭建高性能的系统,给用户最好的体验。