FunctionGraph 扩容打断事务:提交、会话、幂等三层收尾
10 月 29 日我在社区发了一个设计类问题,先把场景说清楚,再谈收尾——场景本身是为把问题问清楚而给出的设定,不是已发生的生产故障记录:FunctionGraph 函数在 VPC 里连 RDS for MySQL,中间走了 RDS 代理;平时实例数很少,业务高峰时实例数在几十秒内从个位数扩到 200 个左右;每个函数实例处理一个请求,请求里要做多条语句的事务:先查库存、再扣库存、再写订单,中间任何一步失败都要整体回滚。
卡住的点不是连接数爆了,而是实例生命周期和事务生命周期的冲突:一个函数实例在事务还没提交时就被平台冻结、回收,或者实例本身超时,代理层和 RDS 侧的那条连接、那个事务、那几行锁,到底谁来收尾?新实例重放请求时,会不会把已经扣过的库存再扣一次?
我的结论是分三层收尾,缺一层都会漏:
第一层:提交必须收进一次调用里
多语句事务不要跨调用、跨实例活着。一次调用里查库存、扣库存、写订单、提交,要么在同一次调用里提交,要么就当它没提交。
实例死了,旧事务就不要指望新实例去“续上”。新实例拿到的不应该是还残留着旧事务状态的后端连接,而应该是一次全新的事务。靠新实例去继承旧后端会话,是最容易把锁和连接搅在一起的做法:旧事务没提交,锁还在,新请求又在同一个会话状态上继续写,到底哪一步算数就说不清了。
这里要明说一个不确定的地方:代理在会话断开时是立刻回滚,还是等会话超时、锁等待超时才释放,不同代理实现和版本可能不一样,我没有在所有版本上实测过。所以不要把“实例死,事务立刻回滚”当成默认保证,超时只能当兜底,不能当成收尾的主路径。主路径是业务层保证:没提交就是没发生,重放时按没发生过重新判断。
第二层:会话钉住是帮手,也是重放时的坑
代理的会话保持(session pinning)在这里是双面的。事务里用了临时表、用户变量或者 SELECT ... FOR UPDATE,代理会把后续语句钉在同一个后端连接上,这在事务活着的时候是必要的:不钉住,后续语句可能被分到另一个后端连接,事务语义就乱了。
但实例死后重放时,钉住就成了坑:如果新实例重放请求时拿到了还残留旧事务状态的连接,重放的语句就可能在旧事务的上下文里执行。所以重放的第一步不是直接重跑扣库存,而是先区分“新请求”和“旧事务重放”:重放必须当成新事务开始,先读回当前状态,再决定下一步是继续、跳过,还是回滚后重做。不要让重放语句默认继承旧会话状态。
第三层:只靠唯一约束,挡不住重复扣库存
幂等设计上,只靠订单表的业务主键唯一约束是不够的,这一点最容易被高估。
唯一约束能挡住的是重复写订单:如果第一遍已经写了订单,第二遍重放时唯一约束会挡下来。但如果第一遍已经扣了库存、还没写订单就断了,第二遍重放时唯一约束根本还没触发,库存就会被扣第二次。扣库存和写订单如果不在同一个原子操作里,唯一约束就管不到扣库存那一步。
够用的做法是两件套,缺一不可:
- 业务主键唯一约束,挡重复写订单;
- 回读校验:重放前先读回订单和库存的当前状态,已经扣过的、已经写过的就不再重复做;或者把“扣库存 + 写订单”做成一张幂等表里的原子操作,或者用事务外的一次性 token 来对齐,让重放的第二次执行在业务上等价于什么都没多做。
只写唯一约束、不做回读校验,等于只防了重复写订单,没防重复扣库存。
超时和连接池:按顺序对齐,不要各设各的
超时不是各设一个数就完事,要按顺序对齐,否则会出现函数以为连接还活着、代理已经回收了,或者代理以为事务还活着、RDS 已经回滚了的情况。讨论里给出的顺序是:
innodb_lock_wait_timeout < socketTimeout < 函数执行超时
池连接 maxLifetime < 代理空闲回收 ≤ wait_timeout
这是顺序,不是通用数值:具体数值要按自己的环境填,控制台字段名不同版本可能有差异,以当前控制台为准。顺序的道理是:锁等待先于连接超时先于函数超时断开,池连接先于代理回收先于数据库空闲超时失效,这样上一层断开时,下一层不会还以为上一层活着。
连接数那一侧也要先归因再限流。直接看三个数,不用猜:
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Threads_running';
SHOW FULL PROCESSLIST;
Threads_connected 暴涨但 Threads_running 不高,PROCESSLIST 里大量是 Sleep 状态、Time 很长,那就是连接堆起来了、没及时释放,不是慢 SQL;Threads_running 也跟着涨、很多正在执行且 Time 很长的同一类查询,才是慢 SQL 占着连接,限流只能缓解,根因在 SQL 和索引。再往下归因到函数,要按 Host 和 User 分组看 PROCESSLIST:函数实例都在 VPC 的某个网段里,函数如果用独立数据库账号,按 User 过滤一眼就能看出函数占了多少;再把 FunctionGraph 实例数的时间曲线和 RDS 的 Threads_connected 曲线放在同一时间轴上对一下,峰值时间对得上,才是函数打爆的证据。
函数侧限流,限的是实例数,不是请求数。每个函数实例就是一个独立进程,它的池子跟着实例生、跟着实例死,全局池化在这里不成立。真正起作用的是两层:单实例并发数,对连库重的函数设成 1,一个实例同一时刻最多只处理一个请求、最多只占一个连接;函数实例数上限,用可用连接数反推:
实例上限 = (max_connections × 0.8 - 其他服务预留连接数) ÷ 单实例池上限
0.8 是留给管理、监控和其他服务的余量,其他服务预留要按实测填,不要填 0。单实例池上限设 1 到 2,不要设 10、20,否则实例一扩容连接就翻倍;池连接的最大存活时间要小于数据库的 wait_timeout,连接必须请求级复用:请求开始时拿连接,请求结束时在 finally 里归还,不要在冷启动时拿一个连接一直 hold 到实例销毁。
代理的接法是函数连代理端点,不是直接连 RDS,代理再用一小池连接连 RDS,这样函数实例数涨,RDS 连接数不跟着等比涨。但代理不是万能:慢 SQL 还是慢,代理层只会把排队从 RDS 移到代理,查询本身不加速;代理本身有连接上限和费用,计价以当前控制台和计价页为准;代理账号权限要和原来函数账号一致,切之前先在测试环境连代理跑一遍,别直接改生产环境变量;灰度做法是先让一小部分函数实例走代理端点,对比 Threads_connected 和平均查询耗时,确认连接数下来了、耗时没明显涨,再全量切。
收尾时按这个顺序查
- 这次调用里提交了没有?没提交就当没发生,不要续旧事务。
- 重放是不是新事务?先读回订单和库存状态,再决定是继续、跳过还是重做。
- 唯一约束 + 回读校验是不是都做了?只做唯一约束,防不住重复扣库存。
- 超时顺序对不对?锁等待、连接超时、函数超时、池存活、代理回收、空闲超时是不是按上面的顺序排的?
- 连接数归因对不对?
Threads_connected、Threads_running、PROCESSLIST的 Sleep 和执行状态、Host/User 分组、时间曲线对齐,是不是都对上了才限流、才接代理?
这五步查完,实例说死就死的问题就不再是“等超时兜底”,而是每一层都有明确的收尾动作:提交收进一次调用,会话不继承旧状态,重放靠回读和幂等对齐,超时和连接池按顺序对齐。
- 点赞
- 收藏
- 关注作者
评论(0)