## 1. 先看清借款与投资在一条业务链上各自扮演的角色 大概半年前,我接到一个做金融撮合类项目的需求,需求方给出的初始描述很简短:把借款业务和投资业务发布上线。放在当时的情境里,这句话听起来像两套独立的CRUD——借款端用户申请、后台审核、放款,投资端用户浏览标的、投钱、等回款。但真正把数据库表字段和状态流转画出来之后,才发现这两个业务是缠绕在同一条资金链路里的,任何一个环节的口径不一致,都会导致金额对不上账。 这篇文章要讲的,就是我当时怎么从零把「发布借款业务」和「发布投资业务」这两个看似独立的功能,设计成一套能自洽运转的撮合系统。这里面包含了核心数据模型、状态机设计、金额占用与释放、并发投标签约方案,以及我实际开发中踩过的坑。适合正在做金融类项目、或者准备接手借贷撮合平台的开发者参考,读过之后至少能避开我第一次设计时走过的弯路。 先说清楚一个容易混淆的点:借款业务和投资业务不是上下游关系,而是同一笔资金的两种视角。 - 从借款人视角看:申请额度 → 创建借款标的 → 通过审核 → 满标放款 → 按期还款。 - 从投资人视角看:浏览在投标的 → 确认投标 → 资金冻结 → 满标划转 → 按计划回款。 两头操作的是同一张借款标的表,只是各自站在自己账户的一侧操作。所以真正要设计的核心不是「两个业务模块」,而是一套统一的标的生命周期,以及围绕这套生命周期的资金账逻辑。 ### 1.1 一张表看懂借款端和投资端的操作视角 我在项目初期画过一张双角色链路对照表,这张表后来成了跟产品和前端对齐口径的基础。这里直接放出来: | 流程阶段 | 借款人(借款端) | 投资人(投资端) | 核心动作 | |---|---|---|---| | 创建阶段 | 填写借款金额、利率、期限,提交申请 | 浏览标的列表,查看可投金额与进度 | 借款表单校验、标的状态初始化 | | 审核阶段 | 等待风控审核,额度处于冻结状态 | 此时看不到该标的,或者只能看到预告 | 审核通过/拒绝,释放或确认额度 | | 投标阶段 | 等待投标满标,不可修改借款信息 | 输入金额、提交投标,资金实时冻结 | 账户扣减、标的进度更新 | | 满标阶段 | 等待放款入账 | 等待放款,冻结资金等待划转 | 状态变更、批量资金划转 | | 还款阶段 | 按期还款 | 按计划收到回款 | 还款计划、还款流水 | 这张表看起来简单,但它隐含了几个重要的设计约束:借款审核期间,标的不能让投资人看到;投标阶段,借款人不能修改任何关键字段;满标之后,任何人都不能再往里面投钱。这些约束不是靠前端按钮禁用实现的,而是靠标的表里的状态字段在接口层做硬校验。 ### 1.2 标的(loan)是串联两端的核心实体 这个项目里最核心的一张表不是用户表,也不是账户表,而是借款标的表。我习惯把它叫做 `loan`,也可以叫 `project`。它承载的信息分三类:借款信息、投资进度、状态控制。 借款信息包括借款金额、年化利率、期限、还款方式;投资进度包括已投金额、可投金额、投资笔数;状态控制包括标的当前所处的生命周期阶段。这三类信息放在一张表里,是为了读写一致性——投资人看到的可投金额,必须和借款人发起的借款金额是同一个数据来源,不能一张表存一份。 标的表在设计时还要冗余一个字段叫 `available_amount`(可投金额),这个后面会专门讲。这里先记住一句话:**标的表是所有资金操作的唯一事实来源**,投标扣减、放款划转、流标解冻,全部围绕这个标的的状态和金额展开。 ### 1.3 状态机没定清楚之前,别急着写接口 我初版设计时犯过一个典型错误:先写了借款创建和投标两个接口,再回头补状态流转。结果就是每个接口里都散落着 `if (status == 1 || status == 2)` 这种临时判断,后面加一个满标事件的时候,几乎把所有接口都改了一遍。 后来我做的第一件事,就是把状态机完整地列成一张流转表,贴在项目文档最前面: | 当前状态 | 允许的操作 | 下一状态 | 触发条件 | |---|---|---|---| | 草稿 | 提交审核 | 风控审核中 | 借款人提交申请 | | 风控审核中 | 审核通过 | 投标中 | 后台审核通过,标的上线 | | 风控审核中 | 审核拒绝 | 已拒绝 | 后台审核不通过 | | 投标中 | 投资人投标 | 投标中 | 未满标,继续投资 | | 投标中 | 满标 | 满标待放款 | 已投金额等于借款金额 | | 投标中 | 流标 | 已流标 | 投标截止时间到达且未满标 | | 满标待放款 | 放款成功 | 还款中 | 资金划转完成 | | 还款中 | 还款完成 | 已完成 | 最后一期还款结清 | 有了这张表,我再写接口的时候,每个写操作都是一条状态迁移动作:先校验当前状态是否允许迁移,再执行迁移,同时在一条事务里完成资金变动。这样后续无论加多少个新操作,都是在状态机的框架里加一条边,而不是在代码里打补丁。 ## 2. 借款发布:从申请校验到额度占用的完整链路 借款业务的「发布」不只是把一条记录插入数据库,它本质上是把一笔借款意愿变成一个可用于投资的标的资产。这个过程我拆成了六个动作:额度校验、占用额度、表单落库、风控审核、状态置为投标中、可投金额初始化。每一步缺失,都会在后续投资阶段以各种隐蔽的方式暴露问题。 ### 2.1 借款发布的六个关键动作 以一个具体场景为例:某借款人A在平台上申请发布一笔 100,000 元的借款,年化利率 10%,期限 12 个月。这条链路在前端是用户填一个表单,在后端则要依次完成: 1. 校验借款人账户状态正常,校验借款金额是否在平台限额内。 2. 读取借款人授信额度,确认可用授信大于等于 100,000 元。 3. 冻结可用授信额度,保证这笔借款在审核期间不会占用到其他借款申请上。 4. 创建借款标的记录,初始状态设为「草稿」或「风控审核中」。 5. 推送风控审核任务,审核通过后将状态置为「投标中」,此时标的才进入投资池。 6. 初始化标的的可投金额为 100,000 元,进度为 0。 这里第二步和第三步,是最容易写出并发问题的两个动作。 ### 2.2 额度占用为什么必须用数据库原子更新 很多第一次做资金项目的开发者,会先 `select available_quota` 查一下余额,然后在代码里判断够不够,够就执行 `update`。这个模式在单机低并发下没问题,一旦同时有两笔借款申请,两个请求都查出可用额度是 100,000 元,也都判断够用,然后各自插入一条 60,000 元的借款申请,额度就超发了。 正确的做法是把「检查额度 + 扣减额度」合并成一条原子 SQL: ```sql UPDATE borrower_quota SET frozen_quota = frozen_quota + #{loanAmount}, available_quota = available_quota - #{loanAmount} WHERE user_id = #{userId} AND available_quota >= #{loanAmount}这条 SQL 如果影响行数为 0,说明额度不足或账户不存在,直接返回「可用授信不足」的提示;如果影响行数为 1,说明额度成功占用,可以继续后面的落库逻辑。数据库的行锁保证了并发场景下不会超占额度。
这个思路在所有资金类系统中几乎是通用的:凡是「先查再扣」的动作,都要改成条件更新的形式,把检查条件直接放进 update 的 where 子句里。
2.3 风控审核期间,额度先冻结不释放
为什么发布借款要先冻结额度,而不是审核通过后再扣?因为审核需要时间,而借款人的授信额度是有限的。如果不冻结,借款人同时提交多笔申请,每一笔审核通过后都去扣可用额度,照样会超发。
冻结的设计相当于把额度池子从「可用」划到了「在途」:这笔授信正在被某次申请占用,但它还不能被放款使用。等到审核通过,frozen_quota要转成used_quota,才算真正被这笔借款占用;如果审核拒绝,则要把冻结的金额回补到available_quota,让借款人可以重新发起申请。
我见过有人图省事,把「冻结」做成内存标记,或者用独立的冻结记录表。对于单体应用,直接在额度表上维护frozen_quota和used_quota两个字段是最简单可靠的方案,查询额度汇总时用total_quota = available_quota + frozen_quota + used_quota来校验,很容易发现数据异常。
2.4 可投金额字段:冗余出来而不是现算现卖
标的创建时,loan.available_amount会初始化为借款金额。每次投资人投标成功,这个字段就减去投标金额;流标时则恢复为借款金额。有人会问,为什么不每次查询时用「借款金额 - 已投金额」实时计算?答案是性能与锁竞争。
实时计算意味着每次投资人列表页刷新,都要对已投金额做一次聚合查询。更关键的是,如果可投金额是算出来的,那么在投标事务里就无法用条件更新来保证「不投超」,必须在应用层加锁,反而更加复杂。
冗余available_amount字段的代价是写操作多了,但换来的是查询简单和条件更新可控。投标时的核心 SQL 就变成了:
UPDATE loan SET available_amount = available_amount - #{investAmount}, invested_amount = invested_amount + #{investAmount}, version = version + 1 WHERE loan_id = #{loanId} AND status = 'INVESTING' AND available_amount >= #{investAmount}同样的模式,和额度占用那一步是一脉相承的条件更新思路。到这里,借款发布这条链路就闭环了:额度占用完毕,审核通过,标的状态是「投标中」,可投金额完整,就等着投资人入场。
3. 投资投标:资金冻结、进度扣减与并发下的最后满标
投资业务的核心不是「把钱花掉」,而是「把钱锁住」。投资人提交投标的那一刻,这笔钱要从可用余额转到冻结余额,直到标的放款或者流标,才能转成真实划转或解冻。这个「先冻结、后划转」的模型,是整个投资业务最需要想清楚的地方。
3.1 投资人从浏览到投标的完整链路
投资人在前端做的事情很直观:打开标的列表,看到一个年化 10%、期限 12 个月、进度 60% 的项目,点进详情,输入 10,000 元,点确认投标。这背后对应后端一串动作:
- 校验标的状态必须是「投标中」。
- 校验投标金额不能超过标的的
available_amount。 - 校验投资人账户可用余额不能低于投标金额。
- 扣减投资人可用余额,增加冻结余额。
- 扣减标的
available_amount,增加invested_amount,同时生成一条投资记录。 - 判断标的
available_amount是否等于 0,若是则触发满标动作。
这里第四步和第五步必须在一个数据库事务里完成,任何一个失败都要整体回滚。很多金额对不上的事故,就是这两步拆成了两个事务,中途抛错导致投资人钱被扣了但标的进度没变。
3.2 账户三栏模型:可用、冻结与总额
投资人的资金账户,我统一用三栏模型来设计,对应的账户表有三个字段:
| 字段 | 含义 | 变动逻辑 |
|---|---|---|
total_balance | 账户总额 | 充值增加、提现减少、放款入账增加、回款增加、还款减少 |
available_balance | 可用余额 | 充值增加、投标时减少、流标解冻增加、回款增加 |
frozen_balance | 冻结余额 | 投标时增加、满标划转时减少、流标时减少 |
这套模型对应到实际生活,就是你把钱放进了一个带「锁定」功能的钱包。投标相当于在钱包里勾选一笔钱设为冻结,谁都不能动;满标放款后,这笔冻结的钱才真正划给借款人;流标则像冻结到期,自动解冻回到可用余额。
这里有一个我踩过的坑:任何情况下,三栏之间都必须满足total_balance = available_balance + frozen_balance。我初期在流标解冻时直接更新了total_balance,导致两边不平,后面排查对账花了不少时间。正确做法是解冻只动后两栏:
UPDATE user_account SET available_balance = available_balance + #{amount}, frozen_balance = frozen_balance - #{amount} WHERE user_id = #{investorId} AND frozen_balance >= #{amount}3.3 最后一份投标的并发处理
投资业务最容易出事故的场景,是「最后一个名额」。假设某个标的三分钟前还剩 5,000 元满标,此时两个投资人同时提交 5,000 元的投标请求,如果处理不当,就会同时成功——可投金额变成负数。
我用的方案是前面提到的条件更新:每笔投标都在 update 语句的 where 条件里带available_amount >= #{investAmount}和status = 'INVESTING'。数据库行锁保证同一时刻只有一个事务能更新这行数据,后执行的那个事务会发现可投金额不足,影响行数为 0,直接报错。
有人会问,要不要用悲观锁select for update把标的一行锁住再做业务判断?在这个场景下其实没必要,条件更新已经把「检查与扣减」原子化了,反而更简单。用一个实际测试数据来说话:同一条标的并发投 200 次、单次 1,000 元,最终invested_amount一定是初始值加 200,000 元,不会有超投发生。
3.4 投资流水记录与幂等约束
投标不能只有账户余额变动,还必须记录流水。我在项目里设计了一张capital_flow资金流水表,字段包括:流水号、用户 ID、业务类型(充值/投标/解冻/放款入账/还款/回款)、变动方向(收入/支出)、发生金额、变动前可用余额、变动后可用余额、关联的标的和投资记录。
这张表有两大作用:一是对账,任何一笔余额变动都可在流水里找到依据;二是排查问题,余额对不上时,只要把目标用户的流水按时间重放一遍,很快能定位到哪一步异常。
还有一个必须提前处理的隐患——重复投标。用户在前端点了一次没反应,再点一次,前端又做了重试,就会产生两笔相同的投资。解决方式是在投资记录表上建唯一索引,比如(user_id, loan_id, invest_no),invest_no由前端生成,同一个语义的投资请求只会落库一次。后端再做一次insert ... on duplicate key update或先查重再插入,双保险。
4. 满标之后:放款划账、流标解冻与状态机收口
投标完成只是投资过程的一半,资金真正从投资人账户流向借款人账户,发生在满标放款环节。这个环节是所有资金动作里面最重的,也是最容易在状态边界上出错的地方。
4.1 满标事件应该在事务边界之外处理
当最后一笔投标把available_amount扣到 0 时,标的已经满标。此时不应该在「投标事务」里直接执行放款。原因很简单:放款涉及批量投资人的冻结资金转出、借款人余额入账、借款合同生成、还款计划生成,整个链路非常长,如果放在投标事务里执行,会拖住投资人的下单速度,而且一旦放款失败,投资人的投标事务也得跟着回滚,体验极差。
我的做法是采用本地消息表:投标事务里只更新标的状态为「满标待放款」,同时插入一条「满标事件」到消息表。随后一个独立的放款处理器扫描消息表,执行真正的放款事务。这样既保证了满标状态的可靠落库,又让放款逻辑异步化,投标事务保持短平快。
4.2 放款事务里的资金流向
放款事务是最能体现「资金一致性」的场景,它必须在一个数据库事务中完成四个动作:
- 将标的状态从「满标待放款」改为「还款中」,防止重复放款。
- 遍历所有投资记录,逐条将投资人的冻结余额清零,对应的资金从平台中间户划向借款人账户。
- 将借款本金合计入账到借款人账户的可用余额。
- 生成借款合同与首期还款计划。
这里的第二个动作要特别注意:不能直接在user_account上做「冻结减、可用加」两步来模拟划转,而应该引入一个平台中间账户,让每一笔资金变动都有来路和去向。比如 10 个投资人各投 1 万元,放款时这 10 万元先各自从投资人冻结账户扣减,汇入平台中间户,再统一从平台中间户入账到借款人账户。这样后续对账时,每一笔钱都能从流水里找到完整的路径。
4.3 流标自动解冻与额度回补
不是所有标的都能满标。如果到了投标截止时间,available_amount仍然大于 0,标的需要进入流标流程。流标的动作有两个方向:投资人的资金解冻,以及借款人的授信额度回补。
对投资人,执行的是批量解冻 SQL:
UPDATE user_account SET frozen_balance = frozen_balance - #{amount}, available_balance = available_balance + #{amount} WHERE user_id = #{userId}对借款人,则是把之前占用的授信额度释放:
UPDATE borrower_quota SET used_quota = used_quota - #{loanAmount}, available_quota = available_quota + #{loanAmount} WHERE user_id = #{userId}这两个操作同样要放在同一个事务里。我用一个定时任务每分钟扫描一次投标中且超过截止时间的标的,先通过条件更新把状态批量置为「已流标」,再逐批处理资金解冻,避免扫单的瞬间和最后一笔投标发生竞态。
4.4 状态机边界:什么操作在什么状态允许
做完满标和流标之后,我把所有接口都对照状态机过了一遍,整理出一份「状态-操作」权限矩阵。这是我最想强调的一点:金融系统的状态不是给前端看的装饰,而是每一个写操作都必须校验的第一道防线。
| 接口操作 | 当前状态要求 | 说明 |
|---|---|---|
| 修改借款信息 | 草稿 | 进入审核前允许修改 |
| 审核通过 | 风控审核中 | 只有审核中的标才能上架 |
| 投标 | 投标中 | 已满标/已流标均不可投 |
| 放款 | 满标待放款 | 重复放款会因状态不匹配被拦截 |
| 流标 | 投标中 | 定时任务专用,按截止时间批量处理 |
我在每个写接口的入口都写了一个checkStatus的公共方法,所有状态校验都收敛在这一处,避免散落在业务代码里。后续产品提需求加一个「撤销标的」的功能,我只要在状态机里加一条「草稿 → 已作废」的迁移,所有接口自动获得拦截能力,不需要再改其他代码。
5. 资金流水、对账与这套系统最容易踩的坑
做了大半年这种类型的项目,最后想系统整理一下落地过程中踩过的高频坑。很多坑不是逻辑复杂,而是细节上的一点点疏忽,等对账时爆发出来,排查成本是最高的。
5.1 金额字段的类型与精度
金融项目里的金额字段,绝对不要用 float 或 double。浮点数在计算机中本身就有精度问题,0.1 + 0.2 都不等于 0.3。数据库层面统一用decimal(20,2),如果对精度要求更高,可以按「分」存储为bigint,也就是所有金额都以整数分为单位,展示时再除以 100。
我在项目里选择的是decimal(20,2)方案,Java 侧对应BigDecimal,禁止用Double做任何金额运算。利率字段建议用decimal(5,4)存储年化利率的小数形式,比如 10% 存为 0.1000,避免出现「10% / 12 个月」在折算时精度漂移的问题。
5.2 多实例定时任务重复扫单
定时任务扫流标、扫满标,在单机部署时很简单。但项目一旦上到多实例部署,两台机器同时扫描同一批标的,就可能出现重复处理。我见过最直观的后果:一个标的被流标两次,投资人资金被解冻两次,账户余额直接翻倍。
解决方式有两种,我实际用的是「任务状态抢占」:每个批次的任务在开始时先执行一条条件更新,把处理中的记录状态改为「处理中」,只有影响行数为 1 的实例才继续处理。比如流标任务的抢占 SQL:
UPDATE loan SET status = 'PROCESSING' WHERE status = 'INVESTING' AND deadline < NOW()执行完这条 SQL 的实例,拿到所有被成功更新的主键,再逐个处理解冻。另一种方式是引入分布式锁,但如果只是为了防重复,状态抢占已经足够,而且和业务语义天然契合。
5.3 对不上账时的排查链路
资金对账发现问题的时候,最忌讳的是直接改数据。我常用的排查链路是三步走:
- 流水重放核对:挑出目标账户的全部资金流水,按时间顺序计算变动后的余额,和账户表当前余额对比。哪一笔开始对不上,问题就定位在哪一笔。
- 状态机回溯:检查异常标的当前状态是否匹配操作记录。比如投资记录显示已满标,但标的还停在「投标中」,八成是满标事件没有异步消费成功。
- 幂等检查:搜索同一个业务单号是否出现了多条资金流水。出现了,基本可以断定是重复消费或重复提交。
这套链路能覆盖大部分问题。我自己遇到最多的情况,是消息表消费失败后没有重试机制,导致满标事件一直卡在「待处理」,表面上看是进度条和状态不一致,实际上是放款流程压根没被触发。所以一定要给消息消费者加上重试和告警,不能静默失败。
5.4 上线前我建议你写完的自检 SQL
项目上线前,我在测试环境跑了很久,最后沉淀了一组自检 SQL,每次发版前跑一遍,能挡住 90% 的常规问题。这里直接分享出来:
-- 账户三栏校验:总额 = 可用 + 冻结 SELECT COUNT(*) FROM user_account WHERE total_balance <> available_balance + frozen_balance; -- 标的状态校验:可投金额 + 已投金额 = 借款金额,且不能出现负数 SELECT COUNT(*) FROM loan WHERE available_amount + invested_amount <> loan_amount OR available_amount < 0 OR invested_amount < 0; -- 流水校验:同一标的的投资记录之和,等于该标的的已投金额 SELECT loan_id, SUM(invest_amount) AS total_invest FROM invest_record GROUP BY loan_id HAVING total_invest <> 0;第三段 SQL 里有条件我没法直接接上标的表写全,实际使用时把HAVING改成子查询关联loan表,按invested_amount做对比即可。这些检查能发现问题,但更重要的是从流程上杜绝问题——每一笔资金变动都有流水、每一次状态迁移都有校验、每一个异步动作都有重试。
做完这个项目再回头看,最核心的感受是:金融项目真正的难点,从来不在某个接口的实现难度,而在于状态、余额、流水三者之间的一致性。借款和投资两个业务,说到底都是同一套状态机在不同角色身上的投影。只要把标的的状态流转理清楚,把每一笔资金变动的语义定死,后面的一切都会顺很多。
最后再分享一个实际开发中很受用的习惯:每次新加一个资金相关的功能前,先在文档里回答三个问题——这笔操作在什么状态下允许执行?它会改变哪几个余额字段?对应的流水记录怎么生成?三个问题回答清楚,代码写起来其实是水到渠成的事。