朋友们,今天想聊聊一个我最近从零搭起来的项目,代码仓库名字就叫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-starter:ORM 框架,操作数据库。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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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. 乐观锁更新余额,version+1,如果更新行数为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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 对账差异表 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;对账任务跑起来之后,财务同事终于从“每天手动拉表核对”里解放出来了。我的经验是,刚上线阶段每天必须人工复核差异工单,跑满一个月形成规律后,再慢慢信任自动化处理。别一上来就全自动,容易出事。
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上线到现在快五个月了,最直观的感受就是账务终于能睡个安稳觉了。以前最怕半夜收到财务转发的对账不平消息,现在即使有问题,也能在几十分钟内定位到具体环节,而不是翻开十几个服务日志大海捞针。
如果后续想把这套东西做得更完善,我建议从三个方向扩:一是加一个统一的事件中心,把交易成功、退款完成这些事件广播给所有下游,方便做积分、通知、分析;二是引入全链路压测,金融服务性能和稳定性是底线,提前摸清系统瓶颈在哪里;三是把配置中心里的风控规则做成可视化平台,让运营自己调整策略,减少研发介入成本。
最后分享一个个人经验。做金融类项目,不要把“花里胡哨”当成目标。稳定、可追溯、可解释,比任何高性能优化都重要。哪怕牺牲一点吞吐量,也要保证每一分钱都算得清楚、每一笔账都记得明白。记住,金融系统的核心不是技术,是信任。你维护的每一行代码,背后都是用户的真金白银。咱们这行,战战兢兢是常态,但踏实做事,账目清清楚楚,就是最大的成就。