很多人第一次接手金融科技类项目,看到“financial-services”这个大词的时候,容易一头雾水:这到底是要做一个App、一套接口,还是一个完整平台?我去年完整带过一个代号叫 financial-services 的服务平台项目,从零搭建账户体系、支付链路、风控规则、对账系统,一直到上线压测和故障排查。这篇文章就把整个过程里最核心的设计决策、踩过的坑、以及最后沉淀下来的实践方案整理出来,给正准备做类似系统的团队一个可以直接参考的底稿。
这个项目适合三类人看:一是刚转金融行业的后端工程师,想弄清账户、交易、对账这些概念在代码层面到底怎么落地;二是产品经理或项目经理,需要理解金融服务系统的边界和演进节奏;三是已经在做支付或账务系统、想对比一下自己的方案有没有明显漏洞的开发者。这里不会写太多教科书理论,更多是“我当时为什么这么选、换了会不会更好”的真实记录。
1. 项目定位与整体架构思路
1.1 金融服务项目最容易被低估的边界问题
我见过不少团队做这类系统,上来就画一个大模块图,把登录注册、实名认证、产品列表、下单支付全塞进去,然后所有代码写在一个工程里。短期看确实快,但一旦涉及资金,问题就暴露了:账户余额被业务代码直接改、支付回调逻辑和营销活动耦合在一起、风控规则散落在各个接口里。后面每次改需求都像踩地雷,线上一个活动就能让账不平。
所以在 design 阶段,我坚持先划清业务边界。整个系统按职责拆成四块核心域:
- 账户与账务域:负责开户、余额变更、流水记录、冻结与解冻。对外只暴露“记账接口”,禁止业务方直接改余额。
- 交易与支付域:负责订单状态机、支付渠道接入、回调处理、查单与补单。它依赖账务域,但不关心用户积分、优惠券这些营销概念。
- 合规与风控域:负责实名认证、规则引擎、交易监控、人工审核。它寄生在交易链路上,通过事件驱动介入。
- 渠道集成域:负责对接第三方支付、银行通道、短信服务商。对外屏蔽渠道差异,提供统一接口。
这个划分说起来简单,实际执行时最难的是“禁止越界”。比如运营希望在大促时给用户余额直接加钱,如果走了账务域的标准接口,加钱记录就会进入流水;但如果图省事在业务库直接 update 余额,后面审计和对账一定出问题。我当时的做法是:业务库和账务库彻底分离,账户余额只存在于账务域的表里,任何余额变动只能通过记账接口发起,并且强制要求传入业务单号。
注意:账务库通常要单独拆出来,不要和业务数据放在同一个数据库实例里,至少要用独立的 schema。这样做的原因不只是隔离故障,更是为了数据库权限和审计方便。否则一次误操作,整个关联库都可能被拖下水。
1.2 技术选型:模块化单体比微服务更适合起步
如果你问我金融服务系统要不要上微服务,我的回答是:团队没到二三十人规模之前,别硬上。微服务带来的是网络开销、分布式事务、链路排查、多环境部署的成本,而早期金融项目的核心矛盾是逻辑正确性,不是并发量。
我们的方案是“模块化单体 + 独立扩展点”。主工程按上面说的四个域分包,域与域之间只通过接口调用,但部署时先打成一个大包。这样开发的调试成本低,代码重构也方便。等到某一块确实遇到性能瓶颈了,再把对应模块抽出来独立部署。
存储选型上,核心账务数据用了 MySQL(InnoDB),缓存用了 Redis。有句话叫“钱的事别放缓存”,余额这类数据绝对不能以 Redis 为准,Redis 只能用来做热数据的读取加速和分布式锁。流水表用自增主键加业务唯一索引,订单表则用雪花ID。为什么要用雪花ID?金融服务里订单号经常要跨系统传递,用数据库自增ID一方面泄露业务量,另一方面渠道侧看到连续ID会有撞库风险,雪花ID在性能和安全性之间比较平衡。
我现在依然认为这个选择是对的。我们后来日交易量涨到百万级别时,MySQL 仍然扛得住,因为账务表只是追加写入,按账户维度做了数据分片。真正压垮系统的往往是业务表上的无差别查询,而不是账务写入。
1.3 异步化:消息队列不是可选项而是必修课
金融服务链路里有一个很容易被人忽视的陷阱:同步调用链太长。用户支付成功后,如果要在返回结果里同时完成记账、积分、通知、风控入库,那响应时间会被最慢的那个下游拖死;更恐怖的是,某个下游抖动会导致整个交易链路失败。
我们当时引入了 RocketMQ 作为事件总线,核心原则是“同步只做必要的事,异步处理所有能延后的事”。用户支付成功后,同步只需要两个操作:修改订单状态为已支付、调用账务接口入账。其余像发短信、推消息、风控异步回溯、数据分析,全部通过支付成功事件触发。
这里抛出一个我后来才想明白的设计点:消息事件的 payload 不要只传一个 ID,还要带上轻量的上下文。比如支付成功事件至少要包含平台订单号、渠道订单号、金额、支付时间。因为消费方可能是积分系统,它只关心订单号,但如果消费者需要做日志归档,还得回查订单表,多一次无谓的 DB 查询还是小事,万一订单表因某些原因数据过期被清理了,事件里的信息就丢了。
2. 账户体系与交易流程的核心设计
2.1 账户模型:为什么账不能直接加钱
账户体系是整个金融服务系统的地基。很多业务同学会问:用户余额不就是表里一个字段吗,支付成功加钱、消费扣钱,有什么好设计的?但真正做过账务的人都知道,余额字段如果被多线程并发改写,最终一定出问题。比如用户同时下两单,两次扣钱,没加锁的情况下余额可能只扣了一笔。
我们实现的账户模型是这样的:
- 账户基础信息表(account):account_id、user_id、account_type(客户户、内部户、虚户)、currency、status、created_at。
- 账户余额表(account_balance):account_id、balance、frozen_balance、available_balance、版本号 version。
- 流水表(account_transaction):tx_id、account_id、biz_type、direction(借/贷)、amount、balance_after、biz_order_no、created_at。
这里最关键的一点是:每次余额变更都必须在同一数据库事务里写入流水,并更新余额行。更新语句必须带版本号或者基于“当前余额>扣减金额”的条件,例如:
UPDATE account_balance SET balance = balance - #{amount}, version = version + 1 WHERE account_id = #{accountId} AND balance >= #{amount} AND version = #{version}如果更新影响行数为0,说明余额不足或版本冲突,业务层重新查余额再决定是继续还是返回失败。这套方案比“先查后改”要安全得多,也避免了加悲观锁带来的性能损耗。
虚拟户是一个很实用的设计。用户可以有多个虚拟子账户,比如“可用金账户”“锁定金账户”“营销金账户”,它们共用一个用户下多账户的模型,每个虚拟户独立记账。做营销活动时,给用户发券可以直接入“营销金账户”,到期自动失效,不会污染主余额账户的账目。
注意:账户号不能直接用 user_id,因为一个用户有多个账户,且未来可能有商户账户、内部账户混用的情况。我们采用“账户类型编码 + 日期 + 序列”的规则生成 account_id,保证全局唯一。
2.2 交易状态机与幂等:宁可笨,不能乱
交易状态机是另一个容易做坏的地方。如果订单状态可以随意跳转,比如从“已支付”变回“待支付”,对账和退款都会乱套。我们定的订单状态只有几个:
- 待支付(INIT):订单已创建,等待用户付款。
- 支付中(PAYING):用户已发起支付但未确认回执,或渠道尚未回调。
- 已支付(PAID):支付成功,资金已记账。
- 已关闭(CLOSED):订单超时未支付或用户主动取消,通常不可再支付。
- 已退款(REFUNDED):订单全额退款或部分退款完成。
- 部分退款(PART_REFUND):订单部分退款,余额还在继续流转。
状态机最重要的规则是:禁止跨状态非法流转。已关闭的订单不能再支付,已退款的订单不能再发起新退款,已支付的订单不能重新变成待支付。代码层面我们会用一个枚举校验器做统一校验,所有状态变更必须走同一个方法,不允许业务代码里随手 setStatus。
幂等处理的方案我分享一个屡试不爽的组合拳。第一层是唯一索引,在支付表中给“biz_order_no + channel_code + channel_order_no”建唯一索引,重复插入直接报 DuplicateEntry 错误;第二层是分布式锁,用 Redis SETNX 在支付请求入口加锁,锁的 key 设计为 pay:order:{orderId},过期时间设置成下游接口超时上限的两倍;第三层是回调处理器的状态判断,处理回调时先查单,发现已是 PAID 就直接返回成功,不再做二次入账。
// 伪代码示意:支付回调处理 boolean isNew = tryLock("pay:order:" + orderId, 10, TimeUnit.SECONDS); if (!isNew) { log.warn("duplicate callback, orderId={}", orderId); return "SUCCESS"; } try { Order order = orderMapper.selectByOrderId(orderId); if (OrderStatus.PAID == order.getStatus()) { return "SUCCESS"; } accountService.credit(accountId, amount, orderId); orderMapper.updateStatus(orderId, OrderStatus.PAID); } finally { unlock("pay:order:" + orderId); }2.3 借贷记账法:交易代码必须懂会计
这个理念是我从做账务系统的前辈那里学来的。刚开始我觉得,用户充100块,给余额加100不就行了,为什么要分“借”“贷”?后来真正做审计和日终对账时才发现,所有资金的流入流出都必须有来有去,有借必有贷,借贷必相等。
举个例子,用户使用余额支付一笔 50 元的订单,平台还要收 1 元手续费。这个交易在账务系统里拆成以下几笔:
| 账户方向 | 借贷方向 | 金额 | 说明 |
|---|---|---|---|
| 客户余额账户 | 贷 | 50 | 扣减用户余额 |
| 平台收入账户-手续费 | 贷 | 1 | 手续费收入 |
| 内部清算户 | 借 | 51 | 待清算给商户的资金 |
很多人问,用户付款明明是钱出去了,为什么用户户是“贷”?因为在客户视角下,资产减少记贷方;而平台收到手续费,收入增加,也是贷方。只有清算户作为中转,资产方增加,记借方。这套规则理解起来需要一点转换,但一旦你按照“资产=负债+权益”的会计恒等式来设计,账户关系就清晰了。而且对账时最爽的地方是:只要保证每一笔流水都借贷平衡,日终汇总出来总额一定匹配,差异一眼就能看出来。
3. 合规风控与反欺诈落地要点
3.1 KYC与身份核验:实名不是走个流程
金融服务平台必须面对实名认证的问题。但不同业务场景对认证级别的需求是不同的,我们的做法是“首次认证 + 分级提额”。首次注册时至少要完成手机号、姓名、身份证号的“三要素校验”;涉及绑卡或提现时,再加银行卡四要素(姓名、身份证、卡号、预留手机号);单笔大额或者累计限额超过一定级别,再做人脸识别活体检测。
这个过程我踩过一个具体的坑:第三方实名认证接口不是 100% 可用,尤其是夜间大促高峰时,外部服务经常超时。如果我们把实名校验做成交易的强依赖,那高峰期用户连充值都做不了。后来改成“本地实名状态缓存 + 异步升级认证”。首笔小额支付允许用户先通过弱实名完成,触发风控规则再拦截,用户完成强实名后自动放行。这样用户体验和合规压力之间的平衡会好很多。
注意:KYC 数据是敏感信息,身份证号、银行卡号不能明文存储。数据库里至少要加密,接口返回时做脱敏。我见到过一个团队把身份证号放在日志里排查问题,后来被安全扫描扫出来全部翻工,这个教训挺痛的。
3.2 交易监控规则与限额:规则引擎要提前设计
风控的规则引擎不要等出了事再补,要在交易链路设计之初就留出埋点。我们为每一笔交易都会采集以下上下文:用户ID、设备ID、IP地址、支付金额、支付频次、订单类目、收款方ID。这些信息统一进入风控上下文对象,由规则引擎打分。
初期规则不需要太多,但有几类必须有:
- 频次类规则:同一用户5分钟内支付失败超过5次、同一设备24小时内绑定超过3张卡、同一IP当日交易超过50笔。
- 金额类规则:单笔超过5万、单日累计超过10万、凌晨时间段大额交易。
- 黑白名单:用户级、设备级、IP级黑名单直接拒绝;白名单用户跳过次级规则但保留审计。
规则引擎我推荐用开源的 Drools 或者自定义 Groovy 脚本,最忌讳的是把规则写在业务代码的 if-else 里。我们当时用了一组 Groovy 脚本,支持运营在管理后台动态调整阈值,发布后五分钟生效。但要注意,动态规则必须有版本管理和审批流程,否则运营误改一条阈值,可能直接导致大额交易全部被拦截。
误杀是风控里最头疼的问题。我们小组一开始把阈值定得太激进,结果很多正常用户下单被拦截,客服投诉量暴增。后面我们把规则分成“观察模式”和“拦截模式”:新规则先进观察模式跑一周,只记录命中数据和误杀率,确认误杀率低于某个阈值再切换成拦截模式。这个机制强烈推荐,它能让你在攻防博弈里始终保持可回退的空间。
3.3 风控处置与人工审核闭环
自动规则不可能解决所有问题,还需要一个人工审核工作台。我们当时的设计是:风控命中后生成事件,事件状态分为“待审核”“已放行”“已拦截”“已冻结”。审核员在工作台看详情,包括用户画像、交易链路、历史行为,再决定处置动作。
这里有一个很容易被忽视的细节:风控事件和交易之间不能是强同步阻塞。如果每一笔交易都要等风控规则跑完才返回,用户体验会大幅下降。我们的做法是:大部分规则在交易请求入口同步跑,只取关键几项,比如黑白名单、频次限制;复杂的机器学习模型判定走异步,先放行交易再回溯,发现风险后做资金冻结,再引导用户完成补充验证。这种折衷方案不适合所有业务,但在用户增长和风险控制之间是比较务实的取舍。
4. 支付渠道接入与对账体系
4.1 多渠道统一接入:设计一套渠道抽象层
支付的本质是通道。一家平台不可能只接一个支付渠道,渠道之间差异很大:有的同步返回支付链接,有的只异步回调,有的需要主动查单,有的回调报文里金额是分,有的是元。如果每个业务方都直接对接渠道原始接口,代码会迅速腐化。
我们做了一个支付渠道统一接入层,对外暴露的是统一接口,内部针对每个渠道写 adapter。核心概念只有三个:
- 渠道定义:channel_code(如 alipay、wxpay、unionpay)、渠道类型、支持的支付产品;记录渠道开关状态。
- 支付网关:负责下单参数组装、签名、发起请求、结果解析。
- 查单策略:同步返回成功、异步回调通知、主动轮询查单;每个渠道可以配置查单间隔。
统一接口的请求参数大致是:biz_order_no、pay_amount、channel_code、expire_time、notify_url。返回结构包括:pay_url、channel_order_no、status。这样上层业务完全感知不到渠道差异。
接入新渠道时,你要关注的三个点:一是签名算法,MD5/RSA/SM2各不相同;二是金额精度,有的渠道用“分”,有的用“元”,还有的需要“厘”;三是对账文件格式,每个渠道导出的对账文件字段顺序都不一样。
实操经验:sandbox 环境和生产环境的密钥要分开管理,而且密钥不能进代码仓库。我们当时有同事把生产私钥 base64 后提交到 Git,结果一条流水都没有泄露,但是安全整改时被查了个底朝天,全部重新生成密钥。
4.2 对账系统:每天自动过一遍的金融体检
我之前对“对账”这件事是有些轻视的,觉得无非就是两边拉个文件,比对一下金额。真正上线才发现,对账系统是金融系统的最后一道防线,它能发现许多潜在问题:掉单、金额被篡改、内部逻辑 bug、渠道Bug、手续费对不上。
我们的对账系统每天凌晨批量执行 T+1 对账,流程是这样:
- 从渠道侧拉取前一日的结算文件或对账文件,解析为标准流水。
- 从本地订单表查询前一日全部成功及部分退款的订单流水。
- 按“平台订单号+渠道单号”做关联比对,生成三类结果:
- 一致:本地已支付且渠道已结算。
- 本地已支付、渠道缺失:这是最危险的掉单,需要标记出来追查。
- 渠道有记录、本地未支付:可能是回调丢失导致本地状态未更新,需要补单或人工介入。
对账文件解析和本地流水查询写完后,最麻烦的是“差异报表”。我们初期生成的差异报表又长又乱,运营根本看不进去。后来改成按差异类型分类,每一个差异都给出建议处理方式,能自动处理的就自动处理(比如本地已支付但渠道缺失,支持自动触发查单后标记为可疑单),需要人工处理的才进待办列表,运维和财务只看待办列表就够了。
关于手续费,这又是一个大坑。渠道扣的手续费往往不是固定的,会有阶梯费率、优惠活动。对账时如果不比对手续费,日终财务结账一定不准确。我们单独维护一张渠道费率表,支持按渠道、商户、日期区间配置,对账引擎根据账单自动计算预期手续费,金额差异超过阈值才告警。
4.3 差错处理:调账必须“有凭有据”
对账之后一定会产生差错,尤其是刚开始那几个月。常见的差错类型主要有三类:
- 本地长款:本地已经扣了用户钱,但渠道没有产生结算记录。原因可能是支付被风控拦截但本地没回滚。
- 本地短款:渠道结算了,本地没有订单。原因往往是回调丢失。
- 金额不一致:本地订单金额和渠道结算金额差了几分钱。多是因为四舍五入或者费率计算规则差异。
针对这些情况,我定了一个铁律:任何余额变动都必须基于一笔原始记账凭证,即必须携带原始业务单号,禁止无单号手工调账。所有差错单都会生成一个“调账单”,记录调账原因、关联订单、操作人、审批人。财务审计的时候只看调账单列表就能搞清楚每一笔钱为什么动过。
提示:处理差错单时,要记得先“挂起”用户的账户状态,或者先冻结对应金额,等调账完成后再解冻。否则一边在人工调账,用户一边在继续消费,很容易出现金额再不平衡。
5. 高可用架构与稳定性保障
5.1 限流、熔断、降级:金融系统的保命三件套
支付链路里有一个很有意思的现象:越到业务高峰,系统越容易出问题;越出问题,用户越会疯狂重试;越重试,系统被拖垮得越快。所以我们在一开始就引入了限流和熔断。
限流主要做了两层。第一层是网关层的全局限流,按用户ID做令牌桶算法,每个用户每秒最多 N 次支付请求;第二层是服务层的接口限流,比如支付下单接口 QPS 超过阈值就直接返回“系统繁忙”。这个阈值不是拍脑袋定的,而是通过压测得出的:我们系统单机最高支撑 800 QPS,留出 40% 余量后,线上单机限流阈值设置在 500 QPS。
熔断用的是线程池隔离和信号量隔离。比如支付渠道的 adapter 调用如果连续失败超过 10 次,断路器打开,后续请求快速失败,不再等待下游超时。这里最怕的是“半开状态”处理不好,所以我们会在 30 秒后放一个探针请求,探针成功则关闭熔断,否则继续打开。
降级策略更重要。我把所有功能分成三级:核心链路(支付、记账、对账)绝不降级;扩展链路(积分、消息通知、数据分析)可以降级;非核心链路(推荐、活动页)随时可以摘。平时就要演练好“摘掉”某个功能后系统还能正常运转,不要等事发时再讨论。
5.2 监控、告警与链路追踪
金融服务系统的可观测性不是可选项。我们上了三套东西:Prometheus + Grafana 做指标监控,SkyWalking 做链路追踪,ELK 做日志聚合。
监控指标里最有价值的不是“请求量”,而是“支付成功率”和“资金差错率”。支付成功率下降可能意味着渠道异常、回调堆积、限额策略误伤;资金差错率上升则意味着账务系统有 bug。每个指标都配套了多级告警:延迟超过 300ms 是 Warning,超过 1s 是 Critical,支付成功率连续 3 分钟低于 99% 直接拉电话告警。
链路追踪这个工具,我原来觉得微服务才用得上,模块化单体没必要。后来排查一个问题时发现,A 服务调 B 服务,B 服务内部调了三四个 mapper,日志分散在多个文件里,靠 grep 根本拼不齐一条完整链路。SkyWalking 接入后,每次请求从网关到数据库全链路都能看到时间消耗分布,排查效率明显高了。
5.3 压测与应急预案:在风平浪静时准备好出乱子
上线前我们做过一次全链路压测,最初结果惨不忍睹:支付下单接口在 300 QPS 时,数据库连接池先被打满,Redis 连接数也飙升。当时的第一反应是加连接数,但加完之后发现数据库 CPU 被打满,慢查询数量暴涨。后来定位到罪魁祸首是一条没有走索引的订单查询,SQL 里对 create_time 做了函数计算,导致索引失效。加了一条冗余字段并使用范围查询后,QPS 直接翻了 3 倍。
压测不只是为了看系统扛不扛得住,更是为了找出瓶颈在哪。如果内存池分配过猛、线程池参数不合理、SQL 走了慢路径,这些问题平时不压测是根本发现不了的。
应急演练我们每季度做一次。比如把支付渠道服务的权重全部摘掉,模拟渠道宕机;或者把消息队列停掉半小时,看补偿任务能不能把积压的消息重新消费完。第一次演练时真是漏洞百出:因为没有自动补单任务,渠道宕机后整整有 37 单处于“支付中”状态没人处理。补单任务上线后,这个问题才解决。
我个人强烈建议把“补单”做成一个常驻的定时任务,每 5 分钟扫描一次“支付中”且超过 2 分钟的订单,主动向渠道发起查单。它能在绝大多数回调丢失的场景下自愈,是金融系统最值得投入的一个小功能。
6. 常见问题与排查实录
6.1 用户扣款了但订单显示未支付
这类问题几乎每个支付系统都会遇到,你也一定遇到过。原因各有不同,但绝大多数是回调丢失或回调处理失败。我们的排查路径是固定的:
- 查本地支付单状态。如果已经是 PAID,那只是前端展示问题。
- 查渠道订单查询接口。渠道侧如果有成功记录,本地没有,说明回调丢了。
- 触发补单任务主动把本地状态修正为 PAID,并补记一笔账。
- 查账务流水。确保用户余额入账,流水存在,再通知用户。
如果是回调处理失败,比如数据库事务回滚、消息消费重复导致的幂等判断出错,需要先看日志里有没有异常堆栈,重点排查消息消费代码里的 try-catch 是不是吞掉了异常。我之前见过一段代码,catch 之后只打印一行“error”,连堆栈都没有,排查时完全摸不着头绪。正确做法是 catch 异常后打印完整堆栈,并让消费任务支持重试。
6.2 对账不平:为什么金额和笔数总对上不
对账刚开始做的时候,我们几乎每天都要面对差异单。印象最深的一次是,对账结果显示本地有 200 笔交易在渠道侧找不到,差点以为是渠道掉单。查了半天,原因是在回调处理时,我们把本地订单号的后两位截断了,导致关联不上。这种低级错误如果对账系统不报差异,可能要很久才能发现。
另一个比较容易忽略的情况:退款订单和支付订单在同一天发生,它的渠道结算文件里金额是负数,而本地流水方向是正数加退字。对账引擎如果没统一处理符号,差异报表里就会出现很多“金额不一致”。
建议把对账差异分成几个类别,每个类别给固定编号。比如 D001 是渠道缺失、D002 是本地缺失、D003 是金额不一致、D004 是手续费不一致。运营和财务拿到编号就知道下一步怎么操作,而不是打开一张 Excel 对着看半天。
6.3 风控误杀导致正常订单被拦截
刚上线风控规则时,有一个线下商户的真实用户被连续拦截了 3 次,用户直接打客服电话投诉。我们查了一下,发现他被命中了一条规则:同一 IP 当日支付超过 20 笔。这个用户的业务场景是便利店代收,一天几十个小额订单很正常,完全不是欺诈特征。
这个事件给了我们两个改进方向。第一,规则命中后不要直接拦截,而是先走二次验证,比如短信验证码、人脸识别,验证不过再拦截。第二,给规则增加“场景维度”,同一规则可以根据用户画像、商户类别区分阈值,不能一刀切。后来我们引入了申诉通道,用户被误拦后可以在 App 上提交申诉,申诉单直接进入人工审核,审核通过后加入白名单。
6.4 数据库主从延迟导致用户查不到订单
金融系统对数据一致性要求高,但为了性能还是会读写分离。上线初期出现过一个问题:用户支付成功后页面跳转,立刻查订单却显示“待支付”,过了几秒刷新才恢复正常。根源是订单写入走了主库,查询走到了从库,主从复制延迟导致刚才的更新还没同步到从库。
解决方案是“写后读一致性”路由:用户在支付完成后的 5 秒内,订单查询请求强制走主库;或者关键查询接口直接统一走主库。更简单粗暴的方案是查询时用主库,只有报表类和运营分析类查询才走从库。这个方案牺牲了一点读写分离的性能,但对金融交易场景来说,一致性比性能更优先。
另外一个容易被忽略的细节是订单列表的分页查询,如果订单量非常大,深分页会影响数据库性能。我们后来用 Redis 存储用户近 30 天订单 ID 列表,分页只查列表,再按 ID 批量查订单详情,性能提升非常明显。这也是为什么我总说金融系统的优化点往往不在 SQL 本身,而在数据访问路径的设计上。
做 financial-services 这类项目,和做普通业务系统最大的不同是:它的很多坑都是“一次性”的。账户模型设计错了,后面重构的代价非常高;状态机没定清楚,每一笔异常单都要人工介入;对账机制缺失,出了问题你根本不知道里面已经烂到什么程度。踩过这些坑之后,我个人的体会是:宁可前期多花两三天把边界划清楚、把表结构讨论透,也不要用上线后的无数个补丁去收拾局面。真正让人踏实的,不是代码写得多快,而是每一笔资金都能被解释清楚。