场景
餐厅提供了网络点餐服务,用户通过微信能很方便的进行点餐并支付,享受餐厅提供的各种餐饮服务。其中可靠的支付服务是其中的核心环节之一,如果支付出了问题,对餐厅或用户都是一个损失,甚至会引起纠纷。如何避免发生这样的问题或者是把发生这样问题的概率降到最低,那就需要结合业务特点和使用场景来仔细分析隐藏的问题。
下面以微信支付中的2种支付场景来解析一下对接过程中遇到的问题以及如何解决
条码支付
对于支付宝和微信的条码支付,都是没有支付成功回调的。这点必须注意,那么基于这个特点,服务器对接了条码支付,就需要考虑到如何可靠的获得支付结果,避免支付单边产生。
下面来举例几种做法,一一来说明各种问题
假设wechat.scanPay() 一次就能确定支付结果,不考虑返回用户正在支付中的场景,后面会提供方案
做法1
同步请求
public order scanPay()throws PayException{ try{ beginTransaction order = createOrder result = wechat.scanPay order.makeSuccess commit } catch(Exception e){ rollback throw new PayException("pay fail") } return order }
问题解析
- 到微信的请求在自身数据库的事务过程中,会增加事务操作时间,微信支付并没有想象中的稳定。
- 当微信支付请求成功,执行数据库代码时会发生异常,有可能是代码bug,也有可能是数据库操作和提交事务都会发生,如果遇到数据库问题,整个事务不会提交,直接回滚,因此刚刚创建的订单就无法与微信支付对应上,对于接下来的交易也会被中断。网上好多人给出的解决方案是把代码进行try catch包起来,一旦遇到异常,catch然后记录日志等其他补救操作。如果这样做了,也许你一辈子也不会遇到问题,但是问题仍然存在:如果catch块的代码由于网络等其他问题也出错了,那职能靠人工去找数据了,代码不能保证一定能执行到catch块,例如:断电、机器重启、进程被干掉等。
- 对于数据库事务都会设定最长超时,如果微信请求过程较长,有可能造成事务超时,无法提交。情况与上面描述就类似了
改进后的做法2
同步请求
public order scanPay()throws PayException{ try{ beginTransaction order = createOrder order.makePaying commit } catch(Exception e){ rollback throw new PayException("pay fail") } try{ result = wechat.scanPay if (result == fail) { ..... } ....... } catch(Exception e){ order.makeFail throw new PayException("pay fail") } try{ beginTransaction order.makePaySuccess commit } catch(Exception e){ rollback throw new PayException("pay fail") } return order }
比做法1进步了,至少事务中不会有微信支付请求
- 创建订单成功后请求微信支付失败,请求微信支付成功后更新订单状态失败。以上2个情况都无法让代码继续运行,try catch 微信支付异常,然后做处理,照样解决不了问题。
如果是同步的请求方式,这个问题也能解决,但是解决的方式不太漂亮会影响用户体验。最好的方式就是采用消息队列进行异步的处理
改造支付为异步请求
请求分为多个阶段,其中MQ表示的是消息队列。
我们对消息队列的要求是不能丢失消息,保证至少发送消息一次。只要不删除消息,消息就会被再次发送。
MQ的可靠性不再本文范围之内,请自行Google 可靠消息队列
//创建订单,然后返回给调用者 public order createOrder(){ order = createOrder return order } //调用者发起支付请求,由于异步操作,不会返回任何结果 public void reqeustScanPay(orderId,amount,code){ mqclient.sendMessage(order); } //调用者轮询支付结果 public void checkOrder(orderId,firstQueryTime) throws PayException{ currentTime = System.currentTime if (currentTime > firstQueryTime + 2min) { //轮询2分钟还没有获得结果,终止轮询,订单等待支付服务自动处理 } order = getOrderbyId(orderId) if (order == null) { throw new PayException("order not exist") } if (order.isPaying) { //如果是支付中结束本次查询 } else if (order.isPaySuccess) { //支付成功.... //终止轮询 } else if (order.isPayFail) { //支付失败.... //终止轮询 } }
发送的支付消息开始异步消费
消息会处理以下几个方法:
//当接收到请求扫码支付时调用。扫码返回结果可能有多种,需要一一判断,根据不同条件来决定继续查询或者是撤销支付或支付成功、失败 public void processScanPay(message){ try{ result = wechat.scanPay if (result == userpaying) { send query memsage into MQ 延迟3s接收消息 delete message return } else if (result == error){ send cancel message into MQ delete message return } else if (result == duplicate pay){ send paySuccess message into MQ delete message return } else if (result == paySuccess){ send paySuccess message into MQ delete message return } //其他情况不举例了,根据实际情况来发送查询或者撤销消息 } catch(Exception e){ send query memsage into MQ 延迟3s接收消息 delete message } } //当支付结果不明确或状态为支付中、用户正在输入密码时调用。微信支付有可能不会立刻返回支付结果,例如用户需要输入密码等情况,就需要多次查询才可以获得结果,但是查询也需要有限制,例如2分钟如果超过,就强制撤销支付,这样就不会造成支付单边 public void processQueryPay(message){ orderId = message.orderId firstTime = message.firstTime currentTime = time.currentTime if (currentTime - firstTime > 2min) { send cancel message into MO delete message return } result = wechat.query(orderId) if (result == userpaying) { //用户还在输入密码,或者微信正在跟银行交互 send query memsage into MQ 延迟3s接收消息 delete message return } else if (result == paySuccess) { //支付成功是最终结果 send success message into MQ delete message return } else if (result == payFail) { //支付失败,是最终结果 send fail message into MQ delete message return } else if (result == unknown) { //微信系统异常经常遇到,因此对未知结果进行再次查询处理 send query memsage into MQ 延迟3s接收消息 delete message return } else if (result == orderNotExist) { //为什么订单会不存在,刚才明明发起了支付?原因就是发起支付后,如果timeout,请求有可能没有被微信处理或者根本没有接受到请求 send cancel message into MO delete message return } //其他情况不举例了 } //接收到支付结果时调用 public void processPay(paySuccess){ if (paySuccess) { beginTransaction lock order order.makePaySuccess commit } else{ beginTransaction lock order order.makePayFail commit } delete message } //撤销支付,直到撤销成功才删除消息,否则有可能出现支付单边 public void processCancel(){ orderId=message.orderId wechat.cancel(orderId) delete message }
方案说明
- 在创建订单阶段,如果订单创建失败,前端直接会返回错误给用户,提示支付失败,不影响资金变动
- 在创建订单阶段,如果请求超时,同样可以当做失败来处理。但是这个超时的请求会带来2种 可能性:第一种:请求收到了,但无法在指定时间内响应,订单会创建成功或者创建失败。第二种,请求没有收到,订单也没有创建成功。
- 在请求支付阶段,请求失败,表示发送消息失败。不影响资金变动
- 在请求支付阶段,请求成功,表示发送消息成功。消息发送成功,只要你代码没有bug,消息一定会正常收到,然后进行支付处理
- 在请求支付阶段,如果请求超时,也会有2种可能。第一种:请求收到了,无法在指定时间相应,消息发送成功。第二种:请求收到了,发送消息失败。都没有关系,客户端只要继续轮询支付结果就可以。
- 轮询支付结果阶段,任何的超时,都忽略,只要重复轮询就可以,除非获得明确的响应结果。当然也不可能无限制的轮询,可以在指定之间范围之内,通常我们是在1-2分钟之内。如果超过这个时间还没有获得准确的支付结果,那就可以直接请求撤销本次支付
- 轮询支付结果阶段,还会出现的一种状况就是客户端crash崩溃退出,无法继续刚才的轮询。因此,支付的任何情况都不能把可靠性交给客户端来保证。需要服务端来保证支付的最终结果
- 服务端其实也存在一个轮询,但是这个轮询不是foreach。试想一下,如果是foreach等待结果,那一次请求的时间就不可控了,当支付并发量大了之后,服务器就无法接收新的请求了。因此通过消息队列来实现轮询,这样每次都是非常短时间的数据处理,然后发送一个continue的消息,接收后再次进行后面的处理。这样的好处就是每次请求都是短时间,不会阻塞服务器的其他请求
- 任何网络请求都有可能超时,超时后必须查询才可能获得结果,但是也不一定会立刻获得结果,必须进行多次查询,因此还是通过消息队列来实现foreach的循环调用
- 为什么要延迟3s接收消息,当用户需要输入密码时,间隔3s,基本时间就足够了,如果间隔太小,查询过多也没有意义,此时间可以根据实际情况调整
会出现的问题
微信扫码支付超时,然后发起查询,返回结果告知订单不存在,当时考虑订单不存在就不处理了,但实际上,过了一会订单支付成功。因为在查询的时候,支付的请求还没到达,之后支付的请求也到达了。因此在这个场景下,对于不存在的订单也必须进行一次撤销操作。这时,撤销的功能就非常重要了,它能最彻底的防止支付单边。
微信如何设计的撤销
先看一个错误的设计
public void cancelOrder(orderId)throw CancelException{ beginTransaction order = db.getOrderId(orderId) if (order == null) { throw CancelException("order not exist") } order.makeCancel commit }
当扫码支付的请求没有到达时,如果使用上面的撤销我们得到的结果只能是订单不存在,并没有把订单进行撤销。但是支付请求有可能是在撤销请求之后到达,这时支付就会成功,这与我们期待的结果是不一样,我们希望用户的支付是无效的。
正确的设计
//根据订单号进行支付撤销,不管订单是否存在,只要不是关闭或者失败状态,都可以进行撤销 public void cancelOrder(orderId)throws CancelException{ beginTransaction order = db.getOrderId(orderId) if (order != null) { if (order.isPayFail) { throw new CancelException("order is fail"); } else if (order.isClosed) { throw new CancelException("order is fail"); } ....... order.makeCancel//把钱退回 } makeCancelByOrderId(orderId) commit }
那么 makeCancelByOrderId(orderId) 里面做了什么呢,这里大致的表结构如下 table:tb_order_cancel{ orderId varchar(50), //orderId是唯一索引或主键 createTime bigint } //记录了被撤销的订单号 public void makeCancelByOrderId(orderId){ try{ insert into tb_order_cancel value(orderId,createTime); }catch(DuplicateKeyException ignore){ //ignore exception } } 支付请求到达时,会先判断是否订单号已经被撤销,如果被撤销,就不再进行支付 public order scanPay(orderId,amount) throws PayException{ cancelOrder = select from tb_order_cancel if (cancelOrder != null) { //说明被撤销了,不应该完成支付 throw new PayException("order was canceled") } do pay ...... }
不管是支付还是撤销请求,谁先到达,最终都会被撤销,并把钱退回用户微信
以上的方法基本能覆盖了条码支付的所有问题。这个只是其中的一部分,还有退款、撤销业务其实只要按照以上的思路来做,就没什么问题了。
开发完成后,需要模拟各种网络情况和返回结果进行测试。这个测试条件比较复杂,只能一个个模拟。
针对于支付这里,我开发了一套基于aop的动态模拟功能,提供给测试人员。测试人员通过界面输入各种条件,就能模拟对应的问题进行测试。目前这个代码还在改进,暂时不会开放出来。
总结
- MQ是基础的保障设施,如果没有它,啥也干不了。当然我们可以用定时任务来替代,不过这个方案在数据和并发少的时候还行,一旦大了,那就不好办了。
- 经过网络的请求,timeout必须要考虑。timeout会产生2个结果,一个是对方收到了,一个是对方没有收到
- 如果要做到无单边,就一定要配合查询、撤销操作。你也可以等定时自动对账来找出差错,这个没问,但是如果能及时退钱,就避免了用户和商户的纠纷。以前我们的系统就是没有及时退回,用户经常投诉,无奈只能及时人工解决。自从方案换了之后,基本上一旦遇到问题1分钟之内,都会把钱及时退回,不用人工操作。
- 一定要考虑到任何事情都是有可能发生,但是需要坚信2个前提:消息队列是可靠的,数据库事务提交后持久化是可靠的。如果不考虑全面,我只能告诉你,当你遇到支付宝和微信bug N个小时的时候,你就知道人工搞数据是多么惨了
- 支付中的订单允许再次发起支付
下一篇,我会讲一下如果可靠的对接微信网页支付,网页支付的例子我会增加组合支付的部分,扫码支付这里的组合支付这次先忽略掉。