服务接口耗时突然暴增的可能原因
工作这么多年, 见过太多妖魔鬼怪, 最常见的问题之一就是: 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/带宽等)