1. 背景

线上有个转账业务,大概每个月都会有一两笔转账是重复的。虽说通过对账可以发现并追回,结果不是很严重,但从错误发生率上来讲还是高了些。应该是哪里写得不对。

2. 分析

整个调用链比较长。因为跨越了不同的子系统,还用到了消息队列。大致是这么个情况:

小程序 -> 小程序后端 -> 业务后端 -> 消息队列 -> 消息处理器 -> 转账业务后端 -> 第三方转账服务

根据日志,root cause是小程序端发起了两次请求。前端开发同学说如果第一次请求成功的话,页面刷新后就不会有能再次发起请求的按钮。能重复发起请求的话,就说明第一次请求失败或是超时了。然而既然有重复转账就说明请求并没有失败而可能是超时了,后端开发同学说在设计时已经考虑过请求超时重试造成重复请求的情况,这又是怎么回事呢?

2.1 转账业务后端

为了防止处理重复的转账请求,转账业务后端要求发起转账请求时带上当前账户的余额,以此来保证幂等性。比如当前账户余额100元,请求转账50元,则发送请求{ 余额: 100, 转账: -50 }。如果因为某些原因导致请求被发送了两次,则第二次请求会因为第一次请求成功后账户余额不是100而被忽略。

严格来讲这样做是不够的,因为会存在ABA问题。比如有两次请求:

r1: { 余额: 100, 转账: -50 }
r2: { 余额: 50, 转账: 50 }

正常情况下执行完r1和r2后余额为100。如果由于某些原因r1被重复发送了两次,并且导致执行顺序为r1-r2-r1的话,最后余额为50,并没有实现幂等。问题在于余额可能重复,所以实现幂等需要一个唯一的幂等号。

然而,排查了日志以后发现,转账业务后端并没有收到重复的请求,所以问题并不在这里。

2.2 消息队列与消息处理器

所使用的消息队列可以保证发送消息at least once,即不丢消息,但可能会有重复消息,所以在处理消息时也需要保证幂等性。这里在消息发送到消息队列前就为消息生成了唯一的幂等号,实现上也没什么问题。从日志上看,重复的转账请求来自不同的消息,所以问题来自上游。

2.3 业务后端

这里的问题就比较多了。分别来看。

2.3.1 没有暴露下游的幂等接口

这里暴露给上层应用的接口中并没有要求提供账户余额,而是在接到上层的请求后自己获取了账户余额后再向下游发送带余额的请求。之所以那么做是因为开发同学觉得对上游暴露下游的幂等接口可能会造成技术对业务的侵入。比如我们在进行银行转账时,只要输入转账金额而不需要输入账户余额。转账接口中需要传入余额就显得不自然。但这样就完全没有用到下游提供的幂等性,因为上游发出的两个重复的请求经过这一步就会变成两个独立的请求。

2.3.2 代码实现问题

这里的代码大致是这样的:

async function 业务处理(/* ... */) {
  // ...
}

async function 转账() {
  // ...
  const res = 业务处理(/* ... */)
  queue.add(转账消息)
  return res
}

// 接口调用处
const res = await 转账()

业务处理()是个async function,但调用时却没有用await,由此带来的问题是:

  1. 业务处理()的返回值resolve之前,就往消息队列发送了消息
  2. 即便业务处理()中reject了,依旧往消息队列发送了消息

第一条问题不大,因为在接口调用处有await,所以正常情况下,接口返回后业务得到了处理,消息也得以发送。第二条的话就严重得多,即使业务失败,接口返回了错误,消息队列中依旧有消息写入。会不会就是这个原因导致上游在收到失败的响应后又发起重试,最终发了两次消息呢?在修改了代码实现以后依旧观察到了重复转账发生,发生的频率也没有明显的变化,所以虽然这里有问题,但不是我们要找的。

至于代码为什么会写成这个样子,开发同学回忆说最初的版本是没有消息队列的,所以代码大致是这样的:

async function 业务处理(/* ... */) {
  // ...
}

async function 转账() {
  // ...
  return 业务处理(/* ... */)
}

// 接口调用处
const res = await 转账()

后面加上了消息队列,就改成了前面那样,但没有留意到业务处理()是async的。所以今后如果要在一个async function里返回另一个async function的值,还是一开始就写成这样比较好:

async function 转账() {
  // ...
  const res = await 业务处理(/* ... */)
  return res
}
2.3.3 幂等接口的组合

在最初没有消息队列的时候,业务处理()只是对数据库执行了UPDATE SET x=y的操作。这样的操作天然幂等,无需做特殊处理。但在使用了消息队列后,需要业务处理()和消息队列作为一个整体保证幂等。虽然它们单独来看都是幂等的,但它们对于重复请求的处理方式不同。具体来讲,天然幂等的操作是“即使重复执行也不影响幂等性”,而通过幂等号来实现则是“发现是重复的就不执行”。一个是允许执行,一个是不允许执行,显然是不能简单地放在一起的。

最终在业务处理()中对重复的请求进行判断,之后再也没有出现重复转账了。

后记

  • 目前的系统是通过对之前的单体系统进行微服务拆分而来。这次的问题也暴露了系统演化过程中的种种不幸。

  • 如果没有一个顺手的链路追踪系统和日志系统的话,问题排查起来会很麻烦。趁着这次排查问题还对自研的日志系统进行了升级。