开发思路篇:转账接口设计

设计一个转账接口

基础功能需求

  • 用户 A 给用户 B 转账 X 元。
  • 保证资金安全(不能出现金额丢失或多扣)。
  • 保证事务完整性(要么都成功,要么都失败)。

核心流程:

1.校验请求参数(金额>0,A不等于B)

2.校验A账户余额是否足够

3.从A扣钱

4.给B加钱

5.写入转账记录

1. 问题描述、梳理及时序图

1.1 问题梳理

a. 明确转账接口涉及的范围 一个完整的转账流程:

  1. 交易系统:生成订单号
  2. 风控系统:风控系统判断此交易是否合理
  3. 账户系统:进行实际的转账操作
  4. 日志系统:对整个过程进行记录(包括交易的账号、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. 表结构的设计

sql 复制代码
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 交易系统的设计和流程

java 复制代码
**
 * @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中将一个完整的转账流程分为以下四个系统

  1. 交易系统: 生成交易号,更新交易状态。
  2. 风控系统:风控系统判断此交易是否合理
  3. 账户系统: 进行实际的转账操作
  4. 日志系统:对整个过程进行记录(包括交易的账号、ip和风控评判等执行过程的记录) 因为简易的系统模块

2.2 账户系统的设计和流程

java 复制代码
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 做key
           RedisLock 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语句即可保障安全

  1. 加锁的时机 有两种方案可以选择:
  • a.先做幂等再加锁?
  • b.先加锁再做幂等? 探讨:可以看出,如果采用第一种,在并发场景下幂等将可能失效 结论:先加锁再做幂等。
  1. 对账户信息表的操作

转账的核心代码存在于com.alipay.account.accountservice.service.AccountService#giroCore

3. 代码设计

3.1 查询转出/转入账户信息

java 复制代码
  // 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 复制代码
java
 采用mybatis-plus操作
     accountMapper.updateById(fromAccount);
     accountMapper.updateById(toAccount);

3.3转账接口具体实现

关于2.2中实际转账接口的实现,运用到了前面说到的使用for update确保当前都,读取最新数据,以及先加锁再幂等校验(即获取分布式锁后幂等校验,校验当前交易id号对应状态),最后执行更新操作(全部操作被包围在数据库事务中)

java 复制代码
/**
 * 核心转账逻辑
 */
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. 参数校验和账户状态校验
  2. 实际转账

时序图如下

与1.3中设计的时序图相比引入

  • 提高接口响应速度 账户系统在完成信息检验后响应调用方 参数校验未通过:告知具体原因(账户被封,余额不足等) 校验通过:告知转账正在进行中
  • 引入MQ完成系统解耦 将日志记录等行为与账户系统解耦且增加了系统的可扩展性(其它系统可通过监听消息等方式参与到整个流程中),提交转账申请后直接返回转账中,具体操作等待MQ消费
  • 引入MQ提高系统容错性 在消息未被正常消费时,可利用MQ的重试机制,重新消费消息。 备注:成功修改转账状态后才告MQ系统,消息消费成功

注:思路基于 https://juejin.cn/post/6981422345630482446文章

相关推荐
快起来搬砖了2 小时前
实现一个优雅的城市选择器组件 - Uniapp实战
开发语言·javascript·uni-app
带娃的IT创业者2 小时前
实战:用 Python 搭建 MCP 服务 —— 模型上下文协议(Model Context Protocol)应用指南
开发语言·python·mcp
minji...2 小时前
C++ STL之list的使用
开发语言·c++
万粉变现经纪人3 小时前
如何解决pip安装报错ModuleNotFoundError: No module named ‘python-dateutil’问题
开发语言·ide·python·pycharm·pandas·pip·httpx
IT乐手3 小时前
Java 实现异步转同步的方法
java
杨杨杨大侠3 小时前
附录 1:🚀 Maven Central 发布完整指南:从零到成功部署
java·github·maven
Sammyyyyy3 小时前
macOS是开发的终极进化版吗?
开发语言·macos·开发工具
青草地溪水旁3 小时前
23 种设计模式
开发语言·c++·设计模式
草履虫建模3 小时前
在 RuoYi 中接入 3D「园区驾驶舱」:Vue2 + Three.js + Nginx
运维·开发语言·javascript·spring boot·nginx·spring cloud·微服务