一、问题背景
近期在生产环境中,我们发现 record-api 服务出现响应缓慢甚至频繁超时的问题。具体表现包括:
-
Record 单个删除接口 请求耗时超过 1 小时;
-
批量删除接口 长时间无响应;
-
查询 Record 详情接口 耗时高达 10 分钟以上。
-
整个
record-api关联的数据库直接查询不了, 严重影响的电话业务.
随着多个区域客户陆续反馈服务不可用,我们立即展开排查。最终发现问题根源在于 PostgreSQL 数据库中 records 表频繁发生 行级锁竞争(Lock: tuple) ,极大地影响了整体读写性能,进而导致服务雪崩。进一步调查发现:是并发的 delete 请求和一个定时任务在数据库中产生了锁冲突,从而引发了严重的性能瓶颈,最终导致 PostgreSQL 数据库的 CPU 达到 99%,服务基本不可用。
目前Record的单表数据量已经达到了 2 千万, 之前创建了一个索引, 花费了半个小时... 已经从 4 千万数量删除到了 2千万, 删除了之前软删除的1 年之前的数据.
这个表中还有大量的 2 年之前的数据, 未来还可以考虑做数据归档.
二、根因分析:并发写请求与定时任务引发的锁冲突
排查过程中发现如下原因叠加导致锁冲突严重:
-
多个大客户高频调用 DELETE 接口(单个 + 批量),形成激烈并发, 它们直接使用 API 调用的方式, 疯狂调用删除;
-
后台定时任务
ExtraKeysScheduler同时频繁更新records表中的historical_keys_log字段; -
两者操作重叠,产生严重行级锁冲突,阻塞其他事务;
-
数据库未设置
socket_timeout,部分连接长期占用连接池资源,最终导致线程耗尽,服务完全雪崩。
异常日志如下所示:
org.postgresql.util.PSQLException: ERROR: canceling statement due to lock timeout
Where: while updating tuple (656,9) in relation "records_account_xxx"
三、第一阶段:快速止血与初步优化
暂停高风险任务
-
立即停止
ExtraKeysScheduler定时任务,避免与 DELETE 接口抢占锁资源; -
稳住主业务服务,防止服务进一步雪崩。
调整 Record 单个删除接口的允许访问频率, 从每秒的50 个请求直接先调整为每秒 5 个请求.
为record-api与数据库的连接设置一个 10 分钟的套接字超时(Socket Timeout)。
目的: 此举旨在防止应用在等待数据库响应时无限期地挂起。当数据库操作耗时过长(例如超过10分钟),该设置会强制中断网络连接,从而触发异常。这能确保应用连接可以被及时释放回连接池,避免因长时间的数据库交互导致连接资源耗尽和泄漏。
在数据库事务中添加锁等待超时
在 PostgreSQL 数据库事务中配置 锁等待超时(Lock Timeout)。
目的: 当一个事务试图获取一个被其他事务持有的锁时,它不会永久等待。该配置将使其在等待一个预设的时间后自动放弃。这可以自动中止因等待锁而阻塞的事务,防止死锁(Deadlocks)的发生,并快速释放数据库资源,提高系统的健壮性。
SQL 查询逻辑优化
1. 移除 SQL 中的动态函数调用,提升索引命中率
原查询示例:
select * from extra_keys
where updated_at <= now() - interval '2 hour'
and updated_at > created_at
and counter <= 0
优化后:
val threshold = now() - Duration.ofHours(2)
...
where updated_at <= :threshold
通过代码提前计算 threshold,提高了索引命中率,显著降低查询耗时。
2. 批量插入改为分批处理,降低事务压力
原逻辑:
insert into historical_processor
select ... from records where updated_at between ...
优化方案:
-
将 SQL 插入迁移至代码层控制;
-
使用分页拉取数据 + 批量插入方式,缩小事务规模,减轻数据库锁压力。
3. 避免多表 update 导致锁表
原 SQL:
update records t
set historical_keys_log = null
from historical_processor h
where h.record_id = t.id ...
优化后使用精准索引:
UPDATE records
SET historical_keys_log = null
WHERE account_id = :accountId AND id IN (:recordIds)
-
使用
(account_id, id)精准索引; -
避免嵌套循环 JOIN 导致的大范围锁表。
四、第二阶段:任务调度重构与区域验证
初步优化上线后,加拿大区域表现稳定。但在美国和欧洲区域上线时暴露出新问题:
-
Job 被暂停数日,堆积 47 万条未处理记录;
-
一次性全量处理导致事务超大,再次出现超时问题。
定时任务重构方案
-
动态获取每个分表对应的 account_id:
SELECT regexp_replace(c.relname, '^records_v2_account_', '') AS account_id
FROM pg_inherits i
JOIN pg_class c ON i.inhrelid = c.oid
WHERE i.inhparent = 'records_v2'::regclass -
按 account_id 分批处理,每批处理 500 条记录;
-
任务执行频率由每 10 分钟提升为每分钟;
-
缩小事务粒度,显著降低锁竞争。
上线结果:
-
us-east-1 和 eu-central-1 成功跑完全部任务;
-
Job 单次执行耗时 < 1 秒;
-
所有积压数据 1 天内全部清理完毕。
五、第三阶段:Delete 接口体系重构
旧架构问题
-
高并发触发 DELETE 接口;
-
同一 record_id 被多次重复删除;
-
与定时任务竞争锁,导致服务冻结;
-
无法保障数据一致性与稳定性。
重构方案
1. 引入 Redis 分布式锁
-
按 record_id 加锁,避免并发冲突;
-
设置锁超时为 5 秒,快速失败反馈。
2. 使用 FOR UPDATE SKIP LOCKED
-
避免读取已被锁住的记录;
-
仅处理可立即锁定的数据行。
3. 支持异步删除架构
-
加入 Feature Flag:
BULK_DELETE_RECORDS_BY_QUEUE; -
将删除任务统一入 Redis 队列;
-
后台异步线程消费处理;
-
提升主线程响应速度与系统稳定性。
Redis 队列结构优化
-
deleteRecordsByIds → 入队
storeDeleteRecordsToQueue; -
deleteByPhoneNumbers / ExternalIds → 校验通过后入队;
-
响应码设计优化:入队成功返回
202 Accepted。
六、经验教训总结
成功经验
-
快速定位问题 SQL,调整查询逻辑与索引使用方式;
-
第一时间中止高风险任务,控制故障范围;
-
架构从同步转异步,从粗放转精细,服务鲁棒性显著提升;
-
合理引入 Redis 锁机制、限流控制与异步调度;
-
使用
FOR UPDATE SKIP LOCKED回避锁冲突,保障系统运行。
遗留问题与优化方向
-
DELETE 接口缺乏全局频控:将引入 API Rate Limiter 限流机制;
-
Job 执行缺乏断点续跑能力:计划引入状态记录与幂等重试逻辑;
-
增强观测能力:增加事务耗时、锁等待、连接池状态等指标监控。
七、结语
此次 record-api 性能治理经历了从 性能雪崩 → 快速止血 → 架构优化 → 稳定运行 的全过程。
我们不仅解决了服务宕机问题,更通过深入分析和分层治理实现了系统的可持续优化。
这次实战过程中的技术方法和策略,希望能为其他团队应对高并发与数据库性能问题提供实用借鉴。