一、序言
1、现状
最近在做一个跨地区转账的功能,先说一下问题现状,公司业务范围主要分布在新加坡、香港和迪拜,相关交易、卡、账户等数据应各地区监管、合规要求必须分地区物理隔离,关于分库我们选择了中间件Sharding-Proxy,分片键为某个地区的区域码,所有的分片表都会带上区域码这个字段。
如果是同地区转账,动账,交易记录读和写,带上当地区域码,所有的数据库请求都会由Sharding-Proxy
路由到该地物理库,所以同地区转账同步在一个事务内跑完其实是没有问题的。
2、问题
如果是跨地区转账,比如从香港到迪拜,或者从新加坡到迪拜,那么问题就来了。虽然Sharding-Proxy本身支持分布式事务,但跨物理库分布式事务之前并没有过实际经验。
同时由于迪拜地区和新加坡、香港地域相隔很远,即使走专线网络传输时间也会比较长,而且转账业务中涉及到两个不同地区多个表的读写,余额变动时也会给出入账账户加上数据库行锁(排它锁),在大事务中很容易超时,同时接口响应慢也会影响用户体验。
于是,我们决定把出账和入账分离,入账通过MQ做成异步处理。
这个时候问题又来了,既然不能通过数据库本地事务保证ACID,那么分布式事务的问题该怎么解决呢?
二、方案探索
开源分布式事务解决方案有Seata
,本身提供了AT、TCC、SAGA还有XA事务模式。但是该方案比较重量级、对外不透明,加上还要部署seata-server
,额外增加了维护成本。
最后还是决定选择比较轻量级的解决方案,MQ
+本地消息表
+重试补发
,大致流程如下。
先看出账方,出账记录、实际出账、本地消息表都在同一个事务,只要出账成功,那么本地消息表中一定会有转账相关的消息。
再看入账方,入账成功后,向ack_queue发送入账成功确认消息。
如果消费成功且入账成功,那么本地消息表中的消息会被逻辑删除,状态置为deleted。
如果消息发送失败、消费入账失败,本地消息表中消息状态会一直为undeleted状态,这时出账方会有一个补偿定时任务轮询本地消息表中状态为undeleted的出账消息。为了减少消息积压,当重试到一定次数后,停止发送消息到MQ,同时发送告警邮件给开发人员处理。
注意:入账方消费消息时需要做幂等,否则会重复入账,这里加上分布式锁就可以解决。
三、根据实际业务进行调整
实际我们的系统更加复杂,普通的转账是从A账户到B账户,而我们的转账A账户可能还会共享企业账户的余额。跨地区转账就更复杂了,每个地区都会有一个对公账户,资金变动流程如下:
如果从香港地区的个人账户A转到迪拜地区的个人账户B,个人账户A和个人账户B又分别共享企业账户CA和CB的余额,那么完整的账户资金变动流程如下:
- 企业账户CA自动划账到个人账户A。
- 个人账户A出账到对公账户PA。
- 对公账户PB出账到个人账户B。
- 个人账户B自动划账到企业账户CB。
1、定时补偿扫表改为扫缓存
如果是直接轮询本地消息表,由于我们的本地消息表是广播表(PS:各个地区物理库都会有相同的表数据),查询时会随机查询各地区物理库,如果随机查询的是迪拜物理库本地消息表的记录,那么查询就会比较慢了。
因此在写完本地消息表后,我们同时会将本地消息表的记录写到Redis中,后续定时任务补偿直接扫缓存而不扫表。
同时在入账消费者方成功处理入账后,除了逻辑删除本地消息表中的记录(置为deleted),还要删除Redis缓存中的记录。
问题:写本地消息表和写Redis缓存这里可能会出现缓存不一致的情况,如果本地消息表写成功了,但Redis缓存写失败了,那么在做定时任务补偿扫缓存时会丢到该记录,这里缓存一致性需要保证。
2、出账异步处理
可以看到,出账时从企业余额共享账户CA → 个人账户A → 对公账户PA,中间会有3个账户的余额变动和出账记录,如果是同步返回,响应时间会比较长,也会影响用户体验,出账操作这里可以通过线程异步。
3、去掉ack_queue
目前系统出账方和入账方并没有跨系统,数据库也没有根据业务做垂直拆分,因此消费者入账成功后可以去掉发确认消息到ack_queue
中,直接在消费端操作数据库将本地消息表中的记录状态置为deleted
并删除缓存就好。
4、入账失败一直重试
跨地区转账业务这里还比较特别,有可能会出现公账的钱不够扣,导致交易失败。如果转入方公账钱不够扣就会导致入账失败,但转出方实际已经扣款成功了,因此入账操作必须成功,出账方定时任务针对本地消息表中的Pending记录会一直重试,直到入账方公账金额够扣,入账交易成功。
备注:在实际业务中,跨地区转账入账方真正到账是有延迟的,虽然出账和入账操作程序只需要在账面上进行余额的扣和减,但是比如从香港地区到迪拜地区,真正的钱要和当地结算后汇款过去才能到账的。
四、可能出现的系统瓶颈
1、各地区公账可能会出现抢锁超时
目前在每个地区都只设有一个公账账户,而在做跨地区转账时,每次都会操作出账方和入账方的公账余额。而在做余额变动时,将会对指定账户加数据库行锁(排它锁),虽然转账业务并不是很频繁,但是如果并发上来确实会导致过多线程等待数据库行锁释放,可能会出现锁争抢超时。
2、出账时异步扣款线程池大小不够用
由于扣账涉及多个账户的余额变动以及业务表记录,扣账操作响应时间会比较长,所以我们通过线程异步,提升用户体验。同时这里扣张操作核心线程数、阻塞队列长度和最大线程数不是很好把控,还是需要根据实际请求量进行调整,拒绝策略我们设置的是交由主线程执行。