从零搭一个金融服务核心系统真实项目侧记先说结论这个代号叫 financial-services 的项目最终交付的是一个面向金融业务场景的实时交易风控与账户资金服务系统支撑日均千万级请求、核心链路耗时控制在百毫秒以内并且经历了完整的安全评审、压测和灰度上线流程。本文用我自己的视角把从需求拆解到落地的全过程复盘一遍包括我踩过的坑、事后想明白的取舍以及那些文档里不会写但实战里非常要命的小细节。适合正在做金融、支付、交易类系统的后端、架构或技术负责人阅读——哪怕你不是做金融的里面涉及的实时计算、规则引擎、数据一致性、容灾降级的思路也是完全可以平移的。做金融类系统跟做普通互联网业务有个本质区别普通业务挂了可以啪一下重启最多丢一点非关键数据金融场景里涉及到钱、交易、账户状态、用户资金安全任何一环出错都不是“修个bug”能收场的而是会直接走到合规、资损、客诉甚至监管层面。这也是为什么我把整个项目的核心矛盾总结为一句话在保证安全合规的前提下把系统做快、做稳、做得可追溯。下面我会按项目的推进顺序把每个环节的核心决策和实操过程完整展开。1. 项目定位与核心诉求拆解1.1 业务场景到底要解决什么问题这个项目起初的输入并不复杂无非是“financial-services”加上一张业务侧的简短描述要做一个承载资金类服务的后台平台涵盖交易处理、账户变动、风控拦截、对账清算几大块。但做过的人都知道业务一句话背后一万个坑。我当时做的第一件事不是选技术栈而是拉着业务方连续开了三轮需求澄清会把“金融服务”这四个字拆成了几个可落地的非功能性需求。第一资金安全是第一优先级。这意味着所有金额计算不能出现精度丢失、重复扣款、超扣、错账。别小看这几条它们决定了数据库字段类型、并发控制策略、幂等机制、以及整个对账体系怎么设计。第二实时性要求非常高。用户发起一笔交易从请求进入系统到返回结果整个链条涉及风控判断、账户余额更新、流水记录、消息通知等核心链路的耗时要控制在几百毫秒内。为了做到这一点我们不能在每个环节都走同步数据库事务而是要把一部分操作改为异步化、最终一致性。第三全链路可审计、可追踪。金融系统不做“删日志”所有交易的入参、出参、规则命中情况、操作人、时间戳都要有完整的记录。这既是合规要求也是后续排查资损问题时的救命稻草。第四系统要能扛住突发流量。金融业务有一个特性平时流量平稳但一旦有营销活动或者某个突发事件流量可以在几分钟内翻几倍甚至几十倍。比如某款理财产品开售抢购高峰期几乎是一瞬间所有用户同时涌入。如果系统没有熔断、限流、降级能力任何一个环节被击穿都可能拖垮整个链路。基于这四条我当时心里的技术方向已经基本清晰核心交易走强一致性的数据库事务但风控判断、额度计算、历史行为分析等非核心但耗时高的逻辑要前置到独立引擎中用异步和缓存加速。1.2 为什么不能直接套用普通互联网架构很多团队拿到这类项目第一反应是把以前做电商、内容系统的经验直接搬过来用一套微服务框架加上 Redis、MQ 就开干。我也不能说这套思路完全错但有几个坑在金融场景里是绕不过去的普通互联网系统讲究“可用性优先”极端情况下可以牺牲一点一致性金融系统反过来一致性出问题比短暂不可用更可怕。用户看不到你系统内部多复杂但他如果发现账户里钱不对、订单重复扣款了那整个信任感就垮了。普通系统可以接受“过一会儿数据同步就好”比如购物车商品数量偶尔延迟无所谓金融场景里余额这种数据如果出现主从延迟、双写不一致哪怕只差一分钱对账时也会报警。特别是涉及资金流水和账务明细的模块必须做到同一笔交易在所有记录点看到的结果完全一致。还有一个容易被忽略的点金融系统对外部的依赖非常多——支付渠道、银行接口、风控数据源、短信服务、实名认证等。任何一个外部接口的超时、抖动、返回异常主流程都得有兜底方案。当初我在设计时就立了一个规矩凡是可以降级的绝不放在主链路同步等待凡是外部依赖必须有超时控制和失败重试策略重试次数必须有上限。这些都是在做架构设计之前就要想清楚的东西不是等代码写完了再加。2. 技术选型与整体架构设计2.1 关键指标与技术栈选型过程需求理清之后我开始定技术指标这决定了后面所有选型。我们按业务预估和压测目标定了一组内部标准指标项目标值说明核心链路响应时间平均小于 100msP99 小于 300ms从请求进入网关到返回交易结果系统可用性99.99%全年不可用时间控制在 52 分钟以内并且核心资金操作不允许中断峰值吞吐单机 5000 QPS整体可水平扩展压测目标按业务峰值预估并留出 40% 余量数据一致性强一致账户/流水 最终一致异步通知/清算资金相关全部走强一致通知类允许最终一致故障恢复RPO 接近 0RTO 小于 30 秒数据库绝不能丢账切换要快技术栈方面我当时经过两轮对比才定下来。主语言是 Java理由不外乎生态成熟、团队熟悉、金融领域有大量可参考的最佳实践。框架层面用了 Spring Boot 做业务服务加上 Dubbo 做内部服务间调用。数据库用了 MySQL因为它能满足强事务需求并且运维经验最普遍。缓存用了 Redis热点数据、短时高频读全部走缓存。消息中间件用的是 RocketMQ主要看重它的事务消息能力和投递可靠性。风控引擎部分我们基于 Groovy 脚本自研了一套规则解析执行器这部分后面会细说。选型期间我最大的体会是金融项目选技术栈第一原则是成熟稳定不是新潮。很多中间件现在宣传得很好但生产环境沉淀不足、坑还没被踩透拿来承载资金链路就是给自己挖坑。优先选的必须是社区活跃、资料丰富、自己团队踩过坑的东西。2.2 整体架构分层与各层职责架构上我大致分了四层每层职责非常清晰接入层用的是 API 网关做统一鉴权、限流、灰度路由和基本的参数校验。外部的交易请求先到这里非法请求在这里直接被挡掉避免打到后端的业务服务。核心服务层承载了主要的业务代码包括交易服务、账户服务、风控服务、用户服务等内部通过 Dubbo 调用。这一层是业务规则最集中的地方也是代码review最严格的地方。基础数据层是 MySQL 实例集群按业务域拆库拆表账户库、流水库、风控库、订单库分开降低相互影响。Redis 集群承载热点数据、分布式锁、幂等标记等。异步与消息层则把非实时要求的动作全部异步化例如交易成功后的短信通知、征信上报、对账文件的生成、清算任务等通过 RocketMQ 的延迟消息和事务消息来调度。这个分层结构看起来简单但它有个非常大的好处每一层都可以独立扩缩容、独立降级。网关扛不住了只扩网关风控引擎出现性能瓶颈可以单独给风控服务加机器消息堆积了可以扩消费者。不会出现因为一个模块出问题而把整条主链路拖死的情况。我觉得架构设计里最重要的一件事是画清楚“哪条链路是主链路哪条链路是旁路”。主链路上只放绝对不能省的动作其余全部旁路异步化。很多系统慢不是因为机器性能差而是把不该在主链路上做的事都堆上去了。3. 核心核心细节解析与实操要点3.1 资金安全体系设计幂等、防重与一致性资金安全是金融系统里最核心的命题。我在这里花的精力最多因为它的很多问题不是上线后靠监控能发现的而是设计阶段就必须堵死的。第一层是接口幂等。用户可能会因为网络超时反复点击“确认支付”渠道方也可能会重发同一笔交易请求。如果不做幂等就会产生重复扣款。我们的做法是请求进来时先根据全局唯一业务流水号去 Redis 查幂等标记如果存在直接返回第一次的处理结果如果不存在则加分布式锁处理同时将请求的唯一 ID 写入数据库唯一索引从数据库层面再托底一次。也就是说即使 Redis 里的标记丢了、锁过期了数据库的唯一索引也能拦住第二次插入。三层保障基本杜绝了重复交易。第二层是余额防超扣。普通做法是先查余额、判断够不够、再扣减但并发场景下这两步之间可能插入其他交易导致最终余额变成负数。我们的做法是采用数据库乐观锁或原子更新减少余额的 SQL 里带上“余额大于等于扣减金额”的条件影响行数为 0 说明余额不足直接返回失败。这比“先查后扣”更安全可靠。差异的部分用流水记录来留痕每一笔扣减都必须对应一条流水流水的累计金额与账户余额严格相等对账时靠这个校验。第三层是金额精度。金融里金额一律用整数分存储数据库中字段类型是 BIGINT绝不使用浮点类型。对外传输时用字符串避免 JSON 序列化精度丢失。这一点看似基础但很多系统出问题恰恰是因为在某处偷偷用了 float 或 double。这里插一句我们踩过一次的教训有个同学在某个报表模块统计交易金额时用了 Double 类型日常金额小看不出来但数据量一大浮点误差就暴露了导致报表对账差了 0.01 元。排查半天最后发现就是精度类型的问题从那以后我们团队定了一个死规定任何涉及金额的字段代码里不允许出现 double/float一律用 Long分或者 BigDecimal字符串。3.2 风控引擎规则怎么配、怎么做实时决策风控引擎是这个项目里比较有技术含量的部分也是业务方最看重的。业务侧希望规则可以灵活调整但又不能每次改规则都发版上线。于是我们需要一个“规则与代码分离”的引擎。我们选型时对比了 Drools、EasyRules 和自研脚本方案。Drools 功能强大但学习成本高、规则文件的管理和部署都偏重EasyRules 轻量但表达能力有限最后我们选了 Groovy 脚本作为规则载体配合自研的规则配置后台。业务风控人员可以在后台配置规则比如“同一设备号在 10 分钟内交易次数超过 5 次即拦截”“单笔金额超过 5000 且用户历史交易量少于 3 笔时进入人工审核”。规则以脚本形式存储支持实时发布后台动态编译加载无需重启服务。具体执行时风控服务会收集一批特征数据用户历史交易频次、设备指纹、IP 归属、收货地址、金额、交易对手等组装成上下文对象然后依次执行命中的规则。规则执行结果有三种放行、拦截、人工审核。整体耗时要求小于 50ms所以特征数据全部走 Redis绝不查库。高频特征如“近 10 分钟交易次数”通过在 Redis 里维护滑动窗口计数器实现用 Lua 脚本保证原子性。一个容易被忽略的细节是特征数据的时效性。如果风控判断依赖用户历史数据而这个数据是 T1 异步更新的那风控规则对一个刚注册的新用户和一个老商户的判断能力差距会很大。我们后来把关键特征改成实时写入加异步归并比如设备指纹和交易频次实时更新到 Redis历史汇总按小时异步落库。这样既保证实时性又不给主链路增加太多开销。3.3 数据库与缓存的一致性方案金融系统里数据库是唯一可信数据源Redis 只做加速和限流标记绝不把 Redis 当成权威数据。这里最容易被问倒的问题是先更新数据库还是先更新缓存我们的答案是主链路根本不主动更新缓存而是删除缓存等待下次读取时回填。为什么更新缓存存在并发下旧值覆盖新值的风险删除缓存虽然也会带来一次缓存穿透但配合短过期时间和数据库兜底风险要小得多。账户余额的读取是一个典型优化点。高频查询场景下每次都查数据库扛不住但余额又是强一致数据。我们的方案是走 Redis但设置了极短的过期时间比如 3 到 5 秒并且在更新余额时同步删除缓存。严格来说这会存在最多几秒的缓存与数据库不一致窗口但在余额展示场景下可以接受真正扣款时以数据库为准不依赖缓存里的余额。这一点必须跟业务方对齐预期否则后面容易扯皮。还有一点所有缓存值必须设置过期时间不允许存在永久 key。即使业务上感觉这个数据永远不会变也要设一个较长的兜底过期防止缓存与数据库长期不一致。4. 实操过程与核心环节实现4.1 交易主链路的代码级实现思路主链路我拆成七个步骤参数校验、风控预检、账户加锁、余额扣减、流水落库、发送消息、返回结果。前几步都在本地或者 Redis 完成真正落库的只有账户扣减和流水写入这两个强一致动作。账户加锁这一步很多人会想到用数据库行锁也就是SELECT ... FOR UPDATE它在并发量不高的场景下完全没问题但在峰值 5000 QPS 的场景下行锁会让数据库连接池很快耗尽。我们优化后的方案是热点账户比如平台账户、公共账户走 Redis 分布式锁普通用户账户依然走数据库行锁。热点账户数量少锁粒度清晰Redis 锁完全可以支撑。扣减余额的 SQL 我写出来供参考其中balance #{amount}这个条件就是防超扣的关键UPDATE account SET balance balance - #{amount}, updated_at NOW() WHERE account_id #{accountId} AND balance #{amount}影响行数为 1 则扣减成功为 0 则余额不足。紧接着插入一条流水记录两者放在同一个数据库事务里要么同时成功要么同时失败不会出现钱扣了流水没记的情况。流水表的设计也有讲究。我们用的是流水号全局唯一 账户ID索引的模式流水类型区分充值、消费、退款、调账等。每笔流水必须包含业务订单号、关联流水号、金额、方向、时间戳、操作人等为后续对账和审计提供完整数据。尤其要注意的是“调整前余额”和“调整后余额”这两个字段虽然不是强制的但对资损排查有巨大价值强烈建议加上。4.2 配置化规则引擎的设计与踩坑规则引擎上线的第一个月我们踩了一个比较大的坑Groovy 脚本类加载器内存泄漏。每次规则变更都重新编译加载一个新的 GroovyClassLoader旧类的引用没有被回收几次发布后 PermGen 区当时还是 JDK 8 之前的模型就爆了。后来我们统一维护了一个固定的 GroovyClassLoader通过parseClass复用同一个加载器来编译新规则问题才解决。如果你们用 JDK 8 的元空间虽然不会爆 PermGen但类加载器数量多了同样会导致元空间增长依然要注意。规则引擎发布时我们规定了“灰度验证”步骤新规则先发布到一小部分流量上观察命中率和误杀率确认稳定后再全量。这个习惯救过我们一次——有一次新规则的正则表达式写错导致大量正常用户被误判为风险用户但因为走了灰度影响范围被控制在了 2% 的流量内几分钟内就回滚了。风控规则要时不时做一次“规则覆盖率”检查把所有历史命中记录拉出来看看是不是有规则从上线到现在从来没有命中过。这类僵尸规则要么是业务已经不需要了要么是条件配置有误导致永远匹配不上留着只会白白消耗性能。4.3 压测实操记录从 800 QPS 到 5000 QPS我单独把压测环节拉出来写是因为这个环节最考验系统设计的真实水平。我们用的压测工具是 JMeter 加自研流量回放工具分三轮推进。第一轮压测我们对单机峰值 QPS 充满了信心结果实际一压800 QPS 的时候 RT 就开始飙升数据库连接池被打满。排查后发现问题出在一个不起眼的操作上——用户风控画像查询在 DB 里做了全表扫描因为最初设计时忘记给 user_id 建立联合索引。加完索引后单机直接压到了 1800 QPS。第二轮压测目标调高到 3000 QPS这次顶不住的是 Redis。高频计数器key集中在一个实例上热点严重单实例 CPU 打满。我们把热点key做了多副本拆分比如把 hash 尾部加随机后缀拆成 16 个虚拟分片读的时候随机读一个分片、写的时候写所有分片。虽然牺牲了一点实时精确度但在风控场景下完全够用。压测结果提升到了 4200 QPS。第三轮压测我们发现 RT 在高峰期开始出现长尾。深入排查后定位到一个很经典的问题Tomcat 线程池的阻塞队列满了部分请求被拒绝。于是我们把同步调用改成异步化把非必须的短信通知、审计日志等挪到 RocketMQ 异步消费主线程不再等待这些操作完成。最终目标达成单节点 5000 QPSP99 150ms。压测全程让我最深的一个体会是系统设计的瓶颈往往不在单个组件多强大而在每个环节的短板是否被补齐。一次压测就是把所有短板都暴露一遍的过程。千万不要在没做过全链路压测的情况下直接上线哪怕业务再催。上线前压测十次好过上线后事故一次。4.4 多活容灾与降级预案容灾多活这部分我们的设计方案是在两个机房都部署全套服务数据库采用主主同步模式平时只有一个主库提供读写另一个机房实时同步主库不可用时秒级切换。当然主主模式风险高需要非常严格的冲突避免我们只对账户和流水表启用并且业务代码里写死了流量路由避免两个机房同时写同一个用户。降级预案里最重要的一条原则是降级开关必须先于故障执行不能等系统已经挂了再操作。我们为每个核心依赖都配了开关例如风控引擎降级、消息发送降级、短信通知降级、外部渠道重试降级。平时这些开关是关闭的一旦监控显示某个模块异常率超过阈值值班同学一键打开对应降级开关保证主流程继续可用。降级是取舍的艺术比如风控降级意味着风险可能会放大所以降级行为本身必须有审计记录并且风控降级只允许持续有限时间超时必须有更高级别的审批才能继续。在演练加真实故障的双重验证下最终我们的故障切换时间从最初的 5 分钟逐步压缩到 20 秒左右也算达到了最初的 RTO 目标。5. 常见问题与排查技巧实录5.1 线上资损类问题的排查路径做金融系统最怕的就是资损。资损出现后第一件事不是急着改代码而是先止血再定位最后才是修复。我分享一套我们验证过的排查路径。第一步看流水任何一笔交易都有流水记录把用户ID、订单号、时间段输入流水查询工具列出所有相关流水看看余额变动是否符合逻辑。第二步看对账跑一次针对该用户的余额 所有流水变动累计之和的校验脚本如果对不上说明存在流水缺失或余额异常更新。第三步查日志把该用户全链路的请求日志打出来看是否存在重复请求、超时重试、接口返回异常等。第四步看Redis确认缓存中的幂等标记是否被异常清掉锁是否存在提前释放的问题。最典型的资损 bug 其实是“超时重试导致重复扣款”渠道返回超时但实际交易成功我们的服务以为失败于是重试结果同一订单扣了两次。后来我们在所有涉及扣款的接口统一加了“订单号维度幂等表”并且重试前先反查渠道交易状态绝不在结果未明的情况下盲目发起第二次扣款。这样做之后重复扣款类的问题基本绝迹了。5.2 性能排查中那些最常见的“假凶手”有一段时间系统整体 RT 上升我们怀疑是数据库慢查询结果 DBA 查了半天慢查询日志也没有发现特别离谱的 SQL。后来才发现是同一机房内一个服务通过公网 IP 调用另一个服务的 Dubbo 接口每次调用都经过公网 NAT光握手延迟就多了几十毫秒。改成内网调用后 RT 立刻降了下来。另一个常见的“假凶手”是日志。很多人不重视日志 I/O 的性能开销结果系统在高峰期几乎每条请求都打三四条 INFO 日志日志文件疯狂滚动磁盘 I/O 成为瓶颈。后来我们把核心链路的日志级别调整为 WARN且全链路跟踪日志单独走异步文件输出。别小看这一步高峰期日志写入量降了 80%整体 RT 肉眼可见地稳定了。线程池配置也踩过坑。有些同学喜欢把 Tomcat 的 maxThreads 调得特别大比如 1000觉得线程越多吞吐越高。但线程多了之后上下文切换开销飙升CPU 反而大部分时间花在切换上。我们的经验值是线程池大小 CPU 核数 ×1 平均等待时间 / 平均计算时间根据压测结果来配置而不是拍脑袋。核心服务一般配 200 到 400 之间配合合理的队列长度就够了。5.3 值班过程中的快速止损清单最后放一份我们的快速止损清单值得复制保存场景快速止损动作恢复措施数据库连接池打满开启限流拒绝部分非核心读请求扩容连接池、优化慢SQL、必要时重启实例风控引擎异常率升高一键降级风控放量后人工抽检定位脚本问题回滚至上一版本Redis 热点key打满CPU拆分成多副本虚拟分片增加实例调整数据分布策略外部支付渠道超时开启重试降级改为异步对账兜底联系渠道方确认状态恢复后重放未确认交易消息堆积严重停止非关键消费者优先处理资金类消息扩容消费者待堆积下降后逐步放开这个清单的价值不在于每条多高深而在于它把“紧急情况下做什么”从大脑里搬到了一个所有人都能看到的地方。值班的人不需要现场思考只要按照清单执行就能在最短时间内把影响控制住。我强烈建议每个金融项目都维护一份这样的清单并且每个季度做一次故障演练让所有人都熟悉开关位置和操作路径。6. 上线后的持续治理与沉淀上线只是开始真正考验系统的是运行中的每一天。我们上线后专门成立了一个小团队持续做三件事监控告警治理、稳定性演练、以及对账体系完善。监控这块除了常规的 CPU、内存、QPS、RT 之外我们额外加了业务级别的监控交易成功率、扣款失败率、风控拦截率、对账差异笔数等。每一个业务指标都配置了对应的告警阈值一旦偏差超过正常范围的 20%就会通知到值班群。业务监控比基础设施监控更能反映系统健康状况这一点我认为值得所有做业务的团队重视。稳定性演练我们坚持每月做一次。可能会有读者觉得太频繁但金融场景真的没得选。每次演练会随机抽取一个故障场景比如杀掉一个节点、断开一个机房的网络、停掉一个外部依赖然后看系统能不能自动恢复。演练完成后再复盘把暴露出的问题修复掉下个月再来。经过大半年我们总结出了一句话系统不是设计出来就稳了是不断被搞坏再修好之后才变稳的。对账体系是整个项目里最“不起眼但最保命”的部分。每天凌晨系统自动从支付渠道拉取前一日的交易对账单与本地流水做逐笔比对找出差异记录并触发告警。这看起来是个批处理任务但它能在用户还没发现之前就捕捉到资损风险。对账文件解析、差异处理、异常重试这些细节都不复杂复杂的是坚持每天跑、每天看、出了差异敢处置。我个人的体会是做金融系统的过程中最珍贵的不是某段代码写得多漂亮而是形成了怎样的流程和习惯先想清楚一致性目标再选型先设计好降级预案再上线先做全链路压测和演练再放量。把每一步都走得扎实系统才能走得远。这套思路我后来又用在了几个非金融的项目上效果同样明显——毕竟任何系统的基础都是不出错、可恢复、能追溯。希望这篇复盘能给你正在做的项目带来一些参考。