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

从借款发布到投资撮合:资金状态机与并发投标实践

从借款发布到投资撮合:资金状态机与并发投标实践 ★ FEATURED ARTICLE
## 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标的需要进入流标流程。流标的动作有两个方向投资人的资金解冻以及借款人的授信额度回补。对投资人执行的是批量解冻 SQLUPDATE 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 的实例才继续处理。比如流标任务的抢占 SQLUPDATE 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做对比即可。这些检查能发现问题但更重要的是从流程上杜绝问题——每一笔资金变动都有流水、每一次状态迁移都有校验、每一个异步动作都有重试。做完这个项目再回头看最核心的感受是金融项目真正的难点从来不在某个接口的实现难度而在于状态、余额、流水三者之间的一致性。借款和投资两个业务说到底都是同一套状态机在不同角色身上的投影。只要把标的的状态流转理清楚把每一笔资金变动的语义定死后面的一切都会顺很多。最后再分享一个实际开发中很受用的习惯每次新加一个资金相关的功能前先在文档里回答三个问题——这笔操作在什么状态下允许执行它会改变哪几个余额字段对应的流水记录怎么生成三个问题回答清楚代码写起来其实是水到渠成的事。
阅读完成 · 觉得有帮助?
咨询建站