写金融系统最绕不开的模块,就是“撮合”两个字。我最近在做一个模拟金融撮合平台的后端重构,项目推进到第三阶段时,核心任务变成了发布借款业务和投资业务。说白了,就是把借款人的需求包装成可交易的标的,再让投资人在另一端看到、投出去,整个过程涉及大量的状态机设计、资金流水处理和并发控制。这篇博文记录的就是这个阶段的完整实践。
我先梳理一下项目背景:整个系统不是真实的金融机构,而是一个教学与实际开发结合的练兵项目,目标是还原一个线上信用借贷撮合平台。所谓“发布借款业务和投资业务”,拆开看其实是两条产品线:一条服务借款人,一条服务投资人。但底层要处理的数据关系和资金逻辑是同一套。
这套业务值得分享的地方在于:它几乎覆盖了金融交易系统里最典型的三类问题——状态流转、并发限额、资金对账。你把这套逻辑吃透了,后面做积分系统、订单系统、账户系统都会觉得顺很多。
这个阶段适合谁来参考?如果你是刚转后端不久、想了解资金撮合类系统怎么设计;或者你已经写过常规CRUD,想看看金融场景下事务和锁到底怎么用;又或者你只是想找一个项目实战的样板,这篇文章都能给你一些可以直接抄走的思路。
1. 业务线拆解:借款端与投资端的关系模型
1.1 闭环第一步:标的是怎么来的
要理解借款和投资的关系,你先得抓住一个核心概念:标的就是一段被标准化了的借款需求。借款人不会直接把“我缺钱”这件事展示给投资人,系统要做的是把借款金额、年化利率、还款期限、还款方式这些散落的信息组装成一个投资人看得懂、算得清、敢下手的产品。
我举个具体例子。一个借款人提交申请:金额10万元、期限12个月、年化利率9.6%、还款方式等额本息,并且打算每个月15日还款。系统拿到这些信息后,并不会立刻把它展示出来,而是先进入审核和风控状态。只有通过了审核,这条借款申请才会被打上“可投”标签,变成标的发布到投资端列表。
所以你在做数据建模时,借款申请和标的是两个独立实体,而不是一个实体。我在项目里把这两者分开落库,理由很直接:申请信息在审核前后会多次修改,标的一旦发布,其利率、金额、期限都应该被冻结,不能跟着借款人的后续操作变来变去。这个分离设计在后续做对账和审计时帮了大忙。
1.2 标的状态机:全流程只有六种状态
状态机是这类系统的第一个设计难点。我梳理过市面上一批同类系统的文档,发现不管怎么命名,核心状态基本逃不出这六个:DRAFT(草稿)、PUBLISHED(投资中)、FULL(满标)、REPAYING(还款中)、CLEARED(已结清)、VOID(流标)。我建议所有团队都先用这六个状态把主干串起来,再考虑加“部分流标”“提前结清”这类分支状态。
一个容易踩坑的地方是:状态的流转方向比状态本身更重要。比如只有满标的标才能流标(打回草稿是禁止的),只有投资中的标才能被提前终止。如果你在代码里不做方向校验,很容易出现“草稿标直接变成已结清”这种逻辑漏洞。我在项目里专门写了一个StateMachine辅助类,把合法的迁移路径全部显式声明出来,任何不在路径上的迁移直接抛异常。
1.3 投资端看到的东西,其实是统计结果
投资端的产品列表页看起来很简单,无非是几列数据:借款编号、年化利率、期限、可投金额。但这些列里面,真正需要查表的其实只有借款编号和年化利率,“可投金额”是一个动态计算字段,它等于标的总额减去所有有效投资订单的金额总和。
这个点看起来不起眼,却是后来并发问题的源头。因为可投金额不是静态存在的列,如果你每次查询都现算,一旦多个投资人同时投标,就会产生“看到的余额明明够,提交时却提示额度不足”的现象。解决思路上我会在后文详细讲,但先记住一个原则:所有涉及钱和剩余额度的更新,永远不要基于“查询后再更新”的朴素逻辑,一定要走数据库层面的原子操作或行锁。
2. 借款业务发布:从申请到上架的完整路径
2.1 借款申请表的核心字段设计
我先把借款申请表的字段列出来,这张表是借款人进入系统的第一站。实际项目中字段还可以拆得更细,但核心骨架大概是:
| 字段名 | 类型 | 说明 |
|---|---|---|
| apply_id | bigint | 申请主键 |
| user_id | bigint | 借款人用户ID |
| amount | decimal(18,2) | 申请借款金额 |
| rate | decimal(5,2) | 年化利率(百分比) |
| term | int | 借款期限(月) |
| repay_type | tinyint | 还款方式(1等额本息 2先息后本 3一次性还本付息) |
| status | tinyint | 申请状态 |
| audit_time | datetime | 审核时间 |
| create_time | datetime | 创建时间 |
这里面有两个我特别想提醒的字段。第一个是amount,坚决不能用double或者float去存,金融场景必须用decimal(18,2)或者BigDecimal。这个坑我在后文会专门说。第二个是repay_type,它是生成还款计划的依据,不同的还款方式计算逻辑完全不同,你在初始化还款计划时一定要先把它读出来做分支处理。
2.2 借款审核的规则落库
审核过程在演示项目里可以简化成三步:资料齐全性校验、黑名单校验、额度规则校验。每一步我都是用一个可插拔的校验器实现的,这样后续风控部门要加新规则,只要在规则链上追加一个类就行,不会动到主流程。
审核结果最终会体现在申请状态上:通过(PASS)、拒绝(REJECT)、人工复审(MANUAL)。我这里的处理是:PASS之后才允许创建标的,REJECT直接结束流程,MANUAL则进入后台任务队列等待人工处理。
需要提醒的是,审核通过不等于标的一定能发出去。我在项目里还加了一道“标的池”限制逻辑——同一时间段允许在投的标的数量不能超过阈值。这个限制不是为了防并发,而是为了控制资金端和资产端的节奏,避免出现一堆小额标把系统打满,真正的大额资产反而排不上队的问题。
2.3 发布借款标的的实操步骤
发布借款业务在代码层面可以分为两步:创建标的、标记发布。创建标的就是把申请信息拷贝过来,生成一个唯一的subject_no,然后初始化金额相关的字段:total_amount、remain_amount两个值此时相等,status = DRAFT。标记发布则是把状态从DRAFT改成PUBLISHED,并写入发布时间。
我画一条精确的SQL更新语句给你看(这是后面所有更新操作的模板):
UPDATE loan_subject SET status = 'PUBLISHED', publish_time = NOW(), version = version + 1 WHERE subject_id = ? AND status = 'DRAFT'注意,WHERE里把当前状态带上了。这是一个非常关键的技巧,它保证了状态的更新是条件化的,如果这条SQL执行后影响行数为0,说明标的已经不是草稿状态,此时必须抛异常回滚,让上层重新查询最新状态。我后来处理所有涉及钱的状态变更都用了这个模式,它比先查再判的写法节省了无数并发隐患。
3. 投资业务发布:标的上架后的那些事
3.1 标的上架后的展示与筛选
当标的进入PUBLISHED状态,投资端的列表查询就开始命中它了。这里有一个性能细节:投资列表页需要展示“可投金额”,如果每次查询都对每个标做一次SUM聚合,列表超过20条时接口耗时就会明显上升。
我的处理方案是双写:loan_subject表保存remain_amount字段,同时维护一张invest_order表。每次投标成功,事务内同时更新这两处。查询列表时直接读remain_amount,极端情况下即使这个字段和实际订单总和对不上,也可以用定时对账任务去修正。这种用空间换时间的做法,在金融系统里的名字叫冗余可查字段,很常见。
筛选逻辑方面,我按投资人最常用的筛选项做了索引:状态(必填)、利率区间、期限、发布时间。前两个字段建了联合索引,发布时间的排序用二级索引解决。我这个阶段的列表页压测,单表100万条数据的情况下,响应延迟能稳定在80毫秒以内,算是比较合格的水平。
3.2 投标并发控制:必须讲清楚的两把锁
投标这个动作是整个系统并发压力最集中的地方。同一个标的,可能同时有成百上千个投资人在操作,如果处理不好“打款成功但标已经满”的尴尬场景,后面对账会非常痛苦。
我先讲第一种方案:乐观锁。在loan_subject表上加一个version字段,投标时执行这样的SQL:
UPDATE loan_subject SET remain_amount = remain_amount - ? , version = version + 1 WHERE subject_id = ? AND remain_amount >= ?如果影响行数为1,说明更新成功;如果为0,说明剩余额度不足,或者version冲突,此时应该抛“额度不足或已满标”的错误。这个方案实现简单,但有一个问题:在高并发下,如果同一个标的被大量命中,大量请求会做无谓的失败重试,浪费数据库连接。
第二种方案是行级锁。投标事务开始时先执行一条SELECT ... FOR UPDATE把标的行为锁住,后续步骤都基于锁定的最新值操作,事务提交后再释放锁。这种方案更稳,但因为锁的粒度大,同一标的的并发能力会被限制在很低的水平。
我在这个项目里最终选了第二种方案,理由很务实:金融标的的数量天然受限,一个平台同时在投的标的也就几十个到几百个,每个标的的投标并发一般不会超过几十笔/秒,这个量级下行锁完全应付得过来,而且逻辑最不容易出错。如果你做的是抢购、秒杀这种超高并发的场景,再考虑乐观锁+消息队列削峰。
3.3 还款计划的生成逻辑
还款计划是投资业务能不能落地的关键,因为它直接决定了投资人能看到多少收益。我以最常见的等额本息为例,把计算的过程完整讲清楚。
等额本息的月还款额公式是:
[ M = P \times \frac{r(1+r)^n}{(1+r)^n - 1} ]
其中P是本金,r是月利率(年化利率除以12),n是还款期数。每月还款额相同,但利息和本金的比例逐月变化。我在代码里没用浮点数直接算,而是每一步都用BigDecimal的divide(mc, RoundingMode.HALF_UP)方法,精度设置在8位小数,最后一期用“总额减已还金额”的方式反推,确保每一分钱都对得上。
顺带提一句:还款计划的明细表一定要包含current_period、due_date、principal、interest、total_due、status这几个核心字段。后面做还款提醒和逾期罚息的时候,这些都是最基础的引用数据。
4. 撮合引擎与资金流水:核心代码怎么落
4.1 两张必建的表:投资订单与资金流水
这一节我直接给你看建表SQL,这两张表是投资业务的核心。
CREATE TABLE invest_order ( order_id bigint PRIMARY KEY AUTO_INCREMENT, subject_id bigint NOT NULL, user_id bigint NOT NULL, invest_amount decimal(18,2) NOT NULL, status tinyint NOT NULL DEFAULT 0, create_time datetime NOT NULL, KEY idx_subject_status (subject_id, status) ); CREATE TABLE capital_flow ( flow_id bigint PRIMARY KEY AUTO_INCREMENT, user_id bigint NOT NULL, account_type tinyint NOT NULL COMMENT '1可用余额 2冻结余额 3待收本息', change_amount decimal(18,2) NOT NULL, balance_after decimal(18,2) NOT NULL, biz_type varchar(32) NOT NULL, biz_id varchar(64) NOT NULL, create_time datetime NOT NULL, KEY idx_user_time (user_id, create_time) );资金流水表的细节值得展开说一下。balance_after字段非常关键,它存储的是这笔流水发生后的账户余额。有了它,你可以随时根据流水表做全量对账,而不用依赖账户表的当前值。这在对账出现差异时能快速定位是哪笔交易出了问题,我在项目里就用这个字段排查出过一次重复写入的Bug。
4.2 投标核心事务的代码逻辑
投标动作我建议放在一个事务方法里完成,大致代码如下(以Java和Spring为例):
@Transactional(rollbackFor = Exception.class) public void invest(InvestRequest request) { // 1. 行锁锁定标的 LoanSubject subject = loanSubjectMapper.selectByIdForUpdate(request.getSubjectId()); if (subject == null || !"PUBLISHED".equals(subject.getStatus())) { throw new BizException("标的不可投"); } if (subject.getRemainAmount().compareTo(request.getAmount()) < 0) { throw new BizException("剩余额度不足"); } // 2. 扣减剩余额度 loanSubjectMapper.deductRemainAmount(request.getSubjectId(), request.getAmount()); // 3. 创建投资订单 InvestOrder order = new InvestOrder(); order.setSubjectId(request.getSubjectId()); order.setUserId(request.getUserId()); order.setInvestAmount(request.getAmount()); order.setStatus(OrderStatus.SUCCESS); investOrderMapper.insert(order); // 4. 生成资金流水:冻结投资人可用余额 capitalFlowMapper.insert(request.getUserId(), ACCOUNT_FREEZE, request.getAmount().negate(), accountService.queryAvailable(request.getUserId()), "INVEST_FREEZE", order.getOrderId()); // 5. 判断是否满标,满标则进入下一步处理 if (subject.getRemainAmount().subtract(request.getAmount()).compareTo(BigDecimal.ZERO) == 0) { loanSubjectMapper.updateStatus(request.getSubjectId(), "FULL"); } }这里每一步我都只做一件事,事务回滚时才能明确。尤其第4步生成资金流水,这步如果漏了,后面还本付息时账根本对不上。我建议你每一步的流水与业务操作放在同一个事务里,不要采用“先更新业务表,再异步记账”的方案,异步记账适合百万级日单量的大型系统,我们的体量用同步事务更稳。
4.3 满标后的放款事务
满标之后并不是直接给借款人打钱,通常还有一道放款确认,确认前用户可以发起“流标”。流标意味着所有投资人的冻结资金要解冻退回。
放款事务我同样是同步完成的:先更新标的为REPAYING,再给借款人账户增加可用余额,同时生成放款流水。这里我额外强调一点:放款时不建议直接调用外部转账接口,而是先做内部记账,记完账后进入待支付队列,由支付模块异步处理。这样做的原因是外部渠道可能失败,如果同步阻塞整个事务,容易把数据库事务拉得特别长,极大影响系统吞吐。
我踩过一次坑是放款回调重复通知,导致账户余额加了两次。后来在流水的biz_id上建了唯一索引,重复回调直接插入失败,问题就消失了。这个经验可以类推到所有回调、通知类的业务处理上:幂等处理的核心就是唯一索引加状态检查,二者缺一不可。
5. 故障复盘:真实踩过的坑与排查方法
5.1 并发投标导致的超额问题
这是这类系统最经典的故障。我最早写完投标接口后做了50并发压测,结果发现一个总额10万元的标最终被投出了12万。原因很直接:多个请求同时查到了remain_amount=50000,都认为自己能投40000,然后在更新时都没有限制条件,最后一共扣了12万。
后来修复方案就是前面说的:更新语句把remain_amount >= ?作为WHERE条件带上,这样错误的更新直接影响行数为0,事务自然失败。这件事也让我养成了一个习惯:凡是涉及“扣减”的SQL,必须带上“扣减后不能为负”的条件。
5.2 金额精度丢失
我接手时发现代码里有人用Double保存利率,然后用它做了一堆乘法,账面上看起来差别不大,但累计下来有几块钱的误差。这类问题在金融系统里的严重性在于:投资人随时可能拿计算器算收益,对不上就是投诉。
排查方法很简单,把所有涉及金额、利率的字段改成BigDecimal,并且统一RoundingMode.HALF_UP。这里我建议在数据库层用decimal(18,4)存储,展示层再四舍五入到2位,可以最大限度减少累计误差。
5.3 状态机错乱:草稿标直接跳到还款中
有一次测试时发现,一笔借款申请还没审核通过,后台却已经生成了放款记录。最后定位是代码里有个定时任务,直接根据申请表违规把状态改成了REPAYING,跳过了中间的发布和满标环节。
修复办法是引入状态机迁移校验:所有状态更新都经过统一入口,入口内查一次合法迁移表。这个校验逻辑不复杂,但我认为每个金融项目都必须有,否则一次错误的状态更新就可能造成资金流水错乱,而复盘时要花好几倍时间才能把账理清。
5.4 我的排查工具与方法
我平时排查这类型问题,第一步永远是拉流水。流水表记录了每一次余额变化,你可以从某笔异常交易开始,把同一用户、同一时段的所有余额变化按时间顺序串起来,几乎一眼就能看到问题出在哪里。
第二步是开慢查询日志。在MySQL里把慢查询阈值设成1秒,压测后去看哪些SQL耗时长,十有八九就是没走索引或者是锁等待。第三步才是查应用日志,不要一开始就钻进日志里,那样效率太低。
6. 上线前的压测要点与我的实操体会
6.1 压测清单
我把这类系统上线前必做的压测项列成一个清单,方便你直接照抄:
| 检查项 | 预期指标 | 说明 |
|---|---|---|
| 投标接口并发压测 | 不出现超卖、不出现金额错误 | 至少压到正常值3倍并发 |
| 满标触发成功率 | 100% | 满标后应立刻触发状态变更 |
| 放款回调幂等 | 重复5次不重复入账 | 唯一索引必须生效 |
| 列表页响应延迟 | 100毫秒内 | 超过则检查索引 |
除此之外建议再做一个“恢复测试”:人为制造数据库连接池耗尽,观察系统是否能自动恢复。这个测试很多团队不做,但线上出问题时,你大概率是先体验到连接池异常,才会发现问题根源。
6.2 一点实际体会
写到这里我大致给出了这套借款和投资业务从模型设计到发布上线的主要环节。整个过程中我最大的感受是,所谓金融项目实战,真正难的不是某个算法或者某个高大上的组件,而是那些看起来很朴素的工程细节:状态有没有被正确约束、流水有没有被完整记录、并发更新有没有被精确限制。把这些细节做扎实了,比引入再多的中间件都管用。
如果你也在做类似的资金撮合类系统,我建议你把这篇文章里提到的状态机校验、条件更新SQL、资金流水三张表先建齐,再从借款发布跑通到投标满标,中间不要图快跳步骤。你亲自动手搭一遍,比看十篇讲解都更有收获。
这个项目下一步我会继续做还款提醒和逾期罚息相关模块,整套账务逻辑还会继续复杂下去。到那个阶段如果有新的体会,我再单独写一篇分享出来。