首页 / 资讯中心 / 文章详情

从零搭建金融微服务:账户、流水、幂等与对账的完整实操指南

从零搭建金融微服务:账户、流水、幂等与对账的完整实操指南 ★ FEATURED ARTICLE
朋友们今天想聊聊一个我最近从零搭起来的项目代码仓库名字就叫financial-services。这两年金融科技赛道大家都懂传统的单体记账、支付逻辑被拆出来做成独立服务几乎成了标配。我手头这个项目就是把散落在各个业务系统里的账户、流水、交易、对账、风控判断全部收拢到一个独立的金融服务模块里统一对外提供能力。项目跑起来之后不仅业务方调用省事了连财务对账、审计追溯都顺了很多。这篇文章想把这套东西的完整设计思路、踩坑过程、技术选型逻辑都摊开讲清楚内容包括账户模型怎么建、金额精度怎么处理才能不出差错、幂等要怎么设计才能防住重复支付、对账任务怎么落以及风控和敏感数据保护怎么在不影响性能的前提下做扎实。如果你正打算把公司的账务、支付、钱包这类能力独立成服务或者已经在做但遇到不少诡异的线上问题这篇应该能给你一些直接能抄作业的方案。1. 项目整体设计与模块边界1.1 为什么单独拆一个“金融服务”模块先说背景。我所在的业务线之前账户和订单是高度耦合的。用户下单、支付回调、退款、提现逻辑散落在三四个微服务里每个服务各维护一份余额字段谁都能改钱。财务同事隔三差五发现两边数字对不上追查流水要翻好几个系统。后来流量上来问题更明显并发高的时候有些服务里直接select ... for update锁行把整个库的写性能拖垮了。拆出financial-services这个独立服务的核心逻辑很简单钱相关的事情必须收敛到一个地方管。所有业务方都不能直接改余额只能通过这个服务提供的交易接口来操作资金。谁想动账先得过账务流水流水不可变余额只由流水汇总而来。这样一来账不会乱审计和回溯也有唯一的入口。理论上讲就算上层业务逻辑出问题底层资金账本还是干净的这比到处救火重要得多。1.2 业务需求拆解与功能边界当时梳理需求和产品、财务、运营拉了好几轮会最后把功能边界定成五大块账户管理开户、销户、冻结、解冻余额查询账户状态机维护。交易处理充值、消费、退款、转账、提现统一走一套交易内核。流水与对账所有资金变动写流水日终和第三方支付渠道、银行做对账。风控规则交易频次、金额阈值、设备指纹等维度的实时判断。合规审计操作日志、敏感数据脱敏、全链路链路追踪标识。业务方只能通过注册中心的 API 网关调用这些能力底层数据库只对金融服务模块开放别的服务连连接串都拿不到。这个边界从一开始就卡死了后面避免了很多扯皮。1.3 整体架构与技术选型考量架构上没搞花活就是主流的微服务模式Spring Cloud 体系做服务注册发现和配置管理交易链路放在一个独立的应用里异步任务对账、批量结算单独拆了一个 worker 进程。存储层用了 MySQL 做主库加从库账务相关的表按用户维度做了分片缓存用的 Redis。选型原则就一句话金融场景先求稳再求快。很多人一上来就提分布式事务、消息队列、异步最终一致性实际落地发现复杂度远超预期。我最终采用的是“同步核心链路 异步对账兜底”的组合。交易接口内部用本地事务保证强一致外部跨服务的数据一致性通过状态机加重试加对账来收敛。这套方案在绝大多数互联网金融场景都够用了而且排查问题的时候非常直观。2. 核心细节解析与实操要点2.1 账户体系余额模型与流水设计的取舍经历过账目对不齐的痛苦之后我对账户模型的第一原则就是任何余额字段都不允许直接 update只能通过流水累加/累减。账务系统里最怕的就是“拍脑袋改余额”一旦改了没有留下痕迹审计和核对全废。我用的模型是这样的账户表account保存账户当前余额包含可用余额和冻结余额两个字段。流水表account_journal记录每一笔资金变动字段包含账户号、变动类型加款/减款、变动前余额、变动后余额、业务单号、幂等键、状态等。账户表里的余额可以理解为“冗余缓存”真正有法律效力的是流水。所有交易都在一个本地事务里完成插入流水然后基于账户当前余额加上这笔变动计算出新余额再用乐观锁版本号去更新账户表。一旦更新行数为 0说明有并发冲突整个事务回滚重试。这套逻辑保证不会丢失更新也不会出现余额负数。2.2 金额精度Long还是BigDecimal怎么选金额存储在数据库里绝大多数新手会踩同一个坑decimal类型没问题但用double或float来算钱早晚要出大事。曾经有业务反馈退款之后账不平追查下去发现代码里用double做除法0.1 0.2 不等于 0.3一分钱差额就出来了。在金融场景一分钱错都不行对账差一分钱都能让人查一晚上。我的做法统一如下数据库层面金额字段全部使用decimal精度设成decimal(15, 2)保留两位小数给绝大多数人民币交易用如果需要精算到厘就设四位小数。应用层面Java 代码里统一用BigDecimal所有涉及金额运算的地方禁止出现double或float。BigDecimal构造时注意要用new BigDecimal(19.90)不要用new BigDecimal(19.90)后者拿到的不是你想要的数。传输层面对外 API 统一用字符串传金额防止 JSON 序列化时变成浮点型导致精度丢失。如果你不喜欢小数运算带来的麻烦还有一种方案是“整数分存储”。所有金额在落库前转成“分”为单位的整数用bigint存储。这样加减乘除全是整数运算性能比decimal更好还不存在精度陷阱。我们线上核心账户表就用的整数分配合注释说明团队内部约定好之后很省心。2.3 幂等设计把重复请求拦在门外金融服务里幂等不是可选项而是保命项。网络超时、客户端重试、MQ 消息重复投递都会导致同一个支付请求被执行两次。如果没有幂等保护用户付了一笔钱系统记两笔后果很严重。我实现幂等的思路是“业务单号 状态机 唯一索引”三层配合交易接口入参必须带业务单号比如订单号服务端根据“业务类型 业务单号”生成唯一幂等键。交易表对幂等键建唯一索引第一次请求插入成功后续请求插入时触发唯一约束冲突直接返回第一次执行的结果。交易状态机明确处理中、成功、失败、关闭。只有“处理中”的订单可以重试后续步骤“成功”的直接返回成功响应“失败”的可以允许重置后重新处理。这套方案比单纯用 Redis 的SETNX做分布式锁更可靠因为 Redis 锁在极端情况下可能过期业务长事务数据库唯一索引是最后的硬兜底。实测下来即使同一个请求并发打过来 10 次最终也只有一条流水落账响应全部返回相同结果不会出现任何业务上的重复扣款。3. 实操过程与核心环节实现3.1 项目骨架与依赖准备我用的是 Java 17 Spring Boot 3.2 MyBatis-Plus注册中心和配置中心用的 Nacos网关用的 Spring Cloud Gateway。建项目这块没必要花太多篇幅简单说说依赖层面必须加的几个东西spring-boot-starter-web提供 REST API。spring-boot-starter-validation参数校验金融接口入参必须严格校验。mybatis-plus-spring-boot3-starterORM 框架操作数据库。redisson-spring-boot-starter分布式锁和缓存。spring-boot-starter-data-redis缓存基础包。shardingsphere-jdbc-core分库分表组件早期就接好不然后期迁移成本高。xxl-job或Quartz定时对账任务调度。3.2 核心数据库表设计表结构是金融服务的灵魂。我贴几张核心表的 DDL你们直接拿去改改就能用。-- 账户表 CREATE TABLE t_account ( id bigint(20) NOT NULL AUTO_INCREMENT, account_no varchar(32) NOT NULL COMMENT 账户编号, user_id varchar(64) NOT NULL COMMENT 用户ID, account_type tinyint(4) NOT NULL DEFAULT 1 COMMENT 账户类型:1-余额账户,2-冻结账户,3-积分账户, balance bigint(20) NOT NULL DEFAULT 0 COMMENT 可用余额单位分, frozen_balance bigint(20) NOT NULL DEFAULT 0 COMMENT 冻结余额单位分, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, status tinyint(4) NOT NULL DEFAULT 1 COMMENT 状态:1-正常,2-冻结,3-注销, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_account_no (account_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账户表; -- 交易流水表 CREATE TABLE t_account_journal ( id bigint(20) NOT NULL AUTO_INCREMENT, journal_no varchar(32) NOT NULL COMMENT 流水编号, account_no varchar(32) NOT NULL COMMENT 账户编号, user_id varchar(64) NOT NULL COMMENT 用户ID, biz_type varchar(32) NOT NULL COMMENT 业务类型:CHARGE-充值,CONSUME-消费,REFUND-退款,TRANSFER-转账,WITHDRAW-提现, biz_no varchar(64) NOT NULL COMMENT 业务单号, direction tinyint(4) NOT NULL COMMENT 方向:1-加款,-1-减款, amount bigint(20) NOT NULL COMMENT 变动金额单位分, balance_before bigint(20) NOT NULL COMMENT 变动前余额, balance_after bigint(20) NOT NULL COMMENT 变动后余额, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 状态:0-处理中,1-成功,2-失败,3-关闭, idempotent_key varchar(128) NOT NULL COMMENT 幂等键, remark varchar(255) DEFAULT NULL COMMENT 备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_idempotent_key (idempotent_key), KEY idx_account_no (account_no), KEY idx_biz_no (biz_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT账务流水表;流水表核心约束就两个journal_no全局唯一用来对外响应idempotent_key唯一用来防重。其他字段全是为审计和对账服务的宁可多存不要少存。3.3 交易处理流程写代码不是最难的部分下面这段是我在TransactionService里实现的核心交易逻辑以消费为例注释里写了每一步在干什么。Service public class TransactionService { Resource private AccountMapper accountMapper; Resource private AccountJournalMapper journalMapper; Transactional(rollbackFor Exception.class) public void consume(String userId, String accountNo, long amount, String bizNo, String remark) { // 1. 生成幂等键后续请求带有相同bizNo时直接忽略 String idempotentKey CONSUME: bizNo; // 试图插入一条处理中的流水如果唯一键冲突说明重复请求直接返回或抛幂等异常 try { AccountJournal journal buildJournal(accountNo, userId, CONSUME, bizNo, -1, amount, idempotentKey); journalMapper.insert(journal); } catch (DuplicateKeyException e) { throw new BizException(REPEAT_REQUEST, 重复请求请勿提交多次); } // 2. 加分布式锁防并发锁粒度要做到accountNo级别 RLock lock redissonClient.getLock(LOCK:ACCOUNT: accountNo); boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (!locked) { throw new BizException(SYSTEM_BUSY, 系统忙请稍后重试); } try { // 3. 乐观锁更新余额version1如果更新行数为0表示有并发更新重试 Account account accountMapper.selectByAccountNo(accountNo); if (account.getStatus() ! 1) { throw new BizException(ACCOUNT_FROZEN, 账户状态异常); } if (account.getBalance() amount) { throw new BizException(BALANCE_NOT_ENOUGH, 余额不足); } long balanceAfter account.getBalance() - amount; int updated accountMapper.compareAndSetBalance( accountNo, account.getVersion(), account.getBalance(), balanceAfter, account.getVersion() 1); if (updated 0) { throw new BizException(CONCURRENT_CONFLICT, 并发更新冲突请重试); } // 4. 更新流水状态为成功并补上变动前后余额 journalMapper.markSuccess(idempotentKey, account.getBalance(), balanceAfter); } finally { lock.unlock(); } } }注意代码里有两个“锁”Redis 分布式锁负责挡住并发数据库的乐观锁保证最终一致性。很多人可能会问有乐观锁为什么还要 Redis 锁因为乐观锁在并发高时会导致大量线程回滚重试浪费数据库资源用分布式锁先挡一层把真正进入事务的线程数降到个位数。虽然极端情况下可能锁超时有问题但配合唯一索引兜底账不会错。3.4 日终对账把出错的钱找回来对账是整个金融服务最容易忽略、但又最重要的模块。上线第一个月我几乎天天被对账问题折磨这里给出相对完整的实现思路。对账任务我分成三步走拉取渠道账单每天定时从微信支付、支付宝、银行等渠道下载前一天的结算文件解析成统一的交易明细。双边匹配用平台流水表的bizNo对应渠道的订单号和渠道明细做匹配。匹配上且金额一致的自动完成匹配上但金额不一致的标记为“金额差异”平台有但渠道没有的标记为“长款”渠道有但平台没有的标记为“短款”。差异处理系统自动把差异数据导出成工单推送相关人员处理。核心匹配代码用不上但有两张表建议必须有-- 渠道对账文件表 CREATE TABLE t_channel_statement ( id bigint(20) NOT NULL AUTO_INCREMENT, channel_code varchar(32) NOT NULL COMMENT 渠道编码, statement_date varchar(16) NOT NULL COMMENT 对账日期, file_url varchar(255) NOT NULL COMMENT 文件地址, total_count int(11) NOT NULL DEFAULT 0, total_amount bigint(20) NOT NULL DEFAULT 0, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-新建,1-解析中,2-解析完成,3-解析失败, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 对账差异表 CREATE TABLE t_reconcilation_diff ( id bigint(20) NOT NULL AUTO_INCREMENT, channel_code varchar(32) NOT NULL, statement_date varchar(16) NOT NULL, biz_no varchar(64) NOT NULL, platform_amount bigint(20) DEFAULT NULL, channel_amount bigint(20) DEFAULT NULL, diff_type varchar(16) NOT NULL COMMENT LONG-长款,SHORT-短款,AMOUNT_DIFF-金额差异, handle_status tinyint(4) NOT NULL DEFAULT 0, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_diff_type (diff_type) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;对账任务跑起来之后财务同事终于从“每天手动拉表核对”里解放出来了。我的经验是刚上线阶段每天必须人工复核差异工单跑满一个月形成规律后再慢慢信任自动化处理。别一上来就全自动容易出事。4. 风控与数据安全设计4.1 风控规则引擎的落地思路金融服务不接风控就是裸奔。我之前见过一个系统用户充了 100 笔一分钱把优惠活动薅秃了最后损失不小。风控模块我做了两层的设计第一层是实时风控在交易入口处同步跑一组轻量规则。比如单笔金额是否超过阈值、用户当日交易笔数是否超限、用户当日交易总额是否超限、设备是否在黑名单等。这组规则要控制在 5ms 内完成所以用本地缓存存规则配置用 Redis 的原子自增计数来做频次统计。第二层是离线风控异步处理通过消息队列把交易事件发给风控分析服务结合更复杂的特征判断关系图谱、历史行为产出可疑交易名单再更新到黑名单缓存中供实时风控读取。实时判断的伪代码如下public boolean riskCheck(String userId, long amount, String deviceId) { // 1. 单笔限额 if (amount riskConfig.getMaxSingleAmount(userId)) { return false; } // 2. 当日累计笔数 String dayKey RISK:COUNT: userId : LocalDate.now(); Long count redisTemplate.opsForValue().increment(dayKey); if (count ! null count riskConfig.getMaxDailyCount(userId)) { return false; } // 3. 设备黑名单 if (riskConfig.isDeviceInBlacklist(deviceId)) { return false; } return true; }注意这里 Redis 的increment和expire要在一个事务里完成或者用 Lua 脚本避免忘记设置过期时间导致 key 永久残留。这个坑我踩过第一次上线当天 Redis 内存就报警了。4.2 数据加密与敏感字段脱敏金融服务的数据安全从库表设计阶段就要想清楚。哪些字段属于敏感字段身份证号、手机号、银行卡号、姓名、地址这些都是。处理方式我分三级存储加密身份证号、银行卡号这类需要回查原文的字段用 AES 加密后落库。密钥放在配置中心或 KMS定期轮换。脱敏展示不需要原文的业务场景里查询出来后要做脱敏比如只显示前 3 后 4。可以写一个统一序列化注解接口层自动脱敏。权限管控能看明文数据的接口必须走独立的授权校验操作记录写审计日志。数据库层面我额外用了“敏感字段加密插件”但这块不建议自己造轮子直接用开源的比如 Jasypt 或者 ShardingSphere 的加密功能都行。关键是加密密钥绝对不能写在代码里也不能提交到 Git 仓库从我经验来说用 Nacos 配置中心加密文配置是最省心的。4.3 审计日志出了事要能追溯审计和日志不一样。业务日志可能被清理审计日志要有独立的存储最好是只追加不能修改。我们设计的是每次交易接口调用生成全局唯一的traceId从网关入口透传到整个链路。所有账务操作不管成功失败都往审计表里写一条记录包含操作人或调用系统、操作类型、请求参数脱敏后、响应结果、耗时、traceId。审计表禁止 update 和 delete谁要改走流程审批由 DBA 执行特殊操作并留痕。不要嫌审计表写得多真出了纠纷用户投诉说扣错钱了这张表就是最有力的技术证据。有一次用户反馈一笔扣款没收到货客服查业务系统查不到订单最终就是靠审计日志里的 traceId 关联出完整的请求链路定位到是上游系统异常导致回调参数缺失把问题解释清楚了。5. 常见问题与排查技巧实录5.1 金额精度丢失差一分钱折腾一晚上这是我最早踩的坑几乎每个金融项目都会遇到。现象一笔 9.9 元的订单退款后账户余额成了 990.12而系统日志里 $9.9 \times 2 19.8$ 竟然输出 19.800000000000004。原因就是代码里某个地方用了double强转。排查思路很简单全局搜索金额相关的变量类型凡是出现double、float的一律改掉数据库字段也统一检查。另外前端传过来的金额后端必须用字符串接收再转BigDecimal防止 JSON 反序列化时变成浮点数。这里有个小技巧在 Jackson 的配置里对金额字段加上自定义反序列化器或者干脆全局配置JsonFormat(shape JsonFormat.Shape.STRING)就能强制金额以字符串形式传输。5.2 并发扣款导致余额为负某个活动上线后用户集中抢购出现一批余额变成负数的情况。定位后发现是扣款逻辑只有三步查余额 - 判断够不够 - 扣款。在并发场景下两个线程同时查到余额 100都认为足够支付 60于是都执行扣款结果余额变成 -20。解决方式就是前面 3.3 节提到的“乐观锁 分布式锁”双保险。核心点记住一句话余额判断和余额更新必须放在同一个原子操作里。用update ... where version ?或者where balance amount作为条件更新更新行数为 0 就说明条件不满足直接回滚。UPDATE t_account SET balance balance - #{amount}, version version 1 WHERE account_no #{accountNo} AND version #{version} AND balance #{amount}这条 SQL 天然防超扣。如果更新行数为 0就可以给业务方一个明确的错误码客户端展示“余额不足”。5.3 对账不平和渠道差出 0.01 元对账差异是金融项目里最磨人的问题。我的经验是绝大多数金额差异都是因为舍入规则不一致。卖家结算、优惠分摊、退款手续费每个环节都涉及舍入。渠道可能用四舍五入我们可能用银行家舍入两边结果自然差一分钱。解决方案建一个“舍入规则配置”对接每一个渠道时明确对方的舍入方式代码里统一封装一个MoneyRounder工具类。如果两边无论如何都不能完全对齐那就设定一个合理的容差范围比如单笔差异小于等于 0.01 元自动标记为“一致”否则进入人工处理。对账这件事不必追求绝对零差异关键是差异可解释、可处理、不滚雪球。5.4 热点账户的锁竞争转账场景经常出现“大账户”问题比如平台手续费账户、头部用户的账户所有交易都要动这个账户的行导致大量线程在等待锁TPS 上不去。一种做法是账户加“滑动余额”或“并行子账户”设计把一个大账户拆成 N 个子账户资金随机分配子账户查询时汇总。但这也带来了复杂度主要是余额一致性维护更麻烦。我的建议是如果业务量没到单账户每秒几千笔扣款的程度不要轻易上子账户方案。先检查是不是锁粒度问题——把分布式锁的粒度从userId改成accountNo把非资金查询的接口不走锁配合本地缓存降低数据库压力基本能缓解大部分瓶颈。5.5 常见问题速查表现象常见原因处理建议余额负数扣款判断与更新非原子where 条件带上 balance amount重复入账幂等不完善业务单号唯一索引兜底对账长款我方订单成功但渠道未成功主动发起退款或人工介入对账短款渠道成功但我方回调丢失重拉回调补账状态机驱动金额不对浮点运算统一用整数分 BigDecimal账户锁等待超时锁粒度太大锁粒度细化到账户维度风控误杀规则过于激进规则配置中心支持灰度放量缓存数据不一致缓存未及时删除更新数据库后主动删缓存双删策略6. 上线之后的扩展与个人体会financial-services上线到现在快五个月了最直观的感受就是账务终于能睡个安稳觉了。以前最怕半夜收到财务转发的对账不平消息现在即使有问题也能在几十分钟内定位到具体环节而不是翻开十几个服务日志大海捞针。如果后续想把这套东西做得更完善我建议从三个方向扩一是加一个统一的事件中心把交易成功、退款完成这些事件广播给所有下游方便做积分、通知、分析二是引入全链路压测金融服务性能和稳定性是底线提前摸清系统瓶颈在哪里三是把配置中心里的风控规则做成可视化平台让运营自己调整策略减少研发介入成本。最后分享一个个人经验。做金融类项目不要把“花里胡哨”当成目标。稳定、可追溯、可解释比任何高性能优化都重要。哪怕牺牲一点吞吐量也要保证每一分钱都算得清楚、每一笔账都记得明白。记住金融系统的核心不是技术是信任。你维护的每一行代码背后都是用户的真金白银。咱们这行战战兢兢是常态但踏实做事账目清清楚楚就是最大的成就。
阅读完成 · 觉得有帮助?
咨询建站