服务接口耗时突然暴增的可能原因
工作这么多年, 见过太多妖魔鬼怪, 最常见的问题之一就是: XXX, 你的接口咋这么慢? 下面是我总结的一些可能原因
业务开发层面
第三方依赖服务的原因
在日常开发中, 我们本身也会以来一些第三方的服务接口, 比如 身份认证服务 , GPS位置解析 等, 如果这些服务接口变慢, 那么我们的服务接口也会变慢。
解决方法
第三方服务的问题, 对于我们自身来讲, 严格意义上来说, 是无解, 因为服务孔子全部在我们自己手里, 只能被动等待第三方服务供应商优化. 但是, 我们可以通过业务流程的手段, 弱化这种超时带来的影响.
- 判断依赖接口是否是核心链路上的接口: 若是, 如身份认证, 没有太好办法, 只能慢慢等, 或者超市失败, 让客户端重试.
- 不是核心依赖, 如GPS解析, 设置一个合理的超时时间, 失败便失败了, 等待什么时候恢复了即可正常访问.
- 数据缓存: 对于第三方读接口, 如果接收数据延迟或者一定程度的不一致性, 我们可以适当缓存第三方结果, 不是每次都调用, 有缓存时直接走缓存.
数据库慢查询
数据库慢查询由于SQL执行变慢也会导致接口变慢
解决方法
- 查看导致慢查询的SQL, 是否是没有索引, 若是, 添加合适索引即可解决.
- 查看导致慢查询的SQL, 应该可以命中索引, 但是 通过
EXPLAIN发现索引没生效, 参见文章: MYSQL索引失效常见场景 来进行排查, 个人经验, 比较常见的是隐式类型转换导致 - 查看导致慢查询的SQL, 查询条件一切正常, 并且可以命中索引, 但是依旧慢查询, 可能存在如下情况
- 索引区分度太低, 导致无法有效过滤数据, 如用户信息在性别字段建立索引, 只能过滤一半数据, 索引监理师需要有合适的区分度
- 翻大页, 这种常见于各种管理后台, SQL形如
select * from people limit 10 offset 100000 order by id desc, 注意, 这种sql, 不是从 100000 开始向后查 10 条, 而是直接查询 100010 条, 将最后 10 条返回. 可以将翻大页的模式调整为游标模式, 每一次查询都将上一次的最小ID作为参数传入, 可以使用主键索引.
不合理的全局锁(公共锁)
以golang为例, 很多并发操作为了并发安全或者某些操作串行化, 会使用全局的锁, 但是锁定逻辑的不合理, 可能会导致锁的持有时间过长, 后续操作陷入锁等待, 简单示例如下:
var globalLock *sync.RWMutex
func main() {
}
// 假定某一个接口请求会调用这个方法
func handle() {
globalLock.Lock()
defer globalLock.Unlock()
// logic 1
// logic 2
// logic 3
}像上述代码, 每次请求都会加锁, 并且锁定的范围是整个方法, 基本等于 当前逻辑完全串行处理, 无并发度可言 , 解决方法如下:
解决方法
- 评估是否有必要加锁, 非必要不加锁
- 评估是否必须加写锁(写锁互斥, 一旦有写锁无法加其他锁), 不是必须用写锁, 可以使用读锁, 读锁是可以反复加的
- 评估锁的范围, 锁的范围越小, 锁的持有时间越短, 并发度越高, 尽可能按需最小范围加锁
- 是否必须使用全局锁, 函数局部的是否足够, 足够的话, 不要使用全局锁
- 必须使用全局锁, 也不需使用写锁, 还可以将锁实例hash化, 本质上也是降低数据锁定的范围
死锁
严格来讲, 这个属于BUG, 但是也属于可能导致的问题, 一般来说, 死锁是可以通过代码避免的, 但是如果出现死锁, 会导致服务不可用, 所以需要及时发现并解决.
解决方法
- 通过pprof 可以查看当前的goroutine情况, 可以查看是否有死锁, 一般来说, 死锁可以通过代码避免, 但是如果出现死锁, 会导致服务不可用, 所以需要及时发现并解决.
- GO工具链 -race 可以进行静态检测, 实用工具链排查
队列的不当使用
此处的队列是指内存的队列, 而非 kafka、redis 等中间件提供的消息队列服务。一般而言, 内存的队列为了防止数据野蛮扩张, 会限制队列容量, 队列数据一旦满了, 便会阻塞后续的入队操作, 导致后续的请求无法正常处理, 而如果队列的消费端有比较重的逻辑, 而队列数据生产速度比较快, 就很容易产生队列阻塞.
解决方法
- 评估队列的容量是否合理, 队列容量过小, 可以适当扩容
- 评估消费端的处理逻辑, 是否确实需要很重的逻辑处理
- 增加消费携程数量
- 评估使用内存队列的必要性, 可以使用 kafka、redis 等中间件提供的消息队列服务
死循环
- 递归程序, 为判断终止条件, 或终止条件有问题导致死循环
- for条件异常, 导致无法结束循环
func logic() {
// someCond 永远为true
for someCond {
}
}- channel等待信号继续执行, 但是生产端无任务生产相关信号
func logic() {
// some logic
<- businessChan
// some logic
}- WaitGroup协程组, 没有及时完成, 这个属于纯粹的BUG, 对于资源类操作, 阻塞类操作, 挨着写上 defer 语句是一个良好的开发习惯
func logic() {
wg := &sync.WaitGroup()
for i := 0; i< 10; i++ {
wg.Add(1)
go func() {
// 缺少wg.Done
// defer wg.Done()
}()
}
wg.Wait()
}配置层面
连接池配置不合理
数据库/redis 连接池大小配置过下, 比如QPS=1000, 但是连接池大小配置为10, 那么在高峰期, 会有大量的请求处于阻塞状态, 等待连接池中的连接释放, 导致连接池的连接数不足, 从而导致连接池满, 后续的请求无法获取连接, 从而导致请求失败.
解决方法
- 评估连接池的大小是否合理, 连接池大小过小, 可以适当扩容, 配置好最好空闲连接数即可
超时时间配置不合理
主要包含如下几个方面:
- 网络超时时间配置不合理, 网络超时时间过短, 会导致请求失败, 网络超时时间过长, 会导致请求超时
- SQL超时时间配置不合理, 这个需要在mysql层面配置
运维层面
机器带宽跑满
- 流量激增, 网络带宽跑满, 导致请求失败
- 服务混布, 机器带宽被其他服务耗尽
- 服务监控监控(不常见): 服务器上一般都会部署公司公共的监控组件, 用于日志采集等, 曾遇到过因为日志数据量太大, 导致机器磁盘IO飙增(未跑满,跑满也是个事), 但是机器带宽被打爆了, 导致机器无法处理其他请求.
机器CPU跑满
- linux cpulimit/cgroup 等工具可以限制机器CPU使用率, 防止机器CPU跑满, 导致机器无法处理其他请求, 或者docker容器等分配CPU资源较少
- 服务混布, 机器CPU资源被其他服务耗尽
解决方法
- 合理评估CPU资源
- 服务混布注意混布服务的分配, 如果两个服务都是使用CPU资源的大户, 那么需要注意混布的比例, 避免一个服务占用过多的CPU资源
- 监控报警, CPU利用率达到临界值时,发出预警
机器内存跑满
内存跑满, 会导致服务不可用, 内存跑满的原因有很多, 比如: 内存缓存数据过大, 内存泄漏, 将一个大文件读到内存等, 内存超限的结果不是超时, 而是服务不可用. 直接 OOM
进程不足
这个原因有些类似于 MYSQL/Redis 连接池配置不合理。 以PHP为例, PHP-FPM 进程数配置过下, 比如QPS=1000, 但是PHP-FPM进程数配置为10, 那么在高峰期, 会有大量的请求处于阻塞状态, 等待PHP-FPM进程释放, 从而导致请求超时.
解决方法
- 评估PHP-FPM进程数是否合理, PHP-FPM进程数过小, 可以适当扩容, 配置好最好空闲进程数即可
- 服务集群横向
云厂商问题
CDN回源数据量过大
因为产品形态原因, 会大量通过CDN访问图片数据资源, 曾经有一次, 因为数据BUG导致大量图片链接发生变化, 导致CDN回源数据量过大, 云厂商服务被压垮, 从而导致请求失败, 已经不是超时问题了.
解决方法
- 如果明确知道会有大量图片链接发生变化, 可以提前通知CDN分批刷新缓存, 避免CDN回源数据量过大
- 提前和云厂商沟通,增加服务器资源, 应对流量尖峰
- 所有重大变更, 做好演练和预案, 快速降级、回退止损等
服务器超卖
这个其实是一个比较狗的原因, 除非是自建机房, 否则大概是无法解决. 至于什么是超卖, 可以参见知乎文章: 关于云计算超卖,你需要知道的都在这里了, 只能说, 夜路走多了,总会撞见鬼的.
总结
耗时突然飙升几大类原因如下:
- 数据库慢查询(很常见了)
- 第三方服务不稳定,超时飙升(很常见了)
- 死锁/锁的范围过大(常见, 但是不好调试, 不易发现)
- 内存队列阻塞,消费能力不足
- 连接池配置不合理
- 服务器资源不足(CPU/带宽等)
