深度实战:多线程在支付场景中的实际应用与避坑指南
支付系统是对并发安全要求最高的业务场景之一:用户同时发起支付、多线程处理回调、批量对账并发拉取渠道数据......任何一处线程安全问题都可能造成资金损失或死锁。 本文结合生产实践,系统梳理多线程在支付场景中的 5 大核心应用,并给出对应的实现方案与踩坑经验。
一、支付场景对多线程的典型诉求
makefile
场景1: 用户并发下单 → 同一账户扣款,需要线程安全的余额操作
场景2: 第三方回调并发到达 → 同一订单被多线程同时处理
场景3: 批量对账 → 上万条流水需要并发拉取渠道数据
场景4: 支付超时检查 → 定时任务并发扫描待支付订单
场景5: 异步通知业务系统 → 支付成功后并发通知多个下游
这五个场景,每一个都有独特的并发挑战,不能用同一个方案解决。
二、账户扣款:乐观锁 + CAS 保障余额一致性
2.1 问题复现
java
// 危险写法:读-改-写不是原子操作
public boolean deduct(Long accountId, BigDecimal amount) {
Account account = accountRepository.findById(accountId);
if (account.getBalance().compareTo(amount) < 0) {
return false; // 余额不足
}
// 两线程都通过了余额检查,都执行到这里 → 超扣!
account.setBalance(account.getBalance().subtract(amount));
accountRepository.save(account);
return true;
}
并发场景下,两个线程同时读到余额 100 元,同时扣 80 元,结果账户变成 20 元(实际应该变成 -60 元并拒绝第二笔)。
2.2 数据库乐观锁方案
sql
-- 账户表加版本号字段
ALTER TABLE account ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
java
@Service
public class AccountService {
@Autowired
private AccountRepository accountRepository;
/**
* 扣款:乐观锁保障原子性
* 底层执行: UPDATE account SET balance=?, version=version+1
* WHERE id=? AND version=? AND balance>=?
*/
@Transactional
public boolean deductWithOptimisticLock(Long accountId, BigDecimal amount) {
int maxRetry = 3;
for (int i = 0; i < maxRetry; i++) {
Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException(accountId));
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientBalanceException("余额不足");
}
// 乐观锁更新:只有 version 匹配时才更新成功
int rows = accountRepository.deductBalance(
accountId,
amount,
account.getVersion() // 携带版本号
);
if (rows == 1) {
log.info("[扣款成功] accountId={}, amount={}", accountId, amount);
return true;
}
// 版本冲突:被其他线程抢先修改,重试
log.warn("[版本冲突] 第{}次重试, accountId={}", i + 1, accountId);
}
throw new ConcurrentModificationException("扣款并发冲突,请稍后重试");
}
}
java
// Repository 层
@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
@Modifying
@Query("""
UPDATE Account a SET a.balance = a.balance - :amount, a.version = a.version + 1
WHERE a.id = :accountId AND a.version = :version AND a.balance >= :amount
""")
int deductBalance(@Param("accountId") Long accountId,
@Param("amount") BigDecimal amount,
@Param("version") Long version);
}
2.3 Redis 原子扣款方案(高并发场景)
当 TPS 极高时(秒杀支付、大促),数据库乐观锁重试会造成大量 DB 压力,改用 Redis Lua 脚本实现原子扣款:
lua
-- deduct_balance.lua
-- KEYS[1] = account:{accountId}:balance
-- ARGV[1] = 扣款金额(分)
local balance = tonumber(redis.call('GET', KEYS[1]))
local amount = tonumber(ARGV[1])
if balance == nil then
return -2 -- 账户不存在
end
if balance < amount then
return -1 -- 余额不足
end
redis.call('DECRBY', KEYS[1], amount)
return balance - amount -- 返回扣款后余额
java
@Service
public class RedisAccountService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final DefaultRedisScript<Long> DEDUCT_SCRIPT;
static {
DEDUCT_SCRIPT = new DefaultRedisScript<>();
DEDUCT_SCRIPT.setScriptSource(new ClassPathResource("lua/deduct_balance.lua"));
DEDUCT_SCRIPT.setResultType(Long.class);
}
/**
* Lua 脚本原子扣款(单位:分)
*/
public long deduct(Long accountId, long amountCents) {
String key = "account:" + accountId + ":balance";
Long result = redisTemplate.execute(
DEDUCT_SCRIPT,
Collections.singletonList(key),
String.valueOf(amountCents)
);
if (result == null || result == -2) {
throw new AccountNotFoundException("账户不存在");
}
if (result == -1) {
throw new InsufficientBalanceException("余额不足");
}
// 异步同步到数据库(最终一致)
asyncSyncToDB(accountId, amountCents);
return result; // 返回扣款后余额(分)
}
}
三、回调幂等:ReentrantLock + 本地锁防并发处理
3.1 问题场景
第三方支付平台(如微信、支付宝)的回调通知可能在短时间内并发多次到达(网络重试机制)。如果处理逻辑包含账户入账,并发处理会导致重复入账。
less
时间线:
T1: 微信回调#1 到达 → 线程A 开始处理,查询订单状态=PENDING
T2: 微信回调#2 到达 → 线程B 开始处理,查询订单状态=PENDING(A还没更新)
T3: 线程A 完成入账,更新状态=PAID
T4: 线程B 完成入账,更新状态=PAID(重复入账!)
3.2 分布式锁方案(Redisson)
java
@Service
@Slf4j
public class PayCallbackService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private PayOrderRepository orderRepository;
@Autowired
private AccountService accountService;
/**
* 处理支付回调(防并发重复处理)
*/
public void handleCallback(String outTradeNo, CallbackResult callback) {
// 同一订单加分布式锁,防止并发处理
String lockKey = "pay:callback:lock:" + outTradeNo;
RLock lock = redissonClient.getLock(lockKey);
boolean acquired = false;
try {
// 尝试获取锁,最多等待3秒,锁自动释放时间30秒
acquired = lock.tryLock(3, 30, TimeUnit.SECONDS);
if (!acquired) {
log.warn("[回调] 获取锁超时,可能有并发处理, outTradeNo={}", outTradeNo);
return;
}
// 加锁后再次检查状态(双重检测)
PayOrder order = orderRepository.findByOutTradeNo(outTradeNo);
if (order.getStatus() == PayStatus.PAID) {
log.info("[回调] 订单已处理,幂等跳过, outTradeNo={}", outTradeNo);
return;
}
// 执行业务逻辑
processCallback(order, callback);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("[回调] 锁获取被中断, outTradeNo={}", outTradeNo, e);
} finally {
if (acquired && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private void processCallback(PayOrder order, CallbackResult callback) {
// 状态机更新:PENDING → PAID
orderRepository.updateStatusPaid(order.getId(), callback.getChannelTradeNo());
// 账户入账
accountService.credit(order.getAccountId(), order.getAmount());
log.info("[回调] 支付成功处理完成, payNo={}", order.getPayNo());
}
}
四、批量对账:线程池并发拉取,CountDownLatch 同步结果
4.1 场景描述
日终对账需要对接多个渠道(微信、支付宝、银联),每个渠道需要调用远程接口拉取对账文件,串行处理耗时太长(每个渠道约 5 秒,10 个渠道就是 50 秒)。
4.2 CompletableFuture 并发对账
java
@Service
public class ReconcileService {
// 对账专用线程池(IO 密集型,线程数多一些)
private final ExecutorService reconcileExecutor = new ThreadPoolExecutor(
10, // 核心线程数
20, // 最大线程数
60L, TimeUnit.SECONDS, // 空闲线程存活时间
new LinkedBlockingQueue<>(50), // 任务队列
new ThreadFactoryBuilder().setNameFormat("reconcile-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:调用者自己跑
);
@Autowired
private List<ChannelAdapter> channelAdapters;
/**
* 多渠道并发对账
*/
public ReconcileReport dailyReconcile(String date) {
log.info("[对账] 开始并发对账, date={}, 渠道数={}", date, channelAdapters.size());
long start = System.currentTimeMillis();
// 并发提交各渠道对账任务
List<CompletableFuture<ChannelReconcileResult>> futures = channelAdapters.stream()
.map(adapter -> CompletableFuture
.supplyAsync(() -> doChannelReconcile(adapter, date), reconcileExecutor)
.exceptionally(ex -> {
log.error("[对账] 渠道{}对账失败", adapter.getChannel(), ex);
return ChannelReconcileResult.failure(adapter.getChannel(), ex.getMessage());
})
)
.collect(Collectors.toList());
// 等待所有渠道完成
List<ChannelReconcileResult> results = futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
long elapsed = System.currentTimeMillis() - start;
log.info("[对账] 并发对账完成, 耗时={}ms", elapsed);
return buildReport(results);
}
private ChannelReconcileResult doChannelReconcile(ChannelAdapter adapter, String date) {
log.info("[对账] 渠道={}, 线程={}", adapter.getChannel(), Thread.currentThread().getName());
try {
// 1. 拉取渠道对账文件
List<ReconcileItem> channelItems = adapter.fetchReconcileData(date);
// 2. 查本地流水
List<PayOrder> localOrders = orderRepository.findByDateAndChannel(date, adapter.getChannel());
// 3. 核对差异
List<ReconcileDiff> diffs = compare(channelItems, localOrders);
return ChannelReconcileResult.success(adapter.getChannel(), channelItems.size(), diffs);
} catch (Exception e) {
return ChannelReconcileResult.failure(adapter.getChannel(), e.getMessage());
}
}
}
4.3 线程池配置要点
java
/**
* 支付系统线程池配置(不同场景隔离,防止互相影响)
*/
@Configuration
public class PayThreadPoolConfig {
/**
* 对账线程池(IO 密集型)
* 线程数 = CPU核心数 * (1 + 等待时间/计算时间),IO 密集设置 2N
*/
@Bean("reconcileExecutor")
public ExecutorService reconcileExecutor() {
int cores = Runtime.getRuntime().availableProcessors();
return new ThreadPoolExecutor(
cores * 2,
cores * 4,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(200),
new ThreadFactoryBuilder().setNameFormat("reconcile-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
/**
* 支付通知线程池(CPU 密集型)
*/
@Bean("notifyExecutor")
public ExecutorService notifyExecutor() {
int cores = Runtime.getRuntime().availableProcessors();
return new ThreadPoolExecutor(
cores + 1,
cores * 2,
30L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadFactoryBuilder().setNameFormat("pay-notify-%d").build(),
new ThreadPoolExecutor.AbortPolicy()
);
}
}
五、异步通知下游:线程池 + 重试机制
5.1 场景描述
支付成功后,可能需要通知多个下游系统(订单系统、会员积分系统、库存系统、营销系统)。如果串行通知,任何一个下游慢都会阻塞主线程。
5.2 并发通知 + 失败重试
java
@Service
public class PayNotifyService {
@Autowired
@Qualifier("notifyExecutor")
private ExecutorService notifyExecutor;
@Autowired
private List<PaySuccessHandler> handlers; // 所有下游处理器
/**
* 支付成功后并发通知所有下游
*/
public void notifyAll(PaySuccessEvent event) {
List<CompletableFuture<Void>> futures = handlers.stream()
.map(handler -> CompletableFuture
.runAsync(() -> notifyWithRetry(handler, event), notifyExecutor)
)
.collect(Collectors.toList());
// 不等待结果(全异步),主线程直接返回
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
.exceptionally(ex -> {
log.error("[支付通知] 部分下游通知失败", ex);
return null;
});
}
/**
* 单个下游通知(带重试)
*/
private void notifyWithRetry(PaySuccessHandler handler, PaySuccessEvent event) {
int maxRetry = 3;
long[] delays = {1000L, 3000L, 10000L}; // 退避间隔:1s, 3s, 10s
for (int i = 0; i < maxRetry; i++) {
try {
handler.onPaySuccess(event);
log.info("[支付通知] 成功, handler={}, payNo={}", handler.getClass().getSimpleName(), event.getPayNo());
return;
} catch (Exception e) {
log.warn("[支付通知] 第{}次失败, handler={}, payNo={}, error={}",
i + 1, handler.getClass().getSimpleName(), event.getPayNo(), e.getMessage());
if (i < maxRetry - 1) {
try {
Thread.sleep(delays[i]);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return;
}
}
}
}
// 三次重试全失败,发告警 + 写补偿表
alertService.sendAlert("支付通知失败,需人工处理: " + event.getPayNo());
compensateService.saveForManual(handler.getClass().getSimpleName(), event);
}
}
六、定时超时检查:分片并行扫描
6.1 问题场景
超时关单通常用定时任务扫描待支付订单,当数据量大时(每次扫描上万条),单线程扫描耗时过长,还可能出现任务重叠执行(上一次还没跑完,下一次就开始了)。
6.2 分片 + 并行扫描
java
@Component
@Slf4j
public class OrderTimeoutJob {
@Autowired
private PayOrderRepository orderRepository;
@Autowired
private OrderService orderService;
// 分片线程池
private final ExecutorService scanExecutor = Executors.newFixedThreadPool(4,
new ThreadFactoryBuilder().setNameFormat("timeout-scan-%d").build());
/**
* 每分钟执行一次,使用 @ScheduledLock 防止集群重复执行
*/
@Scheduled(fixedDelay = 60_000)
@SchedulerLock(name = "orderTimeoutJob", lockAtLeastFor = "PT55S", lockAtMostFor = "PT5M")
public void checkTimeout() {
LocalDateTime expireTime = LocalDateTime.now().minusMinutes(30);
// 分4片并行扫描(按 id mod 4 分片)
int shards = 4;
List<CompletableFuture<Integer>> futures = IntStream.range(0, shards)
.mapToObj(shard -> CompletableFuture.supplyAsync(
() -> processShard(shard, shards, expireTime),
scanExecutor
))
.collect(Collectors.toList());
int totalClosed = futures.stream()
.mapToInt(f -> {
try { return f.get(4, TimeUnit.MINUTES); }
catch (Exception e) { return 0; }
})
.sum();
log.info("[超时关单] 本次关单总数={}", totalClosed);
}
private int processShard(int shard, int totalShards, LocalDateTime expireTime) {
List<PayOrder> orders = orderRepository.findTimeoutOrdersByShard(
expireTime, shard, totalShards, 500 // 每片最多处理500条
);
int closed = 0;
for (PayOrder order : orders) {
try {
orderService.closeOrder(order.getId(), CloseReason.TIMEOUT);
closed++;
} catch (Exception e) {
log.warn("[超时关单] 关单失败, orderId={}", order.getId(), e);
}
}
log.info("[超时关单] shard={}/{}, 关单={}", shard, totalShards, closed);
return closed;
}
}
sql
-- 分片查询(按 id mod 取余)
SELECT * FROM pay_order
WHERE status = 'PENDING'
AND create_time < :expireTime
AND id % :totalShards = :shard
LIMIT :limit
FOR UPDATE SKIP LOCKED -- 跳过已被其他线程锁定的行,防止并发重复处理
七、核心避坑总结
| 场景 | 错误做法 | 正确做法 | 风险等级 |
|---|---|---|---|
| 账户扣款 | 读-改-写不加锁 | 乐观锁/Redis Lua | ⭐⭐⭐⭐⭐ |
| 回调处理 | 直接处理不检查 | 分布式锁 + 双重检测 | ⭐⭐⭐⭐⭐ |
| 线程池 | 直接 Executors.newFixedThreadPool |
自定义 ThreadPoolExecutor | ⭐⭐⭐⭐ |
| 异步通知 | 同步串行通知下游 | CompletableFuture 并发 + 退避重试 | ⭐⭐⭐ |
| 定时扫描 | 单线程全量扫描 | 分片并行 + SKIP LOCKED |
⭐⭐⭐ |
| 线程隔离 | 所有场景共用线程池 | 按业务场景独立线程池 | ⭐⭐⭐⭐ |
记忆口诀
扣款用乐观锁,回调加分布锁;
对账并发跑,通知异步绕;
线程池要隔离,超时分片扫。
八、总结
支付场景中多线程的应用,本质是在吞吐量 与数据安全之间找到平衡:
- 扣款:用锁保原子性,乐观锁优先,高并发转 Redis Lua
- 回调:用分布式锁防并发,状态机 + 双重检测保幂等
- 对账:用 CompletableFuture 并发,线程池 IO 密集型配置
- 通知:全异步不阻塞,退避重试兜底,人工补偿收尾
- 定时任务 :分片并行 +
SKIP LOCKED,防止集群重复处理
线程安全问题在测试环境几乎不会暴露,只有上生产、遇到并发才会炸。
养成在设计阶段就考虑并发模型的习惯,比出了事再排查要省力得多。