在本文中,记录一次并发冲突与请求放大导致的生产环境事故。分析导致这个问题的原因,分享这个过程中作为软件测试工程师的一些实践和经验教训。
事故现象
生产环境,转账相关请求失败量暴增。
直接原因
现网多个重试请求同时到达 svr,导致内存数据库大量返回时间戳冲突。业务方收到时间戳冲突,自动进行业务重试,服务内部也存在重试,导致流量放大。
转账
首先我们一起了解一下转账。转账请求在支付场景中的应用频率非常高,它是现代金融系统中的一个核心功能。在日常生活中,个人和企业都需要进行各种不同类型的转账:
- 个人间转账:朋友、家人之间进行的转账,如还款、借款、生日礼物赠送等。
- 工资支付:企业向员工支付工资、奖金等。
- 税费缴纳:向政府缴纳所得税、增值税等税费、政府退回多征收的税费等。
- 跨境汇款:向国外的个人或企业进行的转账,如国际贸易、留学生汇款等。
- 投资与理财:向股票、基金、保险等金融产品进行的投资转账。
- 退款与赔付:商家或金融机构向客户退还购物款项、保险理赔等。
- ...
随着移动支付、网上银行等数字金融服务的普及,转账请求在支付场景中的应用频率越来越高。人们可以随时随地进行转账,这背后离不开金融科技的发展带来的更加便捷、安全、高效的转账过程。
业务背景介绍
背景一:转账流程
转账流程
转账常见流程:
sequenceDiagram
participant 转出方
participant 银行
participant 转入方
转出方->>银行: 发起转账请求
银行->>银行: 验证转出方信息
银行->>银行: 验证转入方信息
银行->>银行: 检查转账金额
Note over 银行: ...
银行->>银行: 执行转账
Note over 银行: 判断转账是否成功
银行-->>转出方: 通知转账结果
银行-->>转入方: 通知转账结果
转账异常处理
当支付渠道系统内部出现异常,比如给转入方转钱时遇到被调系统返回超时时:
-
系统自动重试: 在大多数情况下,支付渠道系统会在短时间内自动重试转账操作,以确保交易成功。通常,系统会在一定时间内尝试多次,直到转账成功或达到重试次数上限。
-
转账暂停: 如果系统在多次重试后仍然无法完成转账,支付渠道可能会暂停该笔转账。在这种情况下,会通知转出方关于转账暂停的原因,并可能建议转出方稍后再次尝试转账。
-
** 资金退回:** 如果系统在尝试一定次数后仍无法完成转账,支付渠道可能会将资金退回到转出方的账户。转出方可以选择在支付渠道系统恢复正常后重新发起转账。
-
客户通知: 在上述情况下,银行会通过短信、电话或电子邮件等方式通知转出方关于转账失败的原因。客户可以根据银行的建议采取相应措施。
-
...
总之,渠道会尽力确保交易的顺利进行。
转账异常处理流程图
sequenceDiagram
participant from as 转出方
participant to as 转入方
participant MQ
rect rgb(255, 228, 196)
Note over from,to: 扣款成功,充值成功
end
from->>to: 扣款成功
to->>from: 充值成功
Note over from: 流程结束
rect rgb(255, 228, 196)
Note over from,to: 扣款成功,充值失败,重试成功
end
from->>to: 扣款成功
to->>from: 充值失败
from->>MQ: 推送消息
MQ->>to: 发起重试
to->>from: 重试成功
Note over from: 流程结束
rect rgb(255, 228, 196)
Note over from,to: 扣款成功,充值失败,重试失败,退款
end
from->>to: 扣款成功
to->>from: 充值失败
from->>MQ: 推送消息
MQ->>to: 发起重试
to->>from: 重试失败
from->>from: 退款
Note over from: 流程结束
背景二:账户系统合并
因为公司账户系统存在多套,同一个服务商在不同的业务都存在商业合作时,账户归属不同的系统。降本增效大背景下,相关业务完成了业务账户的融合,将同一个商户在两个系统上的商户信息进行整合,融合到同一个账户,方便客户更好的维护,也方便客户账户资金共享,保证业务不中断。
改造后上层调用方会传递迁移前后两套uin
的参数来进行调用,账户系统通过查询 uin 的映射关系和关系中的迁移状态判断实际操作的账户。
即两个不同入口的请求都需要先查询一次迁移关系,如果账户已经迁移,则使用迁移后的账户进行操作,这个逻辑同时适用于转出方 和 转入方, 所以流程图上加上了查询关系的逻辑 蓝色部分。
如果操作过程中,账户状态发生了变化,则内部进行重试。
sequenceDiagram
participant from as 转出方
participant Migrated as 迁移关系
participant to as 转入方
participant MQ
participant MQConsumer
Note over from,to: 查询转入方、转出方迁移关系
rect DodgerBlue
from->> Migrated: 查询迁移关系
Migrated->>from: 返回迁移关系
from ->> from:判断转出方实际抵扣账户
end
rect DodgerBlue
to->>Migrated: 查询迁移关系
Migrated->>to: 返回迁移关系
to ->> to:判断转出方实际抵扣账户
end
Note over from,to: 扣款成功,充值成功
from->>to: 使用迁移后的转出方扣款
to->>from: 使用迁移后的转入方充值
Note over from: .......
实际全流程:
sequenceDiagram
participant user as 用户
participant fromA as 转出账户A
participant fromB as 转出账户A'
participant toA as 充值账户B
participant toB as 充值账户B'
participant Migrated as 迁移关系
participant MQ
rect Bisque
user ->> fromA: 发起转账请求
end
Note over fromA: 查询转出方迁移关系
alt 未迁移
fromA->>fromA: 扣原账户
else 已经迁移
fromB->>fromB: 扣迁移账户
else 迁移中
Note over fromB: 流程结束
end
rect Bisque
fromB->>toA: 充值
end
Note over toA: 查询转入方迁移关系
alt 未迁移
toA->>toA: 充原账户
else 已经迁移
toB->>toB: 充迁移账户
else 迁移中
toB->> MQ: 推送 MQ 重试
Note over toB: 流程结束
end
Note over toA: 查询转入方迁移关系
alt 充值成功
Note over toB: 流程结束
else 充值失败
toB ->> MQ: 推送MQ 重试
Note over fromB: 流程结束
end
Note over MQConsumer: 重试逻辑
MQConsumer ->> MQ: 查询数据
MQ -->> MQConsumer: 存在重试数据
rect Bisque
MQConsumer ->>user : 发起重试
end
user ->> toA: 重试充值
alt 重试成功
Note over toB: 重写充值流水,流程结束
else 重试失败
user ->> fromB: 对转出方进行退款,写退款流水
Note over fromB: 流程结束
end
背景三:扣内存数据库逻辑
为了支持高并发的需求,账户系统使用的是一个自研的缓存数据库,数据库内部有诸多逻辑,其中操作账户时,会先 get 数据,再 set 数据, get 的时候会拿到当前数据的的时间戳 和更新序列号,set 的时候,数据库会校验这个时间戳的合法性。
所以在请求出现并发时会出现这样的情况:
sequenceDiagram
participant 客户端1
participant 客户端2
participant 数据库
客户端1->>数据库: get(键)
数据库->>客户端1: 上次更新时间戳t1, 值v1
客户端2->>数据库: get(键)
数据库->>客户端2: 上次更新时间戳t1, 值v1
客户端1->>客户端1: 修改值v1
客户端2->>客户端2: 修改值v1
客户端1->>数据库: set(键, 新值v1, 上次更新时间戳t1, 序列号1)
数据库->>数据库: 检查上次更新时间戳t1
数据库->>客户端1: 结果(成功)
rect Coral
客户端2->>数据库: set(键, 新值v2, 上次更新时间戳t1, 序列号1)
数据库->>数据库: 检查上次更新时间戳和序列号
Note over 数据库: 数据已经被更新为时间戳t2
数据库->>客户端2: 结果(错误:时间戳冲突)
end
背景四:调用方重试逻辑
调用方除非遇到订单重复、余额不足等明确错误,不然会推送 MQ 进行重试。
问题定位
相信大家看完上面的背景和前面的现象描述已经知道了问题的原因:业务的重试和系统内部的重试逻辑出现了重叠,导致了绝对并发(内存数据库的get\set逻辑极快),但是因为涉及到多个系统,每次请求的 uuid 又完全一致,导致了定位链路过长,定位难度增大。最后在测试环境复现了很多次才复现出来。
总结
针对这个问题给我总结了以下几点:
-
测试环境和生产环境的差异:测试环境很难完全模拟生产环境的各种情况,特别是在并发、性能和压力测试方面。因此,我们需要更加关注这些方面的测试,并尽量使测试环境接近生产环境。
-
完善的测试用例:在设计测试用例时,需要考虑各种异常情况和边缘条件,包括系统之间的相互调用、失败重试等情况。这样可以提高测试的覆盖率,降低类似问题的发生概率。
-
强化并发和压力测试:在软件测试过程中,应该重点关注并发和压力测试,模拟大量用户同时访问和操作,以便发现潜在的性能瓶颈和冲突问题。(常态化性能测试是一个非常好的切入点。后续会专门写一篇博客介绍如何进行常态化性能压测。)
-
监控和日志分析:在生产环境中,应该加强对系统的监控和日志分析,以便及时发现并定位问题。同时,测试人员可以通过分析生产环境的监控和日志数据,了解系统在实际运行中的表现,从而改进测试策略。
以下是一些避免类似问题的发生的改进措施:
- 测试同学需要与开发团队紧密合作,了解系统架构和相互调用的关系,以便更好地设计测试用例。
- 在系统设计和开发阶段,可以引入容错和熔断机制,以应对失败重试和请求放大等问题。测试工程师需要关注这些机制的实现,并在测试中验证其有效性。
- 在测试计划中明确测试范围,包括并发测试、压力测试和性能测试,确保测试环境尽量接近生产环境,有条件的可以使用真实的数据和场景进行测试(现网引流)。
- 对于失败重试等可能会放大流量的逻辑,进行专项测试,模拟各种异常和故障情况(后续会专门写一篇博客介绍如何进行混沌注入),验证系统的稳定性和健壮性。