"写支付系统要敬畏:这里跑的不是请求,是现金流。" —— 某不愿透露姓名的资深架构师
前言:为什么支付系统让程序员头秃?
如果说写 CRUD 是程序员的日常散步,那么写支付系统就是在刀刃上跳芭蕾 —— 优雅、精准,而且不能出一点差错。
一个企业级支付系统需要解决的问题远比你想象的多:
- 钱不能多,也不能少(废话,但你知道有多少系统做不到吗?)
- 高并发下不能"超卖"你的余额
- 网络抖一下不能让用户付两次钱
- 第三方支付渠道说挂就挂,你得兜得住
- 审计要看每一笔流水,而且要看得明明白白
本文将从架构设计的角度,手把手带你拆解一个生产级支付系统的核心设计思路。全程代码实战 + 架构图,让你看完之后不仅能吹牛,还能真干活。
一、支付系统整体架构全景图
别急着写代码,先看看一个支付系统的"全身照":
整个系统的核心就是那个红色的支付核心服务 —— 它是整个系统的"心脏",所有的钱都要从它手里过。保护好它,就像保护你的钱包一样重要。
二、核心领域模型设计
支付系统的领域模型就像是一栋大楼的地基,打不好后面全白搭。我们用 DDD(领域驱动设计) 的思路来拆分:
三、数据库设计:钱的账本不能马虎
3.1 核心表结构
支付系统的数据库设计,重点是数据一致性和可追溯性。每一笔钱的来龙去脉都要记得清清楚楚。
-- 支付订单表:记录每一笔支付的"前世今生"
CREATE TABLE t_pay_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
pay_order_id VARCHAR(32) NOT NULL COMMENT '支付单号(全局唯一)',
biz_order_id VARCHAR(32) NOT NULL COMMENT '业务订单号',
user_id BIGINT NOT NULL COMMENT '用户ID',
amount DECIMAL(12,2) NOT NULL COMMENT '支付金额',
currency VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '币种',
channel VARCHAR(16) NOT NULL COMMENT '支付渠道',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-初始 1-支付中 2-成功 3-失败 4-已关闭',
channel_order_id VARCHAR(64) COMMENT '渠道方订单号',
expire_time DATETIME NOT NULL COMMENT '过期时间',
pay_time DATETIME COMMENT '支付成功时间',
notify_url VARCHAR(256) COMMENT '异步回调地址',
extra JSON COMMENT '扩展信息',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_pay_order_id (pay_order_id),
UNIQUE KEY uk_biz_order_id (biz_order_id),
INDEX idx_user_id (user_id),
INDEX idx_status_create (status, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付订单表';
-- 支付流水表:每一次状态变更都留下痕迹,审计的最爱
CREATE TABLE t_pay_transaction (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
trans_id VARCHAR(32) NOT NULL COMMENT '流水号',
pay_order_id VARCHAR(32) NOT NULL COMMENT '支付单号',
trans_type TINYINT NOT NULL COMMENT '1-支付 2-退款 3-冻结 4-解冻',
amount DECIMAL(12,2) NOT NULL COMMENT '交易金额',
channel_trans_id VARCHAR(64) COMMENT '渠道流水号',
before_status TINYINT COMMENT '变更前状态',
after_status TINYINT NOT NULL COMMENT '变更后状态',
remark VARCHAR(256) COMMENT '备注',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_trans_id (trans_id),
INDEX idx_pay_order_id (pay_order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='支付流水表';
-- 账户表:用户的钱袋子
CREATE TABLE t_account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
account_id VARCHAR(32) NOT NULL COMMENT '账户编号',
user_id BIGINT NOT NULL COMMENT '用户ID',
balance DECIMAL(14,2) NOT NULL DEFAULT 0.00 COMMENT '可用余额',
frozen_amount DECIMAL(14,2) NOT NULL DEFAULT 0.00 COMMENT '冻结金额',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁(比你的银行卡密码还重要)',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1-正常 2-冻结 3-注销',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_account_id (account_id),
UNIQUE KEY uk_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户账户表';
-- 对账记录表
CREATE TABLE t_reconciliation (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
recon_date DATE NOT NULL COMMENT '对账日期',
channel VARCHAR(16) NOT NULL COMMENT '支付渠道',
total_count INT NOT NULL COMMENT '总笔数',
total_amount DECIMAL(14,2) NOT NULL COMMENT '总金额',
diff_count INT NOT NULL DEFAULT 0 COMMENT '差异笔数',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待对账 1-对账中 2-已完成 3-有差异',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_date_channel (recon_date, channel)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对账记录表';
Tips: 金额字段用
DECIMAL,千万别用FLOAT或DOUBLE!不信你试试0.1 + 0.2等不等于0.3。浮点数在支付系统里就是定时炸弹。
四、核心代码实现
4.1 统一支付入口 —— 策略模式搞定多渠道
支付渠道那么多,硬写 if-else 会让你的代码像意大利面一样缠在一起。用策略模式优雅地搞定它:
策略接口定义
/**
* 支付渠道策略接口 —— 所有支付渠道的"入职协议"
*/
public interface PayChannelStrategy {
/** 获取渠道标识 */
PayChannelEnum getChannel();
/** 发起支付,返回渠道方的支付凭证 */
ChannelPayResult doPay(PayOrder payOrder);
/** 查询渠道方订单状态 */
ChannelQueryResult queryOrder(String channelOrderId);
/** 发起退款 */
ChannelRefundResult doRefund(RefundOrder refundOrder);
}
支付宝策略实现
@Component
@Slf4j
public class AlipayStrategy implements PayChannelStrategy {
@Resource
private AlipayClient alipayClient;
@Override
public PayChannelEnum getChannel() {
return PayChannelEnum.ALIPAY;
}
@Override
public ChannelPayResult doPay(PayOrder payOrder) {
AlipayTradePayRequest request = new AlipayTradePayRequest();
AlipayTradePayModel model = new AlipayTradePayModel();
model.setOutTradeNo(payOrder.getPayOrderId());
model.setTotalAmount(payOrder.getAmount().toPlainString());
model.setSubject("订单支付-" + payOrder.getBizOrderId());
model.setProductCode("FAST_INSTANT_TRADE_PAY");
request.setBizModel(model);
request.setNotifyUrl(payOrder.getNotifyUrl());
try {
AlipayTradePayResponse response = alipayClient.execute(request);
if (response.isSuccess()) {
return ChannelPayResult.success(response.getTradeNo(), response.getBody());
}
return ChannelPayResult.fail(response.getSubCode(), response.getSubMsg());
} catch (AlipayApiException e) {
log.error("支付宝支付异常, payOrderId={}", payOrder.getPayOrderId(), e);
return ChannelPayResult.error("SYSTEM_ERROR", "支付宝接口调用异常");
}
}
@Override
public ChannelQueryResult queryOrder(String channelOrderId) {
// 查询逻辑...
return null;
}
@Override
public ChannelRefundResult doRefund(RefundOrder refundOrder) {
// 退款逻辑...
return null;
}
}
策略工厂:自动注册,告别 if-else
@Component
public class PayChannelStrategyFactory {
private final Map<PayChannelEnum, PayChannelStrategy> strategyMap;
/**
* Spring 会自动把所有 PayChannelStrategy 的实现类注入进来
* 再也不用手写 if-else 了,加新渠道只需要加一个实现类,开闭原则 YYDS
*/
public PayChannelStrategyFactory(List<PayChannelStrategy> strategies) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(PayChannelStrategy::getChannel, Function.identity()));
}
public PayChannelStrategy getStrategy(PayChannelEnum channel) {
PayChannelStrategy strategy = strategyMap.get(channel);
if (strategy == null) {
throw new PayException(PayErrorCode.CHANNEL_NOT_SUPPORTED,
"不支持的支付渠道: " + channel);
}
return strategy;
}
}
4.2 支付核心流程 —— 状态机驱动
支付订单的状态流转是整个系统的"主线剧情",用状态机来管理,可以避免出现"薛定谔的订单状态"。
支付核心服务实现
@Service
@Slf4j
public class PayServiceImpl implements PayService {
@Resource
private PayOrderMapper payOrderMapper;
@Resource
private PayTransactionMapper transactionMapper;
@Resource
private PayChannelStrategyFactory strategyFactory;
@Resource
private AccountService accountService;
@Resource
private RiskControlService riskControlService;
@Resource
private IdGeneratorService idGenerator;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private RedissonClient redissonClient;
@Override
@Transactional(rollbackFor = Exception.class)
public PayResult createAndPay(PayRequest request) {
// 1. 幂等校验 —— 同一笔业务订单不能支付两次
PayOrder existOrder = payOrderMapper.selectByBizOrderId(request.getBizOrderId());
if (existOrder != null) {
if (existOrder.getStatus() == PayStatus.SUCCESS.getCode()) {
return PayResult.duplicate("该订单已支付成功,别重复付款啦!");
}
if (existOrder.getStatus() == PayStatus.PAYING.getCode()) {
return PayResult.duplicate("订单支付中,请稍候...");
}
}
// 2. 风控校验 —— 先过风控这一关
RiskCheckResult riskResult = riskControlService.check(request);
if (!riskResult.isPassed()) {
log.warn("风控拦截: userId={}, reason={}", request.getUserId(), riskResult.getReason());
return PayResult.riskBlocked(riskResult.getReason());
}
// 3. 创建支付单
PayOrder payOrder = buildPayOrder(request);
payOrderMapper.insert(payOrder);
recordTransaction(payOrder, null, PayStatus.INIT, "创建支付单");
// 4. 调用渠道发起支付
PayChannelStrategy strategy = strategyFactory.getStrategy(request.getChannel());
payOrder.setStatus(PayStatus.PAYING.getCode());
payOrderMapper.updateStatus(payOrder);
ChannelPayResult channelResult = strategy.doPay(payOrder);
// 5. 根据渠道返回结果处理
if (channelResult.isSuccess()) {
return PayResult.success(payOrder.getPayOrderId(), channelResult.getCredential());
} else if (channelResult.isNeedRetry()) {
return PayResult.paying(payOrder.getPayOrderId(), "支付处理中,请等待回调");
} else {
handlePayFailed(payOrder, channelResult.getErrorMsg());
return PayResult.fail(channelResult.getErrorMsg());
}
}
private PayOrder buildPayOrder(PayRequest request) {
PayOrder order = new PayOrder();
order.setPayOrderId(idGenerator.generatePayOrderId());
order.setBizOrderId(request.getBizOrderId());
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
order.setCurrency(request.getCurrency());
order.setChannel(request.getChannel().name());
order.setStatus(PayStatus.INIT.getCode());
order.setExpireTime(LocalDateTime.now().plusMinutes(30));
order.setNotifyUrl(request.getNotifyUrl());
return order;
}
private void recordTransaction(PayOrder order, PayStatus before, PayStatus after, String remark) {
PayTransaction transaction = new PayTransaction();
transaction.setTransId(idGenerator.generateTransId());
transaction.setPayOrderId(order.getPayOrderId());
transaction.setTransType(TransType.PAY.getCode());
transaction.setAmount(order.getAmount());
transaction.setBeforeStatus(before != null ? before.getCode() : null);
transaction.setAfterStatus(after.getCode());
transaction.setRemark(remark);
transactionMapper.insert(transaction);
}
}
4.3 支付回调处理 —— 可能是最容易翻车的地方
支付渠道的异步回调通知,是整个支付系统里最容易出 Bug 的地方。原因很简单:网络不可靠、回调可能重复、顺序可能乱。
回调处理代码
@RestController
@RequestMapping("/pay/callback")
@Slf4j
public class PayCallbackController {
@Resource
private PayCallbackService callbackService;
@PostMapping("/{channel}")
public String handleCallback(@PathVariable String channel, HttpServletRequest request) {
try {
PayChannelEnum channelEnum = PayChannelEnum.valueOf(channel.toUpperCase());
String result = callbackService.handleCallback(channelEnum, request);
return result;
} catch (Exception e) {
log.error("处理支付回调异常, channel={}", channel, e);
return "FAIL";
}
}
}
@Service
@Slf4j
public class PayCallbackServiceImpl implements PayCallbackService {
@Resource
private PayOrderMapper payOrderMapper;
@Resource
private PayTransactionMapper transactionMapper;
@Resource
private PayChannelStrategyFactory strategyFactory;
@Resource
private RocketMQTemplate rocketMQTemplate;
@Resource
private RedissonClient redissonClient;
@Override
public String handleCallback(PayChannelEnum channel, HttpServletRequest request) {
// 1. 解析并验签 —— 不验签等于裸奔
PayChannelStrategy strategy = strategyFactory.getStrategy(channel);
CallbackData callbackData = strategy.parseAndVerify(request);
if (callbackData == null) {
log.warn("回调验签失败, channel={}", channel);
return "FAIL";
}
String payOrderId = callbackData.getOutTradeNo();
String lockKey = "pay:callback:lock:" + payOrderId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 2. 分布式锁 —— 防止并发回调把数据搞乱
if (!lock.tryLock(5, 10, TimeUnit.SECONDS)) {
log.warn("获取回调锁失败, payOrderId={}", payOrderId);
return "FAIL";
}
PayOrder payOrder = payOrderMapper.selectByPayOrderId(payOrderId);
if (payOrder == null) {
log.error("支付单不存在, payOrderId={}", payOrderId);
return "FAIL";
}
// 3. 幂等校验 —— 已经是终态的直接返回成功
if (PayStatus.isTerminal(payOrder.getStatus())) {
log.info("支付单已处理, payOrderId={}, status={}", payOrderId, payOrder.getStatus());
return "SUCCESS";
}
// 4. 状态合法性校验
if (payOrder.getStatus() != PayStatus.PAYING.getCode()) {
log.warn("支付单状态异常, payOrderId={}, currentStatus={}",
payOrderId, payOrder.getStatus());
return "FAIL";
}
// 5. 处理支付结果
if (callbackData.isPaySuccess()) {
handlePaySuccess(payOrder, callbackData);
} else {
handlePayFailed(payOrder, callbackData.getErrorMsg());
}
return "SUCCESS";
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "FAIL";
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
@Transactional(rollbackFor = Exception.class)
public void handlePaySuccess(PayOrder payOrder, CallbackData callbackData) {
// CAS 更新状态,乐观锁防并发
int rows = payOrderMapper.casUpdateStatus(
payOrder.getPayOrderId(),
PayStatus.PAYING.getCode(),
PayStatus.SUCCESS.getCode(),
callbackData.getChannelOrderId(),
LocalDateTime.now(),
payOrder.getVersion()
);
if (rows == 0) {
throw new PayException("CAS 更新失败,可能存在并发冲突");
}
// 记录流水
recordTransaction(payOrder, PayStatus.PAYING, PayStatus.SUCCESS,
"支付成功, 渠道单号: " + callbackData.getChannelOrderId());
// 发送支付成功消息 —— 用事务消息保证可靠投递
PaySuccessMessage message = new PaySuccessMessage();
message.setPayOrderId(payOrder.getPayOrderId());
message.setBizOrderId(payOrder.getBizOrderId());
message.setAmount(payOrder.getAmount());
message.setPayTime(LocalDateTime.now());
rocketMQTemplate.sendMessageInTransaction(
"pay-success-topic",
MessageBuilder.withPayload(message).build(),
payOrder
);
log.info("支付成功处理完毕, payOrderId={}, amount={}",
payOrder.getPayOrderId(), payOrder.getAmount());
}
}
重点笔记: 回调处理的三板斧 —— 验签、加锁、幂等。缺一个你都会后悔的,信我。
五、分布式事务:支付系统的"生死劫"
支付涉及多个服务(支付、订单、账户),如何保证数据一致性?这是分布式系统的经典难题。
5.1 方案对比
5.2 TCC 模式实战 —— 账户扣款
对于资金操作,我们采用 TCC(Try-Confirm-Cancel),因为钱的事情不能"最终一致"就完了,要尽快一致。
TCC 账户服务实现
@Service
@Slf4j
public class AccountTccServiceImpl implements AccountTccService {
@Resource
private AccountMapper accountMapper;
@Resource
private TccTransactionMapper tccTransactionMapper;
/**
* Try 阶段:冻结资金
* 做业务检查,预留资源(冻结余额)
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean tryFreeze(String xid, Long userId, BigDecimal amount) {
log.info("[TCC-Try] 冻结资金, xid={}, userId={}, amount={}", xid, userId, amount);
// 防悬挂:如果 Cancel 已经执行过,拒绝 Try
if (tccTransactionMapper.existsByXidAndStatus(xid, TccStatus.CANCELLED)) {
log.warn("[TCC-Try] 检测到悬挂,拒绝执行, xid={}", xid);
return false;
}
// 冻结资金(乐观锁 + 余额校验)
int rows = accountMapper.freezeAmount(userId, amount);
if (rows == 0) {
log.warn("[TCC-Try] 余额不足或账户异常, userId={}, amount={}", userId, amount);
return false;
}
// 记录 TCC 事务日志
TccTransaction tccLog = new TccTransaction();
tccLog.setXid(xid);
tccLog.setUserId(userId);
tccLog.setAmount(amount);
tccLog.setStatus(TccStatus.TRYING);
tccTransactionMapper.insert(tccLog);
return true;
}
/**
* Confirm 阶段:确认扣款
* 幂等性保证:检查状态,只处理 TRYING 状态的记录
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean confirm(String xid) {
log.info("[TCC-Confirm] 确认扣款, xid={}", xid);
TccTransaction tccLog = tccTransactionMapper.selectByXid(xid);
if (tccLog == null) {
log.warn("[TCC-Confirm] 事务记录不存在, xid={}", xid);
return true;
}
if (tccLog.getStatus() == TccStatus.CONFIRMED) {
log.info("[TCC-Confirm] 已确认过(幂等), xid={}", xid);
return true;
}
// 扣除冻结金额
accountMapper.deductFrozenAmount(tccLog.getUserId(), tccLog.getAmount());
// 更新事务状态
tccTransactionMapper.updateStatus(xid, TccStatus.CONFIRMED);
return true;
}
/**
* Cancel 阶段:释放冻结资金
* 空回滚处理:如果 Try 没执行过,直接记录并返回
*/
@Override
@Transactional(rollbackFor = Exception.class)
public boolean cancel(String xid) {
log.info("[TCC-Cancel] 取消冻结, xid={}", xid);
TccTransaction tccLog = tccTransactionMapper.selectByXid(xid);
// 空回滚:Try 没执行过,直接插入一条 CANCELLED 记录
if (tccLog == null) {
log.info("[TCC-Cancel] 空回滚, xid={}", xid);
TccTransaction emptyCancel = new TccTransaction();
emptyCancel.setXid(xid);
emptyCancel.setStatus(TccStatus.CANCELLED);
tccTransactionMapper.insert(emptyCancel);
return true;
}
if (tccLog.getStatus() == TccStatus.CANCELLED) {
log.info("[TCC-Cancel] 已取消过(幂等), xid={}", xid);
return true;
}
// 释放冻结金额
accountMapper.unfreezeAmount(tccLog.getUserId(), tccLog.getAmount());
tccTransactionMapper.updateStatus(xid, TccStatus.CANCELLED);
return true;
}
}
TCC 三大坑: ① 空回滚(Try 没来,Cancel 先到了)② 悬挂(Cancel 执行完,Try 又来了)③ 幂等(同一个操作被调了好几次)。上面的代码把这三个坑都填了。
六、幂等性设计:让"重复"变得无害
支付系统里,幂等性就像安全带 —— 平时觉得碍事,出事了才知道救命。
6.1 幂等方案架构
6.2 基于 Redis + AOP 的幂等注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {
/** 幂等 key 的 SpEL 表达式 */
String key();
/** 幂等有效期(秒) */
int expireSeconds() default 300;
/** 重复请求的提示信息 */
String message() default "请勿重复提交";
}
@Aspect
@Component
@Slf4j
public class IdempotentAspect {
@Resource
private StringRedisTemplate redisTemplate;
private final SpelExpressionParser parser = new SpelExpressionParser();
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
String idempotentKey = parseKey(joinPoint, idempotent.key());
String redisKey = "idempotent:" + idempotentKey;
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "1", idempotent.expireSeconds(), TimeUnit.SECONDS);
if (Boolean.FALSE.equals(success)) {
log.warn("幂等拦截, key={}", redisKey);
throw new PayException(PayErrorCode.DUPLICATE_REQUEST, idempotent.message());
}
try {
return joinPoint.proceed();
} catch (Exception e) {
// 业务异常时释放幂等锁,允许重试
redisTemplate.delete(redisKey);
throw e;
}
}
private String parseKey(ProceedingJoinPoint joinPoint, String keyExpression) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
EvaluationContext context = new MethodBasedEvaluationContext(
null, signature.getMethod(), joinPoint.getArgs(),
new DefaultParameterNameDiscoverer()
);
return parser.parseExpression(keyExpression).getValue(context, String.class);
}
}
// 使用方式 —— 一个注解搞定幂等
@Service
public class PayServiceImpl implements PayService {
@Override
@Idempotent(key = "#request.bizOrderId", expireSeconds = 600, message = "订单正在处理中,请勿重复提交")
@Transactional(rollbackFor = Exception.class)
public PayResult createAndPay(PayRequest request) {
// 业务逻辑...
}
}
七、风控系统:支付的"保安大哥"
没有风控的支付系统,就像没有门的银行,谁都能进来随便拿。
7.1 风控引擎架构
风控规则引擎实现
@Service
@Slf4j
public class RiskControlServiceImpl implements RiskControlService {
@Resource
private StringRedisTemplate redisTemplate;
@Resource
private BlacklistService blacklistService;
private static final int MAX_FREQ_PER_MINUTE = 5;
private static final BigDecimal MAX_SINGLE_AMOUNT = new BigDecimal("50000");
private static final BigDecimal DAILY_LIMIT = new BigDecimal("200000");
@Override
public RiskCheckResult check(PayRequest request) {
List<RiskRule> rules = buildRules(request);
for (RiskRule rule : rules) {
RiskCheckResult result = rule.evaluate();
if (!result.isPassed()) {
log.warn("风控规则命中: rule={}, userId={}, reason={}",
rule.getName(), request.getUserId(), result.getReason());
return result;
}
}
return RiskCheckResult.pass();
}
private List<RiskRule> buildRules(PayRequest request) {
return List.of(
// 黑名单检查
new RiskRule("黑名单校验", () -> {
if (blacklistService.isBlocked(request.getUserId())) {
return RiskCheckResult.reject("用户在黑名单中");
}
return RiskCheckResult.pass();
}),
// 频次限制
new RiskRule("交易频次", () -> {
String key = "risk:freq:" + request.getUserId();
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
if (count > MAX_FREQ_PER_MINUTE) {
return RiskCheckResult.reject("交易太频繁,请稍后再试(1分钟最多" + MAX_FREQ_PER_MINUTE + "笔)");
}
return RiskCheckResult.pass();
}),
// 单笔限额
new RiskRule("单笔限额", () -> {
if (request.getAmount().compareTo(MAX_SINGLE_AMOUNT) > 0) {
return RiskCheckResult.reject("单笔金额超限(最大" + MAX_SINGLE_AMOUNT + "元)");
}
return RiskCheckResult.pass();
}),
// 日累计限额
new RiskRule("日累计限额", () -> {
BigDecimal dailyTotal = getDailyTotal(request.getUserId());
if (dailyTotal.add(request.getAmount()).compareTo(DAILY_LIMIT) > 0) {
return RiskCheckResult.reject("日累计交易额超限");
}
return RiskCheckResult.pass();
})
);
}
private BigDecimal getDailyTotal(Long userId) {
String key = "risk:daily:" + userId + ":" + LocalDate.now();
String total = redisTemplate.opsForValue().get(key);
return total != null ? new BigDecimal(total) : BigDecimal.ZERO;
}
}
八、对账系统:每天的"数钱仪式"
对账就像每天晚上数钱 —— 确认口袋里的钱和账本上记的一样多。
对账核心代码
@Component
@Slf4j
public class ReconciliationJob {
@Resource
private PayOrderMapper payOrderMapper;
@Resource
private ReconciliationMapper reconMapper;
@Resource
private ChannelBillService channelBillService;
@Resource
private AlertService alertService;
/**
* 每天凌晨 2 点跑对账 —— 这个点程序员和服务器都很安静
*/
@Scheduled(cron = "0 0 2 * * ?")
public void dailyReconciliation() {
LocalDate reconDate = LocalDate.now().minusDays(1);
log.info("开始对账, date={}", reconDate);
for (PayChannelEnum channel : PayChannelEnum.values()) {
try {
reconcileChannel(reconDate, channel);
} catch (Exception e) {
log.error("对账异常, date={}, channel={}", reconDate, channel, e);
alertService.sendAlert("对账异常",
String.format("日期: %s, 渠道: %s, 异常: %s", reconDate, channel, e.getMessage()));
}
}
}
private void reconcileChannel(LocalDate reconDate, PayChannelEnum channel) {
// 1. 下载并解析渠道对账单
List<ChannelBillItem> channelBills = channelBillService.downloadAndParse(reconDate, channel);
Map<String, ChannelBillItem> channelBillMap = channelBills.stream()
.collect(Collectors.toMap(ChannelBillItem::getOutTradeNo, Function.identity()));
// 2. 查询本地支付成功的记录
List<PayOrder> localOrders = payOrderMapper.selectSuccessOrders(reconDate, channel.name());
int matchCount = 0;
int diffCount = 0;
List<ReconDiffRecord> diffs = new ArrayList<>();
// 3. 逐笔核对
for (PayOrder localOrder : localOrders) {
ChannelBillItem channelItem = channelBillMap.remove(localOrder.getPayOrderId());
if (channelItem == null) {
// 本地有、渠道没有 —— 长款
diffs.add(ReconDiffRecord.longPayment(localOrder));
diffCount++;
} else if (localOrder.getAmount().compareTo(channelItem.getAmount()) != 0) {
// 金额不一致
diffs.add(ReconDiffRecord.amountMismatch(localOrder, channelItem));
diffCount++;
} else {
matchCount++;
}
}
// 4. 渠道有、本地没有 —— 短款(最危险!)
for (ChannelBillItem remainItem : channelBillMap.values()) {
diffs.add(ReconDiffRecord.shortPayment(remainItem));
diffCount++;
}
// 5. 保存对账结果
saveReconciliationResult(reconDate, channel, matchCount, diffCount, diffs);
if (diffCount > 0) {
alertService.sendAlert("对账差异告警",
String.format("日期:%s 渠道:%s 差异:%d笔 请尽快处理!", reconDate, channel, diffCount));
}
}
}
九、高可用设计:让系统像蟑螂一样"打不死"
9.1 高可用架构全景
9.2 熔断降级 + 重试实现
@Service
@Slf4j
public class ResilientPayService {
@Resource
private PayChannelStrategyFactory strategyFactory;
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final RetryRegistry retryRegistry;
public ResilientPayService() {
// 熔断器配置:连续 5 次失败就熔断,60秒后尝试恢复
CircuitBreakerConfig cbConfig = CircuitBreakerConfig.custom()
.failureRateThreshold(50)
.waitDurationInOpenState(Duration.ofSeconds(60))
.slidingWindowSize(10)
.minimumNumberOfCalls(5)
.build();
this.circuitBreakerRegistry = CircuitBreakerRegistry.of(cbConfig);
// 重试配置:最多重试 3 次,间隔指数退避
RetryConfig retryConfig = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.intervalFunction(IntervalFunction.ofExponentialBackoff())
.retryOnException(e -> e instanceof TimeoutException || e instanceof IOException)
.retryOnException(e -> !(e instanceof PayBizException))
.build();
this.retryRegistry = RetryRegistry.of(retryConfig);
}
public ChannelPayResult payWithResilience(PayOrder payOrder, PayChannelEnum channel) {
String channelName = channel.name();
CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(channelName);
Retry retry = retryRegistry.retry(channelName);
Supplier<ChannelPayResult> supplier = () -> {
PayChannelStrategy strategy = strategyFactory.getStrategy(channel);
return strategy.doPay(payOrder);
};
// 组合:重试 + 熔断 + 降级
Supplier<ChannelPayResult> decoratedSupplier = Decorators.ofSupplier(supplier)
.withRetry(retry)
.withCircuitBreaker(circuitBreaker)
.withFallback(List.of(
CallNotPermittedException.class,
TimeoutException.class
), e -> {
log.error("支付渠道 {} 不可用,触发降级, payOrderId={}",
channelName, payOrder.getPayOrderId(), e);
return handleFallback(payOrder, channel, e);
})
.decorate();
return decoratedSupplier.get();
}
/**
* 降级策略:
* 1. 尝试切换备用渠道
* 2. 如果没有备用渠道,返回"稍后重试"
*/
private ChannelPayResult handleFallback(PayOrder payOrder, PayChannelEnum failedChannel, Throwable e) {
PayChannelEnum backupChannel = getBackupChannel(failedChannel);
if (backupChannel != null) {
log.info("切换备用渠道: {} -> {}", failedChannel, backupChannel);
PayChannelStrategy backupStrategy = strategyFactory.getStrategy(backupChannel);
return backupStrategy.doPay(payOrder);
}
return ChannelPayResult.error("CHANNEL_UNAVAILABLE", "支付通道繁忙,请稍后再试");
}
private PayChannelEnum getBackupChannel(PayChannelEnum primary) {
Map<PayChannelEnum, PayChannelEnum> backupMap = Map.of(
PayChannelEnum.ALIPAY, PayChannelEnum.WECHAT,
PayChannelEnum.WECHAT, PayChannelEnum.ALIPAY
);
return backupMap.get(primary);
}
}
十、分布式 ID 生成:给每笔交易一个独一无二的"身份证"
支付单号必须全局唯一、趋势递增(对数据库索引友好)、信息可读(一眼能看出时间和类型)。
@Component
public class PayIdGenerator {
private final long machineId;
private final AtomicLong sequence = new AtomicLong(0);
private volatile long lastTimestamp = -1;
public PayIdGenerator(@Value("${pay.machine-id:1}") long machineId) {
this.machineId = machineId;
}
/**
* 生成支付单号
* 格式:PAY + yyyyMMdd + HHmmss + 机器ID(3位) + 序列号(8位)
* 示例:PAY20260413153025001000000001
*/
public synchronized String generatePayOrderId() {
return generate("PAY");
}
public synchronized String generateRefundId() {
return generate("REF");
}
public synchronized String generateTransId() {
return generate("TXN");
}
private String generate(String prefix) {
long now = System.currentTimeMillis();
if (now == lastTimestamp) {
sequence.incrementAndGet();
} else {
sequence.set(0);
lastTimestamp = now;
}
LocalDateTime dateTime = LocalDateTime.ofInstant(
Instant.ofEpochMilli(now), ZoneId.systemDefault());
return String.format("%s%s%03d%08d",
prefix,
dateTime.format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")),
machineId,
sequence.get());
}
}
十一、安全设计:把"坏人"挡在门外
接口签名验签
@Component
@Slf4j
public class SignatureInterceptor implements HandlerInterceptor {
@Value("${pay.sign.secret}")
private String signSecret;
private static final long MAX_TIMESTAMP_DIFF = 300_000; // 5分钟
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
String timestamp = request.getHeader("X-Timestamp");
String nonce = request.getHeader("X-Nonce");
String signature = request.getHeader("X-Signature");
// 1. 参数完整性校验
if (StringUtils.isAnyBlank(timestamp, nonce, signature)) {
writeError(response, "签名参数不完整");
return false;
}
// 2. 时间戳防重放攻击
long requestTime = Long.parseLong(timestamp);
if (Math.abs(System.currentTimeMillis() - requestTime) > MAX_TIMESTAMP_DIFF) {
writeError(response, "请求已过期");
return false;
}
// 3. 验证签名
String body = getRequestBody(request);
String expectedSign = calculateSign(timestamp, nonce, body);
if (!MessageDigest.isEqual(signature.getBytes(), expectedSign.getBytes())) {
log.warn("签名验证失败, uri={}, expectedSign={}", request.getRequestURI(), expectedSign);
writeError(response, "签名验证失败");
return false;
}
return true;
}
private String calculateSign(String timestamp, String nonce, String body) {
String raw = timestamp + nonce + body + signSecret;
return DigestUtils.sha256Hex(raw);
}
// 敏感数据脱敏工具
public static String maskCardNumber(String cardNo) {
if (cardNo == null || cardNo.length() < 8) return "****";
return cardNo.substring(0, 4) + "****" + cardNo.substring(cardNo.length() - 4);
}
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) return "****";
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
}
十二、监控告警:系统的"健康体检"
一个没有监控的支付系统,就像闭着眼睛开车 —— 出了事都不知道怎么死的。
自定义业务指标埋点
@Component
@Slf4j
public class PayMetricsCollector {
private final MeterRegistry meterRegistry;
private final Counter paySuccessCounter;
private final Counter payFailCounter;
private final Timer payLatencyTimer;
private final AtomicReference<BigDecimal> dailyAmount = new AtomicReference<>(BigDecimal.ZERO);
public PayMetricsCollector(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.paySuccessCounter = Counter.builder("pay.transaction.success")
.description("支付成功笔数")
.register(meterRegistry);
this.payFailCounter = Counter.builder("pay.transaction.fail")
.description("支付失败笔数")
.register(meterRegistry);
this.payLatencyTimer = Timer.builder("pay.transaction.latency")
.description("支付耗时")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
Gauge.builder("pay.daily.amount", dailyAmount, ref -> ref.get().doubleValue())
.description("当日交易总额")
.register(meterRegistry);
}
public void recordPaySuccess(String channel, BigDecimal amount, long latencyMs) {
paySuccessCounter.increment();
payLatencyTimer.record(latencyMs, TimeUnit.MILLISECONDS);
dailyAmount.updateAndGet(current -> current.add(amount));
Counter.builder("pay.channel.success")
.tag("channel", channel)
.register(meterRegistry)
.increment();
}
public void recordPayFail(String channel, String errorCode) {
payFailCounter.increment();
Counter.builder("pay.channel.fail")
.tag("channel", channel)
.tag("error_code", errorCode)
.register(meterRegistry)
.increment();
}
/**
* 计算交易成功率(告警核心指标)
*/
public double getSuccessRate() {
double success = paySuccessCounter.count();
double fail = payFailCounter.count();
double total = success + fail;
return total == 0 ? 1.0 : success / total;
}
}
十三、性能优化:让支付飞起来
13.1 性能优化全景
13.2 热点账户优化
大商户(比如某奶茶品牌)的账户可能每秒被操作几百次,直接怼数据库必死无疑。
@Service
@Slf4j
public class HotAccountService {
@Resource
private StringRedisTemplate redisTemplate;
@Resource
private AccountMapper accountMapper;
private static final String HOT_ACCOUNT_KEY = "hot:account:";
private static final int BATCH_FLUSH_THRESHOLD = 100;
private static final int FLUSH_INTERVAL_SECONDS = 5;
/**
* 热点账户入账:先记 Redis,定时批量刷入数据库
* 把 N 次数据库写操作合并成 1 次,性能直接起飞
*/
public void creditHotAccount(Long userId, BigDecimal amount, String transId) {
String key = HOT_ACCOUNT_KEY + userId;
String member = transId + ":" + amount.toPlainString();
// 用 Redis Sorted Set 缓存待入账记录,score 为时间戳
redisTemplate.opsForZSet().add(key, member, System.currentTimeMillis());
Long pendingCount = redisTemplate.opsForZSet().size(key);
if (pendingCount != null && pendingCount >= BATCH_FLUSH_THRESHOLD) {
flushToDatabase(userId);
}
}
/**
* 定时刷库任务:每5秒把 Redis 里攒的入账批量写入数据库
*/
@Scheduled(fixedDelay = 5000)
public void scheduledFlush() {
Set<String> keys = redisTemplate.keys(HOT_ACCOUNT_KEY + "*");
if (keys == null) return;
for (String key : keys) {
String userIdStr = key.replace(HOT_ACCOUNT_KEY, "");
flushToDatabase(Long.parseLong(userIdStr));
}
}
@Transactional(rollbackFor = Exception.class)
public void flushToDatabase(Long userId) {
String key = HOT_ACCOUNT_KEY + userId;
Set<String> members = redisTemplate.opsForZSet().range(key, 0, -1);
if (members == null || members.isEmpty()) return;
BigDecimal totalAmount = members.stream()
.map(m -> new BigDecimal(m.split(":")[1]))
.reduce(BigDecimal.ZERO, BigDecimal::add);
// 一次性更新数据库
accountMapper.addBalance(userId, totalAmount);
redisTemplate.opsForZSet().removeRange(key, 0, members.size() - 1);
log.info("热点账户批量入账完成, userId={}, count={}, totalAmount={}",
userId, members.size(), totalAmount);
}
}
十四、项目工程结构
一个好的工程结构,能让接手的人少骂几句(虽然还是会骂)。
pay-system/
├── pay-api/ # 对外 API 接口定义(给其他服务调用的 Feign 接口)
│ ├── src/main/java
│ │ └── com.example.pay.api
│ │ ├── PayApi.java
│ │ ├── dto/
│ │ │ ├── PayRequest.java
│ │ │ ├── PayResult.java
│ │ │ └── RefundRequest.java
│ │ └── enums/
│ │ ├── PayChannelEnum.java
│ │ └── PayStatusEnum.java
│ └── pom.xml
│
├── pay-service/ # 核心业务服务
│ ├── src/main/java
│ │ └── com.example.pay
│ │ ├── PayApplication.java
│ │ ├── controller/ # 接口层
│ │ │ ├── PayController.java
│ │ │ └── PayCallbackController.java
│ │ ├── service/ # 业务逻辑层
│ │ │ ├── PayService.java
│ │ │ ├── PayServiceImpl.java
│ │ │ ├── AccountService.java
│ │ │ └── RefundService.java
│ │ ├── channel/ # 支付渠道策略
│ │ │ ├── PayChannelStrategy.java
│ │ │ ├── PayChannelStrategyFactory.java
│ │ │ ├── AlipayStrategy.java
│ │ │ └── WechatPayStrategy.java
│ │ ├── tcc/ # TCC 分布式事务
│ │ │ ├── AccountTccService.java
│ │ │ └── AccountTccServiceImpl.java
│ │ ├── risk/ # 风控模块
│ │ │ ├── RiskControlService.java
│ │ │ └── RiskRule.java
│ │ ├── reconciliation/ # 对账模块
│ │ │ └── ReconciliationJob.java
│ │ ├── idempotent/ # 幂等组件
│ │ │ ├── Idempotent.java
│ │ │ └── IdempotentAspect.java
│ │ ├── security/ # 安全组件
│ │ │ └── SignatureInterceptor.java
│ │ ├── monitor/ # 监控组件
│ │ │ └── PayMetricsCollector.java
│ │ ├── mapper/ # 数据访问层
│ │ │ ├── PayOrderMapper.java
│ │ │ ├── AccountMapper.java
│ │ │ └── PayTransactionMapper.java
│ │ ├── domain/ # 领域模型
│ │ │ ├── PayOrder.java
│ │ │ ├── Account.java
│ │ │ └── PayTransaction.java
│ │ ├── config/ # 配置类
│ │ │ ├── AlipayConfig.java
│ │ │ ├── RedisConfig.java
│ │ │ └── RocketMQConfig.java
│ │ └── common/ # 公共组件
│ │ ├── PayException.java
│ │ ├── PayErrorCode.java
│ │ └── Result.java
│ ├── src/main/resources
│ │ ├── application.yml
│ │ ├── application-dev.yml
│ │ └── mapper/*.xml
│ └── pom.xml
│
├── pay-job/ # 定时任务服务(对账、关单等)
├── pay-gateway/ # 网关服务
├── docker-compose.yml
├── README.md
└── pom.xml
十五、部署架构
十六、总结:支付系统设计的"九字真言"
经过上面十几个章节的"折腾",让我们用一张图总结支付系统设计的核心要点:
架构设计 Checklist
| 维度 | 关键点 | 是否覆盖 |
|---|---|---|
| 正确性 | 幂等、分布式事务、对账 | ✅ |
| 可靠性 | 熔断降级、重试、消息可靠投递 | ✅ |
| 安全性 | 签名验签、加密存储、风控拦截 | ✅ |
| 可扩展 | 策略模式、SPI 扩展、分库分表 | ✅ |
| 可观测 | 指标监控、链路追踪、日志体系 | ✅ |
| 高性能 | 缓存、异步、热点优化、连接池 | ✅ |
写在最后
支付系统是技术含量最高、挑战最大的业务系统之一。它逼着你去思考分布式系统的每一个细节:一致性、可用性、幂等性、安全性。
写支付系统就像当医生 —— 不允许犯错,每一行代码都可能和钱挂钩。但也正因为如此,做好一个支付系统,会让你从"CRUD 工程师"蜕变为"真正的架构师"。
最后送大家一句话:
"先把钱算对,再想怎么算快。"
如果这篇文章对你有帮助,请点赞收藏转发三连,让更多人看到。
如果你在生产环境中踩过支付系统的坑,也欢迎在评论区分享,咱们一起"排雷" 💣。
本文所有代码仅为架构设计示例,生产环境请根据实际业务需求调整。涉及支付渠道的对接请参考各渠道官方文档。
技术栈速览: Spring Boot 3.x + Spring Cloud + MySQL + Redis + RocketMQ + Nacos + Sentinel + Prometheus + Grafana + SkyWalking