开发思路篇:转账接口设计
设计一个转账接口
基础功能需求
- 用户 A 给用户 B 转账 X 元。
- 保证资金安全(不能出现金额丢失或多扣)。
- 保证事务完整性(要么都成功,要么都失败)。
核心流程:
1.校验请求参数(金额>0,A不等于B)
2.校验A账户余额是否足够
3.从A扣钱
4.给B加钱
5.写入转账记录
1. 问题描述、梳理及时序图
1.1 问题梳理
a. 明确转账接口涉及的范围 一个完整的转账流程:
- 交易系统:生成订单号
- 风控系统:风控系统判断此交易是否合理
- 账户系统:进行实际的转账操作
- 日志系统:对整个过程进行记录(包括交易的账号、ip和风控评判等执行过程的记录)
1.2 问题探讨的前提
- RPC框架:dubbo
- 数据库:mysql 存储引擎:Innodb 事务隔离级别:如无特殊说明,默认为可重复读
- MQ:RocketMQ
- 分布式缓存:采用redis实现
- Java框架:SpringBoot(含Spring事务)
简略版的时序图如下
b. 转账接口注意的事项 站在转账系统的角度,思考可能存在的问题
- a:交易系统可能对同笔订单发起多次请求 原因:当发起第一次请求时由于(超时,网络异常)等各种原因失败,则RPC框架会执行容错机制如:failover,当第一次调用失败后,会进行重,重试最多次数与failOver机制配置有关
- b:对t_alipay_account表进行修改时可能存在并发修改等情况
- c:数据一致性问题: 交易流水表t_alipay_trans应该与账户系统表t_alipay_account的数据保持一致,同时t_alipay_account表内部的两条数据的变更结果应该一致,但是日志系统的执行结果不应该对转账的行为造成影响。
c. 表结构的设计
1. 其中 创建t_alipay_account的sql语句如下
CREATE TABLE t_alipay_account(guid VARCHAR(64) PRIMARY KEY NOT NULL COMMENT '账户主键',balance DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '账户余额',owner_id VARCHAR(64) NOT NULL COMMNET '账户归属人id',...create_time TIMESTAMP NOT NULL '数据创建时间',update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMNET '数据更新时间') ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='账户信息表';
CREATE INDEX idx_owner_id ON 表名称 (owner_id);2. 创建t_alipay_transfer
CREATE TABLE t_alipay_transfer(guid VARCHAR(128) PRIMARY KEY NOT NULL COMMENT '交易id',type int NOT NULL DEFAULT 0 COMMENT '交易类型',from_account VARCHAR(64) NOT NULL COMMNET '转出账号',to_account VARCHAR(64) NOT NULL COMMNET '转入账号',...create_time TIMESTAMP NOT NULL '数据创建时间',update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMNET '数据更新时间') ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='交易表';
CREATE INDEX idx_from_and_to_account ON t_alipay_transfer (from_account,to_account);
CREATE INDEX idx_to_account ON t_alipay_transfer (to_account);3. 由于日志表t_alipay_log只是负责记录交易过程中的一些细节,并不能作为核账等依据,所以不在此设计
本次表设计的原则: a. 主键设计 由于主键存在与各种索引的val中,以及回表等操作需要用到主键索引,所以主键的大小和类型就格外重要。如:主键不宜过大(MYISAM引擎对此不敏感)。 另外对于用户量较大的公司而言,数据表的主键需慎重选择自增(需考虑到后续分库分表等操作) b. 索引原则 索引的设计需要由具体的业务场景来定义,在本次项目中t_alipay_transfer 只设置了两个索引idx_from_and_to_account(from_account,to_account)、idx_to_account(to_account) 这两个索引可以满足查询某个用户的所有交易(入账、出账)及指定两个账户间的流水等操作
2. 代码设计
首先项目整体目录如下
- transfer-iface:交易系统对外接口
- account-iface: 对外提供的账户系统接口
- account-service: account-iface接口的具体实现
- base:项目中的公共包
- log:为日志系统,用于记录各种类型的日志
2.1 交易系统的设计和流程
*** @date :Created in 2021/7/5 下午4:00* @description:交易系统外部接口* @modified By:* @version: $*/
public interface TransferIface {/*** 交易接口* 1. 新增一条交易数据* 2. 传入交易id等数据封装成转账接口所需数据(交易id =【第1步】中插入数据生成的主键id)* 3. 调用账户系统进行转账* 4. 根据账户系统转账结果更新交易状态* @param transferReq* @return*/ServiceResp transfer(TransferReq transferReq);/*** 通过交易id查询交易信息** @return*/ServiceResp<TransferResp> queryTransferById(String transferId);}其中TransferReq定义如下:
public class TransferReq {// 转出账户private AccountInfo fromAccountInfo;// 转入账户private AccountInfo toAccountInfo;// 转账金额private BigDecimal amount;
}
在接受到一个转帐请求后 TransferIface#transfer 将首先生成一笔交易id(t_alipay_transfer中的guid),同时设置状态此笔交易状态为进行中
其中账户系统的转账接口为com.alipay.account.accountiface.iface.AccountIface#giro
我们的重点放在- account-iface、account-service。
回顾1.2中将一个完整的转账流程分为以下四个系统
- 交易系统: 生成交易号,更新交易状态。
- 风控系统:风控系统判断此交易是否合理
- 账户系统: 进行实际的转账操作
- 日志系统:对整个过程进行记录(包括交易的账号、ip和风控评判等执行过程的记录) 因为简易的系统模块
2.2 账户系统的设计和流程
public interface AccountIface {/*** 转账接口* @param giroReq* @return*/ServiceResp giro(GiroReq giroReq);}其实现类/*** 1. 前置参数校验* a. 检验参数是否合理* b. 利用交易号做幂等* 2. 读取转出账户(fromAccount)信息并加写行锁* 3. 判断转出账户余额及状态* a. 余额不足或状态异常(账户被禁用等):释放redis锁,返回转账失败原因* b. 余额充足及状态正常:进行第【4】步* 4. 读取接受账户(toAccount)信息并加写行锁* a. 状态异常(账户被禁用等):释放redis锁,返回转账失败原因* 5. 进行两个账户余额更新操作* 6. 响应结果** @param giroReq* @return* @throws InterruptedException*/@Transactional(propagation = Propagation.REQUIRED, readOnly = false, timeout = 200, rollbackFor = RuntimeException.class)public ServiceResp giro(GiroReq giroReq) throws InterruptedException {// 1. 前置参数校验String result = preCheckTransfer(giroReq).getCode();if (!GiroResult.PARAM_OK.getCode().equals(result))return ServiceResp.fail(result);try {// 2. 加锁:利用accountId:交易id 做keyRedisLock lock = new RedisLock(redisTemplate, "accountId:" + giroReq.getFromAccountId(), 10000, 20000);if (!lock.lock()) {// 获取锁失败return ServiceResp.fail(GiroResult.FREQ_LIMIT.getDescription());}// 实际转账GiroResult giroResult = giroImpl(giroReq);if (GiroResult.OK.equals(giroResult)) {return ServiceResp.success(GiroResult.OK.getDescription());}return ServiceResp.fail(giroResult.getDescription());} finally {// 释放锁lock.unlock();}}
AccountIface#giro接口实现的思考
1.如何做幂等? 利用交易状态做幂等,如果交易状态不是进行中,则代表此笔交易已完成或已取消,则中止此次转账请求。 2.如何加锁?
注:代码中已去掉加锁逻辑,因为使用for update语句即可保障安全
- 加锁的时机 有两种方案可以选择:
- a.先做幂等再加锁?
- b.先加锁再做幂等? 探讨:可以看出,如果采用第一种,在并发场景下幂等将可能失效 结论:先加锁再做幂等。
- 对账户信息表的操作
转账的核心代码存在于com.alipay.account.accountservice.service.AccountService#giroCore
3. 代码设计
3.1 查询转出/转入账户信息
// 3. 查询转出账户余额及状态AccountEntity fromAccount = accountMapper.selectOne(giroReq.getFromAccountInfo().getAccountId());其中selectOne()方法具体实现如下public interface AccountMapper extends BaseMapper<AccountEntity> {@Select("SELECT guid,balance,owner_id,status FROM t_alipay_account WHERE guid = #{accountId} for update")AccountEntity selectOne(@Param("accountId") String accountId);}
转出账户sql语句 加入for update字段,有两个作用
- 代表使用的是当前读(而非快照读):要求读取最新数据
- 给行加写锁 防止其它事务对转出账户进行写操作
3.2 账户余额信息更新
java采用mybatis-plus操作accountMapper.updateById(fromAccount);accountMapper.updateById(toAccount);
3.3转账接口具体实现
关于2.2中实际转账接口的实现,运用到了前面说到的使用for update确保当前都,读取最新数据,以及先加锁再幂等校验(即获取分布式锁后幂等校验,校验当前交易id号对应状态),最后执行更新操作(全部操作被包围在数据库事务中)
/*** 核心转账逻辑*/
private GiroResult giroImpl(GiroReq giroReq) {// 1. 查询转出账户(for update加行锁)AccountEntity fromAccount = accountMapper.selectOne(giroReq.getFromAccountInfo().getAccountId());if (fromAccount == null || fromAccount.getStatus() != AccountStatus.ACTIVE) {return GiroResult.FROM_ACCOUNT_INVALID;}// 2. 幂等检查if (!TransactionStatus.PROCESSING.equals(fromAccount.getTransactionStatus(giroReq.getTransferId()))) {return GiroResult.DUPLICATE_TRANSFER;}// 3. 查询转入账户AccountEntity toAccount = accountMapper.selectOne(giroReq.getToAccountInfo().getAccountId());if (toAccount == null || toAccount.getStatus() != AccountStatus.ACTIVE) {return GiroResult.TO_ACCOUNT_INVALID;}// 4. 校验余额if (fromAccount.getBalance().compareTo(giroReq.getAmount()) < 0) {return GiroResult.INSUFFICIENT_BALANCE;}// 5. 执行余额更新fromAccount.setBalance(fromAccount.getBalance().subtract(giroReq.getAmount()));toAccount.setBalance(toAccount.getBalance().add(giroReq.getAmount()));accountMapper.updateById(fromAccount);accountMapper.updateById(toAccount);// 6. 更新交易状态为成功fromAccount.setTransactionStatus(giroReq.getTransferId(), TransactionStatus.SUCCESS);return GiroResult.OK;
}
4. 一些思考
4.1 锁机制的缺陷
由于时间的关系,加锁代码实现的并不完美
- 简化锁机制的实现
为了防止setnx和expire命令的非原子性问题,采用了将过期时间写在redis value中,但其实可以用set(可以同时实现setnx和expire命令,且是原子执行)命令代替。
- 存在锁过期的风险
当reids value中时间的值<当前时间则代表锁过期, 但是转账的事务可能在此并未执行完毕,将会导致锁失效。 解决方案:可以参考Redisson实现Redis分布式锁的原理中的lock()方法,可以通过不断刷新等机制使得锁保持有效。
4.2 日志系统保存关键数据
日志文件的特点:允许丢失,记录日志行为不能影响系统业务执行。 文中的代码并未对关键数据(如ip等数据)进行保存,这时可以引入MQ,在关键时刻将重要数据发送至MQ,由日志系统监听MQ消费并落表日志信息。 采用RocketMQ单向发送消息 这种方式主要用在日志发送等不特别关心发送结果的场景。
4.3 mysql相关设置
4.3.1 快照读还是当前读?
本系统如果采用快照读将会引起余额不准问题 通过当前读+行写锁机制保证了数据更新的准确性
4.3.2 数据读取会不会有延迟问题
这个问题与实际的数据库架构设计有关,海量交易数据一定采用多库多表的设计方案,读和写都在一张表,所以不会存在数据读取延迟问题。
4.4 系统的演进
4.4.1 转账行为拆分:提高系统响应速度和容错性
将com.alipay.account.accountservice.service.AccountService#giro
中的转账行为分为
- 参数校验和账户状态校验
- 实际转账
时序图如下
与1.3中设计的时序图相比引入
- 提高接口响应速度 账户系统在完成信息检验后响应调用方 参数校验未通过:告知具体原因(账户被封,余额不足等) 校验通过:告知转账正在进行中
- 引入MQ完成系统解耦 将日志记录等行为与账户系统解耦且增加了系统的可扩展性(其它系统可通过监听消息等方式参与到整个流程中),提交转账申请后直接返回转账中,具体操作等待MQ消费
- 引入MQ提高系统容错性 在消息未被正常消费时,可利用MQ的重试机制,重新消费消息。 备注:成功修改转账状态后才告MQ系统,消息消费成功
注:思路基于https://juejin.cn/post/6981422345630482446文章