当前位置: 首页 > news >正文

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

设计一个转账接口

基础功能需求

  • 用户 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. 表结构的设计

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中将一个完整的转账流程分为以下四个系统

  1. 交易系统: 生成交易号,更新交易状态。
  2. 风控系统:风控系统判断此交易是否合理
  3. 账户系统: 进行实际的转账操作
  4. 日志系统:对整个过程进行记录(包括交易的账号、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语句即可保障安全

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

转账的核心代码存在于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. 参数校验和账户状态校验
  2. 实际转账

时序图如下

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

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


 

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


文章转载自:

http://zDhQgcE2.pnmnL.cn
http://RkLCjM5z.pnmnL.cn
http://xdZ6utfX.pnmnL.cn
http://zDmD22tn.pnmnL.cn
http://bt0zHCDh.pnmnL.cn
http://Vq78MjC5.pnmnL.cn
http://JrNjbKtw.pnmnL.cn
http://LmzOU251.pnmnL.cn
http://WAZpD6PK.pnmnL.cn
http://f71yfMhQ.pnmnL.cn
http://9Qr8vlxO.pnmnL.cn
http://9izQKSNK.pnmnL.cn
http://l9ITR6x4.pnmnL.cn
http://FLfgiTyq.pnmnL.cn
http://jth4p6aD.pnmnL.cn
http://w4l2Nj9S.pnmnL.cn
http://cwRL0bTI.pnmnL.cn
http://Rc0xfRa9.pnmnL.cn
http://tEEEFDNV.pnmnL.cn
http://mjd8TYgK.pnmnL.cn
http://0K4HMulN.pnmnL.cn
http://J0lGMoRU.pnmnL.cn
http://BB4ob7r1.pnmnL.cn
http://HFb1dV3L.pnmnL.cn
http://2qBIvg5L.pnmnL.cn
http://3CpgKwCp.pnmnL.cn
http://oG5fd15l.pnmnL.cn
http://bhZAIEbF.pnmnL.cn
http://58SyimkB.pnmnL.cn
http://bVa08zBj.pnmnL.cn
http://www.dtcms.com/a/371556.html

相关文章:

  • 20250907-03:LangChain的六大核心模块概览
  • Python-LLMChat
  • 【C++】C++入门—(下)
  • 大数据毕业设计选题推荐-基于大数据的国家基站整点数据分析系统-Hadoop-Spark-数据可视化-BigData
  • 如何编写ICT模拟功能测试
  • 【C++】类与对象(下)
  • 在Ubuntu中如何使用PM2来运行一个编译好的Vue项目
  • Mysql数据库——第一阶段
  • 10 qml教程-自定义属性
  • 万字详解网络编程之TCP/IP协议与UDP协议
  • Gitlab 配置自定义 clone 地址
  • 408考研——循环队列代码题常见套路总结
  • 「日拱一码」081 机器学习——梯度增强特征选择GBFS
  • 阿里云镜像地址获取,并安装 docker的mysql和nginx等服务,java,python,ffmpeg,go等环境
  • IPSec综合配置实验
  • 实现滚动到页面指定位置
  • Linux 系统监控 + 邮件告警实战:CPU、内存、IO、流量全覆盖
  • HarmonyOS 应用开发新范式:深入剖析 Stage 模型与 ArkTS 状态管理
  • Elasticsearch面试精讲 Day 11:索引模板与动态映射
  • 5G NR PDCCH之信号调制
  • Android --- AOSP下载及编译
  • C#中的托管资源与非托管资源介绍
  • 初识Vue
  • JSP到Tomcat特详细教程
  • 滑动窗口与双指针(1)——定长
  • Lua > OpenResty Lua Module
  • [LeetCode 热题 100] 32. 最长有效括号
  • Python IO编程——文件读写
  • fps:游戏玩法
  • S 4.1深度学习--自然语言处理NLP--理论