跳至主要内容

缓存 key 多加一个时间戳,数据库被打满

被电话叫起来的时候大概是一点多,值班群里已经在刷主站接口超时。我第一反应不是去看日志,是先确认一件事:这个点能不能回滚。

后来回头看,这个下意识的顺序救了不少时间,但也就仅此而已。真正的问题从那天上午十点就开始了,只是没人把它和主站报警联系起来。

一个「业务自己点一下」的功能

出事的是后台报表的导出。那张报表要跨几张业务大表做聚合,原来由 DBA 每周手动跑一次,导成 Excel 发出去。业务提了几轮,说想自己点。

方案很朴素:点一下,创建任务落库,后台 worker 去跑查询,生成文件丢对象存储,前端轮询进度,好了给下载链接。上线前做过压测,单次查询十几秒,我们认为可以接受。

故障发生在功能上线第二天的上午。业务集中使用的时间段,数据库 CPU 从平时的百分之十几一路往上走,连接数被打满,主站接口开始超时。我们最先收到的报警来自主站,而不是导出功能本身——这一点当时把注意力带偏了一阵。

止血分了三次才停干净:

  1. 关掉导出入口。没用,已经点过的任务还在队列里,worker 照跑。
  2. 停掉 worker 的消费。队列里的任务卡在中间态,前端轮询还在打接口。
  3. 清队列、补中间态数据、再放量。

缓存 key 里那个不该出现的东西

根因在缓存 key。

我们原本的设计是:同一份报表、同样的筛选条件,一天之内只生成一次,后面都走缓存。key 的构成大致是用户 id、报表 id、筛选条件的哈希、日期。看代码的时候我甚至觉得这个设计挺讲究。

PYTHON4 行
1
2
3
4
# 出事时的写法,大致长这样
def cache_key(user_id, report_id, params):
raw = f"{user_id}:{report_id}:{json.dumps(params, sort_keys=True)}"
return "report:" + hashlib.md5(raw.encode()).hexdigest()

问题在 params 里有一个「截止时间」,前端默认带的是当前时间。每次请求这个值都在变,哈希自然每次都不同,缓存命中率是零。

更糟的是我们还叠了一层逻辑:如果缓存文件的生成时间距现在超过 N 分钟,就重新生成。这个 N 设得很小,本意是防止拿到过期文件。

两件事合起来,后果是:用户点一次生成,产生一个新任务;前端每几秒轮询一次进度,而轮询接口也带着同样的「当前时间」参数,于是在服务端看来,每一次轮询都是一次新的生成请求。一个人点一下,可能排出几十个任务,每个任务都在跑十几秒的聚合查询。

压测为什么没发现?因为压测脚本用的是固定参数,同一个 key,缓存全命中。我们压的是「查询有多快」,没压「不命中会怎样」。

现在的做法是在入口处先做参数归一化,把「当前时间」这类天然变化的东西折叠掉:

PYTHON7 行
1
2
3
4
5
6
7
# 现在:入口先归一化,把「今天之内」折叠成同一个值
def normalize(params):
p = dict(params)
end = p.get("end_time")
if end is None or is_today(end):
p["end_time"] = today_str()
return p

顺带把轮询改成了带退避的,任务 id 幂等,重复请求只返回状态、不触发新任务。

平均值把信号稀释掉了

有一件事我到现在还记得清楚:故障期间,主站监控面板上的平均响应时间只涨了一点点。值班的人盯着那个图看了几分钟,觉得「还行」。

因为慢的只是导出这一类请求,占比不高,被大量正常请求摊平了。真正先有反应的是 p99 和数据库连接池的等待数——这两个当时都不在我默认打开的那个面板上。

这件事之后的改动很具体:

  • 报警阈值不再只盯平均值,核心接口全部加上 p99 和错误率的组合条件。
  • 给后台重活单独一条连接池,至少设一个并发上限,不让它和主站抢资源。
  • 轮询类接口按来源做限流,一个用户同时只能有一个进行中的任务。

这些都不算高明,但每一条都对应那天晚上的一个具体动作。

变更单上加了一栏

复盘的时候有人提议「加强代码 review」。我不太认同。那段代码是我们一起看过的,没人提出问题,因为它看起来太正常了——缓存 key 里带业务参数,天经地义。

真正缺的是另一件事:上线的时候没人说得清,如果这个功能把数据库打满了,几分钟内怎么让它停下来。

所以后来变更单上多了一栏,叫「止血手段」,要求写具体到点哪里:回滚代码、关哪个开关、停哪个消费组、清哪张表。写不出来就不算准备好上线。这一栏填起来很烦,但它逼着人在写代码的时候就想一遍失败路径。

现在每次上线前我会问一句:如果这东西上线后把数据库打满了,我们几分钟内能让它停住?大部分时候,没人提前想过这个问题。

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#复盘 #线上故障 #缓存

评论