1. 项目背景与整体定位
1.1 这个项目到底在解决什么问题
闪聚支付这个项目,我第一次接触的时候,最直观的感受就是:它把平时散落在各个角落的支付逻辑,硬生生地收拢成了一套可以讲清楚、可以跑通、可以复现的完整体系。很多做后端或者做业务系统的人,平时接触支付无非就是调个接口、收个回调、改个状态,但真要把支付当成一个独立项目来拆解,你会发现里面牵扯的东西远比想象中多——订单怎么建、金额怎么算、渠道怎么选、回调怎么保证不丢、对账怎么兜底,每一个环节单独拎出来都能写一整篇。
这个项目适合谁看?我的判断是三类人。第一类是刚入行不久、想从“只会写增删改查”往“能扛业务模块”过渡的开发者,支付是最好的练手场景之一,因为它天然带有强一致性、幂等、状态机这些硬核概念。第二类是有一定经验、但没系统梳理过支付链路的工程师,你可能平时零散地写过一些支付相关代码,但没想过把它们串成一条完整的线。第三类是想做一个完整项目放进自己作品集的人,支付项目的含金量比一般的后台管理系统要高不少,因为它能体现你对业务边界和异常处理的理解。
我个人的习惯是,拿到任何一个项目,先不看代码,先问三个问题:它的核心对象是什么、它的主流程是什么、它最容易出问题的地方在哪里。闪聚支付这个项目,核心对象就是“支付单”和“交易流水”,主流程是“下单—支付—回调—对账”,最容易出问题的地方则是回调的幂等和状态的一致性。把这三个问题想明白,后面看什么都顺。
1.2 为什么选择支付作为学习载体
很多人学技术喜欢挑简单的做,比如做个博客、做个待办清单,这些当然没问题,但它们的业务复杂度太低,你很难从中体会到“一个状态改错了会带来什么后果”。支付不一样,支付里的每一个字段、每一次状态流转,背后都对应着真实的资金含义。你少写一个校验,可能就意味着重复扣款;你回调处理不当,可能就意味着订单状态和实际资金不一致。
闪聚支付这个项目在设计上有一个很明显的取向,就是它不追求大而全,而是把一条主链路做扎实。这一点我很认同。我见过太多项目,功能列表拉得很长,但每个功能都是浅尝辄止,最后学的人也是一知半解。与其这样,不如先把“用户下单、选择支付方式、发起支付、接收回调、更新订单、生成对账文件”这一条线走通,走透,再考虑扩展。
从技术选型的角度来说,这类项目通常会涉及几个关键点:一是接口的幂等设计,二是异步回调的可靠性,三是状态机的严格管理,四是对账的兜底机制。这四个点,基本上就是支付系统的四根柱子。你把这四根柱子立住了,后面加什么渠道、加什么业务类型,都只是在这个骨架上面挂东西。
1.3 项目的整体架构思路
闪聚支付在架构上走的是比较典型的分层思路。最上面是面向用户的接口层,负责接收下单请求、查询请求;中间是业务逻辑层,负责处理订单创建、支付单生成、渠道路由;下面是渠道适配层,负责对接不同的支付渠道,把统一的请求转换成各个渠道要求的格式;再往下是数据层,负责订单、支付单、流水、对账文件的持久化。
这种分层的好处在于,渠道适配层被单独抽出来了。你想想,如果哪天要接一个新的支付渠道,你只需要在适配层加一个实现,上层业务逻辑几乎不用动。这就是面向接口编程的价值。很多新手写支付,喜欢把渠道相关的逻辑直接写在业务层里,用一堆 if-else 来判断走哪个渠道,短期看没问题,长期看就是灾难。
另一个值得说的点是,这个项目在数据表设计上通常会区分“订单表”和“支付单表”。为什么要分开?因为一个订单可能对应多次支付尝试。用户第一次支付失败了,第二次换了个渠道再支付,这时候订单还是那个订单,但支付单应该生成新的。如果你把订单和支付单混在一张表里,这种场景就很难处理。这个设计细节,是我觉得新手最容易忽略、但恰恰最能体现设计功力的地方。
2. 核心模块拆解与设计考量
2.1 订单模块:一切从这里开始
订单模块是整个支付链路的起点。用户在前端点击“确认支付”,后端第一件事就是创建订单。这个订单里通常包含几个关键信息:订单号、用户标识、商品信息、订单金额、订单状态、创建时间。看起来很简单,但这里面有几个坑。
第一个坑是订单号的生成。很多新手直接用数据库自增 ID 当订单号,这在单机环境下没问题,但一旦涉及到分库分表或者需要对外暴露订单号,自增 ID 就不合适了。常见的做法是使用雪花算法生成一个全局唯一的 ID,或者用“时间戳+随机数+业务标识”的组合。闪聚支付这类项目通常会采用后者,因为实现简单,而且订单号本身还能携带一些业务信息。
第二个坑是金额的存储。我见过有人用 float 或者 double 来存金额,这是绝对不行的。浮点数在计算过程中会有精度丢失,比如 0.1+0.2 不等于 0.3,这在支付场景里是致命的。正确的做法是用整数来存,单位是分。比如 10 元存成 1000 分。这样所有的加减乘除都是整数运算,不会有精度问题。这个点我在很多项目里都强调过,但每次还是能看到有人踩坑。
第三个坑是订单状态的初始值。订单创建之后,状态应该是“待支付”还是“已创建”?这两个状态看起来差不多,但在状态机里含义不同。“已创建”表示订单已经落库,但用户还没有发起支付;“待支付”表示用户已经选择了支付方式,正在支付中。闪聚支付这个项目通常会区分这两个状态,因为它们的后续流转路径不一样。
提示:订单表里建议加一个“过期时间”字段。用户下单之后如果一直不支付,订单应该在一定时间后自动关闭,释放库存或者优惠券。这个逻辑通常用定时任务来实现,但要注意定时任务的执行频率和订单量的匹配,频率太高浪费资源,频率太低订单关闭不及时。
2.2 支付单模块:一次支付尝试的完整记录
支付单是订单和具体支付渠道之间的桥梁。一个订单可以对应多个支付单,但同一时间只能有一个“进行中”的支付单。支付单里通常包含:支付单号、关联订单号、支付渠道、支付金额、支付状态、渠道返回的流水号、创建时间、完成时间。
支付单的状态流转比订单更细。一般来说会有“待支付”“支付中”“支付成功”“支付失败”“已关闭”这几个状态。这里面的关键是“支付中”这个状态。为什么需要它?因为用户发起支付之后,渠道那边处理是需要时间的,可能是几百毫秒,也可能是几秒。在这段时间里,支付单的状态就是“支付中”。如果渠道返回成功,就变成“支付成功”;如果返回失败,就变成“支付失败”;如果超时没有返回,就需要有一个补偿机制去查询渠道,确认最终状态。
这个“支付中”状态的处理,是支付系统里最考验人的地方。我见过很多项目,支付单只有“待支付”和“支付成功”两个状态,用户发起支付之后直接等回调,回调来了就改成功,没来就一直等着。这种设计在正常情况下没问题,但一旦回调丢失或者延迟,支付单就会一直卡在“待支付”,用户那边看到的就是“支付处理中”,体验很差。
闪聚支付这个项目在支付单模块的设计上,通常会引入一个“主动查询”的机制。也就是说,除了被动等待回调,系统还会在支付单创建后的一段时间内,主动去渠道查询支付结果。这个查询通常由定时任务触发,比如每隔 5 秒查一次,查几次之后如果还没有结果,就标记为失败或者进入人工处理。这个机制看起来简单,但它是保证支付可靠性的关键一环。
2.3 渠道适配模块:统一接口,屏蔽差异
渠道适配模块是闪聚支付项目里我觉得最有学习价值的部分。不同的支付渠道,接口格式、签名方式、回调格式都不一样。如果没有适配层,你的业务代码里就会充斥各种渠道特有的逻辑,维护起来非常痛苦。
适配层的核心思路是“面向接口编程”。定义一个统一的支付接口,比如pay(PayRequest request)和query(QueryRequest request),然后每个渠道实现这个接口。业务层只依赖这个接口,不关心具体是哪个渠道。这样新增渠道的时候,只需要加一个实现类,然后在配置里注册一下就行。
这里面有几个细节值得展开。第一个是签名。大部分渠道都要求对请求参数进行签名,防止篡改。签名的算法通常是 MD5 或者 HMAC-SHA256,具体用哪个取决于渠道的要求。适配层需要把统一的请求参数转换成渠道要求的格式,然后按照渠道的规则签名。这个转换过程最好单独抽一个类来做,不要和业务逻辑混在一起。
第二个是回调处理。渠道的回调格式也各不相同,有的用 JSON,有的用 XML,有的用表单。适配层需要把回调数据解析成统一的格式,然后交给业务层处理。这里要注意的是,回调处理必须做幂等。因为渠道可能会重复发送回调,如果你的处理逻辑不是幂等的,就会导致重复更新订单状态,甚至重复发货。
第三个是异常处理。调用渠道接口的时候,可能会遇到网络超时、渠道返回错误码、渠道系统维护等各种异常。适配层需要把这些异常统一转换成业务层能理解的异常类型,比如“渠道暂时不可用”“渠道返回参数错误”等。这样业务层就可以根据异常类型决定是重试还是直接失败。
| 渠道特性 | 常见处理方式 | 注意事项 |
|---|---|---|
| 签名算法 | MD5/HMAC-SHA256 | 密钥不要硬编码在代码里 |
| 回调格式 | JSON/XML/表单 | 解析前先做格式校验 |
| 超时时间 | 通常 5-30 秒 | 根据渠道文档设置,不要拍脑袋 |
| 重试策略 | 指数退避 | 避免频繁重试导致渠道限流 |
| 对账文件 | CSV/TXT | 注意文件编码和分隔符 |
2.4 对账模块:最后的兜底防线
对账模块是很多学习项目会忽略的部分,但闪聚支付这个项目把它放进来了,我觉得这是一个很正确的选择。对账的本质是:拿渠道那边的流水记录,和系统自己的支付单记录做比对,找出不一致的地方。
对账通常分为两步。第一步是下载对账文件。渠道一般会在每天凌晨生成前一天的交易流水文件,系统需要定时去下载。第二步是解析文件并比对。把文件里的每一笔交易和系统里的支付单做匹配,匹配上的标记为“已对账”,匹配不上的标记为“异常”,需要人工介入。
对账模块的价值在于,它能发现那些通过正常回调发现不了的问题。比如渠道那边扣了钱,但回调因为网络问题没发过来,系统里的支付单还是“支付中”。如果没有对账,这笔交易就永远卡在那里了。有了对账,第二天就能发现这笔差异,然后手动把支付单改成“支付成功”。
注意:对账文件的下载和解析一定要做异常处理。文件可能不存在、可能格式不对、可能内容为空,这些情况都要考虑到。我见过有人的对账任务因为文件格式变化直接抛异常,导致整个对账流程中断,后面几天的对账都没跑。
3. 实操流程与关键环节实现
3.1 从零搭建项目骨架
假设你现在要动手把这个项目跑起来,第一步肯定是搭骨架。我的习惯是先建一个多模块的 Maven 或者 Gradle 项目,把接口层、业务层、渠道层、数据层分开。这样做的好处是依赖关系清晰,不会出现循环依赖。
具体来说,可以建这么几个模块:payment-api放对外接口和 DTO,payment-service放业务逻辑,payment-channel放渠道适配,payment-dao放数据库访问。模块之间的依赖关系是单向的:api 依赖 service,service 依赖 channel 和 dao,channel 和 dao 之间没有依赖。
数据库方面,至少需要这几张表:订单表、支付单表、渠道配置表、对账记录表。订单表和支付单表的字段前面已经说过了,渠道配置表用来存各个渠道的商户号、密钥、回调地址等信息,对账记录表用来存每次对账的结果。
CREATE TABLE `order` ( `id` bigint NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL, `user_id` bigint NOT NULL, `amount` int NOT NULL COMMENT '单位:分', `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-已创建 1-待支付 2-已支付 3-已关闭', `expire_time` datetime NOT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`) ); CREATE TABLE `payment` ( `id` bigint NOT NULL AUTO_INCREMENT, `payment_no` varchar(64) NOT NULL, `order_no` varchar(64) NOT NULL, `channel` varchar(32) NOT NULL, `amount` int NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0-待支付 1-支付中 2-支付成功 3-支付失败 4-已关闭', `channel_trade_no` varchar(128) DEFAULT NULL, `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `finish_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_payment_no` (`payment_no`), KEY `idx_order_no` (`order_no`) );建表的时候有几个细节要注意。订单号和支付单号都要加唯一索引,这是防止重复创建的最后一道防线。金额字段用 int,注释里写清楚单位是分。状态字段用 tinyint,不要用 varchar,既省空间又方便比较。
3.2 下单与支付的核心代码逻辑
下单的逻辑其实不复杂,核心就是校验参数、生成订单号、落库。但有几个地方需要特别注意。第一是参数校验,金额必须大于 0,用户标识不能为空,商品信息不能为空。第二是订单号的生成,要保证全局唯一。第三是过期时间的设置,一般是下单后 30 分钟。
public Order createOrder(CreateOrderRequest request) { // 参数校验 if (request.getAmount() <= 0) { throw new BizException("金额必须大于0"); } // 生成订单号 String orderNo = generateOrderNo(); // 构建订单 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(request.getUserId()); order.setAmount(request.getAmount()); order.setStatus(OrderStatus.CREATED); order.setExpireTime(LocalDateTime.now().plusMinutes(30)); // 落库 orderMapper.insert(order); return order; }支付逻辑比下单复杂一些。用户选择支付渠道之后,系统需要先检查订单状态,确认订单是“待支付”或者“已创建”,然后创建支付单,调用渠道接口,最后根据渠道返回的结果更新支付单状态。
public PayResponse pay(PayRequest request) { // 查询订单 Order order = orderMapper.selectByOrderNo(request.getOrderNo()); if (order == null) { throw new BizException("订单不存在"); } if (order.getStatus() != OrderStatus.CREATED && order.getStatus() != OrderStatus.PENDING) { throw new BizException("订单状态不允许支付"); } // 创建支付单 Payment payment = new Payment(); payment.setPaymentNo(generatePaymentNo()); payment.setOrderNo(order.getOrderNo()); payment.setChannel(request.getChannel()); payment.setAmount(order.getAmount()); payment.setStatus(PaymentStatus.PENDING); paymentMapper.insert(payment); // 调用渠道 Channel channel = channelFactory.getChannel(request.getChannel()); PayResult result = channel.pay(buildChannelRequest(payment)); // 更新支付单状态 if (result.isSuccess()) { payment.setStatus(PaymentStatus.PAYING); payment.setChannelTradeNo(result.getChannelTradeNo()); } else { payment.setStatus(PaymentStatus.FAILED); } paymentMapper.updateById(payment); return buildPayResponse(result); }这段代码里有一个关键点:调用渠道之前,支付单已经落库了,状态是“待支付”。调用渠道之后,根据结果更新为“支付中”或者“支付失败”。为什么要先落库再调用?因为如果先调用再落库,万一调用成功但落库失败,这笔支付就丢了,渠道那边扣了钱,系统里却没有记录。先落库再调用,最坏的情况是落库成功但调用失败,这时候支付单是“待支付”,可以通过主动查询来确认最终状态。
3.3 回调处理的幂等与状态机
回调处理是支付系统里最容易出问题的地方。渠道回调过来,系统需要做几件事:验签、解析参数、查询支付单、更新状态、返回成功。这里面每一步都可能出问题。
验签是第一步,也是最重要的一步。如果验签不通过,说明这个回调可能是伪造的,直接拒绝。验签的逻辑通常是拿回调参数按照渠道规定的规则重新计算签名,然后和回调里的签名比对。
更新状态的时候,必须做幂等。怎么做?最简单的办法是加一个状态判断:如果支付单已经是“支付成功”,就直接返回成功,不再重复处理。这个判断要放在更新之前,而且要用数据库的行锁或者乐观锁来保证并发安全。
@Transactional public String handleCallback(String channel, Map<String, String> params) { // 验签 if (!verifySign(channel, params)) { return "FAIL"; } // 查询支付单 String paymentNo = params.get("payment_no"); Payment payment = paymentMapper.selectByPaymentNo(paymentNo); if (payment == null) { return "FAIL"; } // 幂等判断 if (payment.getStatus() == PaymentStatus.SUCCESS) { return "SUCCESS"; } // 更新状态 payment.setStatus(PaymentStatus.SUCCESS); payment.setChannelTradeNo(params.get("channel_trade_no")); payment.setFinishTime(LocalDateTime.now()); int updated = paymentMapper.updateStatusWithLock(payment); if (updated == 0) { // 并发情况下可能已经被其他线程更新了 return "SUCCESS"; } // 更新订单状态 orderService.markPaid(payment.getOrderNo()); return "SUCCESS"; }这里的updateStatusWithLock方法需要在 SQL 里带上状态条件,比如UPDATE payment SET status = 2 WHERE payment_no = ? AND status != 2。这样即使两个回调同时到达,也只有一个能更新成功,另一个会返回 0 行受影响,然后直接返回成功。
提示:回调接口一定要做好日志记录。把回调的原始参数、验签结果、处理结果都记下来。出了问题的时候,这些日志就是你的救命稻草。我见过有人回调处理失败,但什么日志都没打,最后只能靠猜。
3.4 主动查询与定时补偿
主动查询是回调的补充机制。支付单创建之后,如果在一定时间内没有收到回调,系统就应该主动去渠道查询支付结果。这个逻辑通常由定时任务来实现。
定时任务的频率怎么定?我的经验是,支付单创建后的前 2 分钟,每 5 秒查一次;2 分钟到 10 分钟,每 30 秒查一次;10 分钟到 30 分钟,每 2 分钟查一次;超过 30 分钟还没有结果,就标记为失败,进入人工处理。这个频率不是固定的,要根据渠道的响应速度和业务量来调整。
@Scheduled(fixedDelay = 5000) public void queryPendingPayments() { // 查询创建时间在30分钟内、状态为支付中的支付单 List<Payment> payments = paymentMapper.selectPayingPayments( LocalDateTime.now().minusMinutes(30) ); for (Payment payment : payments) { try { Channel channel = channelFactory.getChannel(payment.getChannel()); QueryResult result = channel.query(payment.getPaymentNo()); if (result.isSuccess()) { handleCallback(payment.getChannel(), result.toCallbackParams()); } else if (result.isFailed()) { payment.setStatus(PaymentStatus.FAILED); paymentMapper.updateById(payment); } } catch (Exception e) { log.error("查询支付单失败: {}", payment.getPaymentNo(), e); } } }这个定时任务有几个要注意的地方。第一是异常处理,单个支付单查询失败不能影响其他支付单。第二是频率控制,如果支付单量很大,每 5 秒查一次可能会给渠道造成压力,这时候可以考虑分批查询。第三是超时处理,超过一定时间还没有结果的支付单,要自动标记为失败,不能无限期地查下去。
3.5 对账文件的下载与解析
对账文件的下载通常是通过 HTTP 或者 SFTP。渠道会提供一个文件下载地址,系统定时去下载。下载下来的文件一般是 CSV 或者 TXT 格式,每行一条交易记录。
解析文件的时候,要注意几个问题。第一是文件编码,有的渠道用 UTF-8,有的用 GBK,解析之前要先确认。第二是分隔符,CSV 用逗号,TXT 可能用竖线或者制表符。第三是文件头,有的文件第一行是标题行,解析的时候要跳过。
public void reconcile(String channel, LocalDate date) { // 下载文件 File file = downloadReconcileFile(channel, date); // 解析文件 List<ReconcileRecord> records = parseFile(file); // 逐条比对 for (ReconcileRecord record : records) { Payment payment = paymentMapper.selectByPaymentNo(record.getPaymentNo()); if (payment == null) { // 系统里没有这笔支付单,记录异常 saveException(record, "支付单不存在"); continue; } if (payment.getAmount() != record.getAmount()) { // 金额不一致,记录异常 saveException(record, "金额不一致"); continue; } if (payment.getStatus() != PaymentStatus.SUCCESS) { // 系统里状态不是成功,但渠道那边是成功,需要修正 payment.setStatus(PaymentStatus.SUCCESS); paymentMapper.updateById(payment); } } }对账的结果要保存下来,方便后续查询和统计。对账记录表里通常包含:对账日期、渠道、总笔数、成功笔数、异常笔数、对账状态。异常记录要单独存一张表,包含具体的异常原因和原始数据。
4. 常见问题与排查技巧实录
4.1 回调丢失怎么办
回调丢失是支付系统里最常见的问题之一。表现就是用户明明支付成功了,但系统里的订单还是“待支付”。造成回调丢失的原因有很多:网络抖动、渠道系统故障、回调地址配置错误、防火墙拦截等。
排查思路是这样的:第一步,先确认渠道那边是不是真的扣款成功了。可以通过渠道的商户后台查询,或者调用渠道的查询接口。第二步,如果渠道那边确实成功了,但系统没收到回调,就去检查回调地址是否配置正确、服务器是否能被外网访问、防火墙是否放行了回调端口。第三步,如果这些都正常,那就是回调在传输过程中丢了,这时候就需要靠主动查询和对账来兜底。
我的经验是,不要指望回调 100% 可靠。任何依赖回调的系统,都必须有主动查询和对账作为补充。回调只是“尽快通知”,不是“保证通知”。
4.2 重复回调导致重复处理
重复回调的原因通常是渠道在没收到成功响应之前会不断重试。如果你的回调接口处理时间过长,或者返回的响应格式不对,渠道就会认为你没收到,然后继续发。
解决重复回调的核心是幂等。前面说过,在更新状态之前先判断当前状态,如果已经是终态就直接返回成功。但这里有一个细节:判断和更新必须是原子的。如果你先查询状态,发现是“支付中”,然后准备更新为“支付成功”,但在这两步之间,另一个回调也查询到“支付中”,也准备更新,就会导致两次更新都执行。所以更新的时候必须带上状态条件,用数据库的行锁来保证原子性。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 订单一直待支付 | 回调丢失 | 查渠道后台、查回调日志 | 主动查询+对账兜底 |
| 订单重复更新 | 重复回调 | 查回调日志、查更新记录 | 幂等判断+状态条件更新 |
| 金额不一致 | 精度问题 | 检查金额存储类型 | 用整数存分 |
| 支付单卡在支付中 | 渠道超时 | 查渠道查询接口 | 定时查询+超时标记失败 |
| 对账文件解析失败 | 编码/格式变化 | 查看原始文件 | 加编码检测+格式兼容 |
4.3 渠道接口调用超时
调用渠道接口超时是很常见的。超时之后,你不知道渠道那边到底有没有处理成功。这时候千万不能直接标记为失败,因为有可能渠道已经扣款了,只是响应没回来。
正确的做法是:超时之后,把支付单状态保持为“支付中”,然后通过主动查询来确认最终状态。如果查询到成功,就更新为成功;如果查询到失败,就更新为失败;如果查询不到,就继续查,直到超时时间到了再标记为失败。
超时时间怎么设置?一般渠道的文档都会给出建议值,通常是 5 到 30 秒。我的经验是,设置成比渠道平均响应时间略长一点,比如渠道平均 2 秒返回,那就设置 10 秒。太短了容易误判,太长了会拖慢整个请求。
4.4 数据库连接池耗尽
支付系统在高峰期会有大量的数据库操作,如果连接池配置不当,很容易出现连接耗尽的情况。表现就是请求大量超时,日志里全是“获取数据库连接超时”。
解决这个问题,首先要检查连接池的最大连接数是否足够。一般来说,支付系统的连接池最大连接数可以设置成 50 到 100,具体要看数据库的承载能力。其次要检查有没有慢 SQL,慢 SQL 会长时间占用连接,导致连接池被占满。最后要检查有没有连接泄漏,比如开了事务但没提交或者没回滚。
注意:支付相关的数据库操作一定要加索引。订单表的 order_no、支付单表的 payment_no 和 order_no,这些字段都要有索引。没有索引的查询在数据量大了之后会非常慢,直接拖垮整个系统。
4.5 日志与监控的配置要点
支付系统的日志和监控非常重要。日志方面,至少要记录这几类:请求日志(谁在什么时候发起了什么请求)、渠道调用日志(调用了哪个渠道、请求参数是什么、响应是什么、耗时多少)、回调日志(收到了哪个渠道的回调、参数是什么、处理结果是什么)、异常日志(出了什么错、堆栈是什么)。
监控方面,要关注几个核心指标:支付成功率、平均响应时间、回调处理延迟、对账异常笔数。这些指标一旦出现异常波动,就要及时排查。比如支付成功率突然下降,可能是某个渠道出了问题;回调处理延迟突然增大,可能是回调处理逻辑有性能瓶颈。
我个人的习惯是,在支付链路的每个关键节点都打上日志,并且带上一个统一的 traceId。这样出了问题的时候,可以通过 traceId 把整个链路的日志串起来看,非常方便。
4.6 一些踩过的坑和实操心得
第一个坑是金额单位不统一。有的渠道要求传元,有的要求传分,如果不注意,就会导致金额差 100 倍。我的做法是在系统内部统一用分,只在调用渠道的时候做转换,并且转换的地方要写清楚注释。
第二个坑是回调地址带参数。有的渠道回调的时候会把原来的参数带回来,有的不会。如果你的回调地址里带了订单号之类的参数,就要确认渠道是否支持。不支持的话,就只能从回调参数里解析。
第三个坑是测试环境没有真实回调。很多渠道的测试环境不会真的发回调,这时候就需要自己模拟回调来测试。我的做法是写一个模拟回调的接口,手动触发,验证回调处理逻辑是否正确。
第四个坑是对账文件的时间差。渠道生成对账文件的时间和系统的时间可能有差异,导致对账的时候匹配不上。解决方法是把对账的时间范围放宽一点,比如对账前一天的数据,但实际比对的时候把前两天的数据也带上。
第五个坑是渠道密钥的轮换。渠道有时候会要求更换密钥,如果系统里硬编码了密钥,更换的时候就要改代码重新发布。正确的做法是把密钥放在配置中心或者数据库里,支持动态更新。
5. 项目扩展与进阶方向
5.1 多渠道路由策略
闪聚支付目前可能只接了一两个渠道,但实际生产环境中,通常需要接多个渠道。多渠道路由的核心是:根据一定的策略,决定一笔支付走哪个渠道。策略可以很简单,比如按金额分流、按渠道费率分流、按渠道可用性分流。
按金额分流是最常见的。比如小额走渠道 A,大额走渠道 B,因为渠道 A 对小额的手续费更低,渠道 B 对大额的成功率更高。按费率分流就是选费率最低的渠道。按可用性分流就是选当前成功率最高的渠道。
实现路由策略的时候,建议把策略抽象成一个接口,不同的策略实现这个接口。这样新增策略的时候只需要加一个实现类,不用改现有的代码。
5.2 支付链路的异步化
目前的支付链路可能是同步的:用户发起支付,系统调用渠道,等待渠道返回,然后返回给用户。这种模式在渠道响应慢的时候,用户体验很差。异步化的思路是:用户发起支付后,系统立即返回一个“支付处理中”的状态,然后通过消息队列异步调用渠道,渠道返回后再通过 WebSocket 或者轮询通知用户。
异步化的好处是用户体验好,系统吞吐量高。但缺点是复杂度增加,需要引入消息队列,需要处理消息丢失和重复消费的问题。我的建议是,如果业务量不大,同步模式就够了;如果业务量很大,再考虑异步化。
5.3 对账的自动化与告警
目前的对账可能是每天跑一次,人工查看结果。进阶的方向是自动化:对账完成后,自动生成报告,如果有异常,自动发送告警。告警的方式可以是邮件、短信或者即时通讯工具。
自动化的关键是对账结果的标准化。每次对账完成后,生成一个结构化的结果,包含:对账日期、渠道、总笔数、成功笔数、异常笔数、异常明细。然后根据这个结果判断是否需要告警。比如异常笔数超过 10 笔就告警,或者异常金额超过 1000 元就告警。
5.4 从支付到清结算的延伸
支付只是资金流转的第一步,后面还有清结算。清算是算清楚每个商户应该收多少钱、扣多少手续费;结算是把钱实际打给商户。这部分内容比支付更复杂,涉及到计费规则、结算周期、发票管理等。
如果你想继续深入,可以从简单的清结算开始:每天统计每个商户的交易总额和手续费,生成结算单,然后手动打款。等这套流程跑通了,再考虑自动打款和更复杂的计费规则。
5.5 一些学习建议
最后说几点学习建议。第一,不要只看代码,要动手跑。支付系统里的很多问题,只有实际跑起来才会遇到。第二,不要只关注正常流程,要多想想异常情况。支付系统的价值恰恰体现在异常处理上。第三,不要闭门造车,多看看成熟的开源支付项目,看看别人是怎么设计的。第四,做好笔记,把遇到的问题和解决方案记下来,这些都是宝贵的经验。
我个人在实际操作中的体会是,支付系统最难的不是写代码,而是想清楚各种边界情况。你把边界情况想清楚了,代码自然就写对了。想不清楚边界情况,代码写得再漂亮也是白搭。所以每次写支付相关的逻辑,我都会先花时间把可能的情况列出来,然后再动手写。这个习惯让我少踩了很多坑。