背景: 答主22年年底硕士毕业入职,23年6月转正,到23年年底,恰好全职工作一周年。答主日常工作主要是是公司IAM系统的开发,也就是和用户的认证,登录, 各种密码学操作相关。23年10-12月接连做了好几个性能优化的需求,将多个接口的TPS性能提高了100%以上, 特此分享我是如何进行性能优化的。文章串联了我之前写的多篇文章,也算是对我这一年掘金写作之路的回顾,包括分布式系统,go语言并发编程,缓存的选型和数据一致性考量,各种开发/测试/运维工具的使用。毕竟答主仅有一年工作经验,如有错误欢迎大家指出讨论。
永远不打没有准备的仗
使用 wrk/ jmeter 压测性能指标
在优化之前就得摸清楚目前的性能指标是多少(TPS,QPS), 以及性能瓶颈在哪里。如果是简单的HTTP接口,可以使用WRK进行压测,如果是复杂场景,需要对多个grpc/http接口 进行级联压测,可以使用 jmeter.wrk 可以参考我的另一篇文章(使用wrk对http接口进行压测并计算其TPS),jmeter我也会在不久后出一篇帖子。
使用 pprof 定位性能瓶颈
确定好现有的性能指标后就得精确的找到性能瓶颈,可以使用pprof生成火焰图或者trace图,看看到底是哪里耗时长,哪里链路调用复杂。pprof生成火焰图和trace图也可以看我的另一篇文章(使用pprof进行性能卡点分享生成 trace 图与火焰图)
一些常见的极其耗时操作
- SQL查询(表记录较多,没有索引或者索引失效,常用联系的两种表为建立关联表)
- 密码学操作,如使用1024位或者更长位的rsa秘钥进行签名或签名
- 跨服务请求,频繁调用http(s)或rpc请求,
梳理业务逻辑,简化业务流程
减少SQL查询操作
由于历史原因(业务逐步复杂,或者人员交接),通常很多业务的代码是由冗余的,最常见的就是重复查询。
比如登录账号密码的过程,就有至少要查询4类信息,分别是查询获取用户登录别名(如果支持别名登录),查询用户密码状态(是否为初始密码),获查询用户盐值用于将名为密码哈希,查询服务端公钥用于非对称加密。
如果4类信息如果分为分为4个接口查询,则会至少需要4次http/RPC调用和4次数据库查询。这时候就需要合并接口的数量,争取一次性查询出全部结果并且缓存下来。
此外,IAM系统的token也经常在短时间内被多个服务端调用。token 通常是 jwt 格式的(什么是jwt, jwt的签发和验签可以查看我的另一篇文章使用jwt-go实现jwt签名与验签), 里面包含了用户的ID或者是账户名,服务端用自己签发的token中的用户ID或者是账户到数据库查询用户信息(邮箱,手机号,职位等等)。这里token换取的用户信息通常和密码状态,盐值,密码状态通常是一张表存储的,可以一并缓存下来。关于缓存的选型,我会在后面梳理
减少接口调用
随着业务的复杂,接口会越来越多,许多时候需要级联调用多个接口,但很多情况下这些接口的调用顺序是无所谓的,这时就可以将多个接口进行合并,将多次请求合并成一个请求。
该上缓存上缓存
分布式缓存 or 本地缓存
缓存大分两种,分布式缓存 和 本地缓存。分布式缓存最常用的也有两种,etcd 和 Redis
分布式缓存和本地缓存的使用场景区别还是挺大的。如果需要数据在所有机器上面共享,就选分布式缓存,如果数据不需要多台机器共享就本地缓存。
本地缓存使用场景
比如我上面说的和用户信息相关的,就应该选本地缓存,在负载均衡使用client hash算法的情况下,同一个客户端的请求一定是打到同一台机器上,所以没有必要将同一份数据缓存到所有机器上
缓存预估 与 缓存预热
选择本地缓存,最需要考虑的是两个方面
- 一数据规模,避免长时间大量缓存生效,从而占用系统内存
- 缓存的生命周期,什么时候写入,什么时候删除,这里既有占用内存的考虑,也有数据一致性的考虑。
在用户认证的场景(输入用户名,密码,获取token的场景),我的方案是
- 评估和限定缓存规模,比如限制一台机器只能有10000个键值对,每个键值对的过期时间是多少,如果超过10000个键值对则降低过期时间,超过30000个键值对则不进行写入缓存操作。评估的缓存大小时间需要根据机器的物理内存,用户的并发量,单个用户的缓存数据大小相结合。运维层面,可以使用Prometheus监控查看接口或是内存历史负载数据。关于Prometheus的快速上手,可以访问我的另一篇文章(docker部署Prometheus实现一个解决的QPS监控)。
- 使用缓存预热技术,也就是当访问不到缓存时被动的从数据库获取数据再写入缓存。当用户输入用户名时,就将用户的相关信息一次性全部塞入本地缓存,当用户密码校验通过后就立即将密码相关的缓存清空(密码状态,盐值,哈希后的密码等)。之所以需要立即删除操作是为了尽可能的维持数据的一致性。当这个数据确定以后不再使用后就应该立即删除。如果认证完需要签发token,则需要继续维持一段token换取用户信息的缓存的时间。
分布式缓存场景
而一些和用户无关,需要全局共享的缓存则应该放入分布式缓存。如果对数据的一致性,可用性,容灾能力要求较高,则应该使用etcd(其底层是raft算法,关于一致性算法raft的介绍可以看我的另一篇介绍Raft算法选主详解与复现, 完成MIT6.824(6.5840)Lab2A),比如服务间的注册与发现,各服务的启动配置。
如果对数据的可用性,容灾性要求没那么高,则可使用Redis集群,其底层实现是基于 gossip 算法(一种数据复制算法)。比如iam系统可以配置各种认证协议(扫码登陆,账号密码登陆,短信登陆等等),这种数据规模虽然不大,一个租户下也就是两三条,或者三五条记录顶天了,但是每个用户来都需要查一次(一般情况是查两次,第一次是静态验证比如账号密码,第二次是动态验证比如短信,otp动态码),只要有查询就占用SQL连接,就会消耗更大的资源。而且这些查询到的的数据是所有用户共享的,数据一般由管理员配置好以后不再变化,所以这种场景放入Redis是最好的。
缓存一致性
接上面,我们常将一些不经常变的数据写入redis(过期时间通常较长或者永不过期),但一旦修改数据库数据后后,数据库和redis的数据总会存在不一致的问题。简单来看有有两种方式
- 先删除redis, 再修改数据。但如果好巧不巧,在写入的过程中来了个新请求则会将旧数据搬到缓存上去并且长期生效
- 先修改数据库,再修改缓存。但如果更新 redis 过程中出错,则会导致数据不一致
延迟双删除
一个简单的,尽可能保持数据一致性的方案是延迟双闪。先删除redis,再更新数据库,然后再删除redis。 这样下一次请求来的时候先读redis, redis 没有再度 数据库,将数据库的数据写入 redis。 之所以更新数据库后再删除缓存,就是为了防止更新数据库一半时刚好有历史数据被塞入 redis 并且长期生效,所以一旦成功更新数据库后应该立马删除redis缓存。之所以是删除redis缓存而不是更新,是因为删除操作更快速,原子性更高。
其他优化 tips
使用 go routine 提高程序并发性能
比如某个函数请求了A服务校验了某些变量, 然后又请求了B服务校验了另一些变量,然而这两个校验的顺序并无影响,任何一个校验不通过都会报错。所以可以大胆使用 go routine 向两个服务发送请求,任意一个收到返回了都直接报错。关于 go rouinte 的经典使用场景,可以查看我的另一篇文章(golang并发编程场景错误模板 排雷贴(持续更新中))
尽量少打印info级别的日志
日志打印也需要IO操作,尤其是一些结构化的日志,打印起来相当的耗时。对于一些简单的接口,不容易报错的接口,尽量不要在函数的入口打印 info 级别的日志
用ECC非对称加密替换RSA非对称加密
与RSA非对称加密相比,ECC能够大幅度降低密钥的长度与计算消耗。ECC 160位的密钥长度就可媲美 1024位的 RSA密钥的安全性能。对于一些非特别重要的数据,和注重性能的场景,应该使用ECC替换掉 RSA。关于ECC的介绍,可以参考我都另一篇文章(小白也能看懂的ECC椭圆曲线加密算法)
一点感慨
距离我第一次掘金创作,过去了1年5个月。我的第一次创作,那还是我在Mobvista实习的时候,现在回想起来那篇帖子自己写的也是迷迷糊糊,只能看懂个大概意思。加入Datacloak后视野更加开阔了,平时工作很杂很忙,既有开发的任务,也有各种运维和测试的任务,但忙碌和收获也是成正比的。每当我遇到新问题时,我都会尽力地将如何思考问题,解决问题记录下来,渐渐地形成自己的知识文档。感谢掘金提供了这么好的平台,也感谢掘金上留言的朋友们,更感谢工作上给予我各种帮助和指导的前辈们。2023最后一天,祝大家2024bug少少,快乐多多!
最后欢迎大家前往 我的专栏 浏览,点赞,收藏