在支付API的设计与实现中,幂等性是保障数据一致性和交易正确性的关键特性。以下是实现支付API幂等性的核心方法及最佳实践:
1. 唯一标识(IDempotent Key)机制 2. 状态机设计
核心原则 :将支付流程抽象为状态机(如:待支付→处理中→成功/失败),同一状态的重复请求不改变结果。
关键实现 :
使用数据库字段记录交易状态。
通过乐观锁(如版本号)防止并发冲突。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 CREATE TABLE payment_orders ( id VARCHAR (36 ) PRIMARY KEY , status ENUM('PENDING' , 'PROCESSING' , 'SUCCESS' , 'FAILED' ) NOT NULL , amount DECIMAL (10 , 2 ) NOT NULL , version INT DEFAULT 0 ); UPDATE payment_orders SET status = 'SUCCESS' , version = version + 1 WHERE id = '${idempotency_key}' AND status = 'PROCESSING' AND version = ${current_version};
3. 分布式锁与缓存结合
适用场景 :高并发场景下防止重复处理。
实现方式 :
使用Redis或ZooKeeper实现分布式锁。
缓存已处理的请求结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 @Service public class PaymentService { @Autowired private RedisTemplate<String, String> redisTemplate; public PaymentResult processPayment (String idempotencyKey, PaymentRequest request) { Boolean locked = redisTemplate.opsForValue().setIfAbsent( "lock:payment:" + idempotencyKey, "locked" , 30 , TimeUnit.SECONDS); if (!locked) { return getCachedResult(idempotencyKey); } try { PaymentResult cachedResult = getCachedResult(idempotencyKey); if (cachedResult != null ) { return cachedResult; } PaymentResult result = executePayment(request); cacheResult(idempotencyKey, result); return result; } finally { redisTemplate.delete("lock:payment:" + idempotencyKey); } } }
4. 幂等性中间件
推荐方案 :使用开源中间件简化实现,如:
Resilience4j (Java):提供幂等性拦截器。
Spring Retry :结合唯一标识实现重试幂等。
5. 客户端最佳实践
生成可靠的唯一ID :使用UUID或业务相关的唯一标识。
持久化唯一ID :将ID与请求参数一起存储,确保重试时携带相同ID。
处理超时策略 :设置合理的超时时间,避免过早重试。
6. 测试与验证
编写幂等性测试用例 :模拟重复请求,验证结果一致性。
压力测试 :在高并发场景下验证幂等性保障机制的有效性。
注意事项
幂等性范围 :仅保证同一请求的多次调用结果一致,不处理业务逻辑变更。
数据有效期 :缓存结果需设置合理的过期时间(建议24小时)。
异常处理 :处理部分成功的边界情况(如支付已扣款但状态未更新)。
通过以上方案,可有效实现支付API的幂等性,确保交易在分布式环境下的一致性和可靠性。
应用示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 package com.example.payment;public enum PaymentStatus { PENDING("待支付" ), PROCESSING("处理中" ), SUCCESS("支付成功" ), FAILED("支付失败" ), CANCELED("已取消" ), REFUNDED("已退款" ), PARTIAL_REFUNDED("部分退款" ); private final String displayName; PaymentStatus(String displayName) { this .displayName = displayName; } public String getDisplayName () { return displayName; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 package com.example.payment;import javax.persistence.*;import java.math.BigDecimal;import java.time.LocalDateTime;@Entity @Table(name = "payment_orders") public class PaymentOrder { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(unique = true, nullable = false) private String idempotencyKey; @Column(nullable = false) private BigDecimal amount; @Column(nullable = false) @Enumerated(EnumType.STRING) private PaymentStatus status; @Column private String transactionId; @Column private LocalDateTime createTime; @Column private LocalDateTime updateTime; @Version private Integer version; public boolean isFinalState () { return status == PaymentStatus.SUCCESS || status == PaymentStatus.FAILED; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 package com.example.payment;import org.springframework.data.jpa.repository.JpaRepository;import org.springframework.data.jpa.repository.Lock;import org.springframework.data.jpa.repository.Modifying;import org.springframework.data.jpa.repository.Query;import org.springframework.data.repository.query.Param;import org.springframework.stereotype.Repository;import javax.persistence.LockModeType;import java.util.Optional;@Repository public interface PaymentRepository extends JpaRepository <PaymentOrder, Long> { Optional<PaymentOrder> findByIdempotencyKey (String idempotencyKey) ; @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT p FROM PaymentOrder p WHERE p.idempotencyKey = :key") Optional<PaymentOrder> findByIdempotencyKeyWithLock (@Param("key") String idempotencyKey) ; @Modifying @Query("UPDATE PaymentOrder p SET p.status = :status, p.updateTime = CURRENT_TIMESTAMP, p.version = p.version + 1 " + "WHERE p.id = :id AND p.status = :currentStatus AND p.version = :version") int updateStatus (@Param("id") Long id, @Param("status") PaymentStatus newStatus, @Param("currentStatus") PaymentStatus currentStatus, @Param("version") Integer version) ;}
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 package com.example.payment;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.data.redis.core.RedisTemplate;import org.springframework.stereotype.Service;import java.util.concurrent.TimeUnit;import java.util.function.Supplier;@Service public class IdempotencyService { private static final String IDEMPOTENCY_KEY_PREFIX = "idempotency:" ; private static final String LOCK_KEY_PREFIX = "lock:" ; private static final long LOCK_EXPIRE_TIME = 30 ; private static final long RESULT_CACHE_TIME = 86400 ; @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private PaymentRepository paymentRepository; public <T> T execute (String idempotencyKey, Supplier<T> action) { T cachedResult = getCachedResult(idempotencyKey); if (cachedResult != null ) { return cachedResult; } boolean locked = acquireLock(idempotencyKey); if (!locked) { waitForLockRelease(idempotencyKey); return execute(idempotencyKey, action); } try { cachedResult = getCachedResult(idempotencyKey); if (cachedResult != null ) { return cachedResult; } PaymentOrder existingOrder = paymentRepository.findByIdempotencyKey(idempotencyKey); if (existingOrder != null && existingOrder.isFinalState()) { T result = (T) mapOrderToResult(existingOrder); cacheResult(idempotencyKey, result); return result; } T result = action.get(); cacheResult(idempotencyKey, result); saveOrder(idempotencyKey, result); return result; } finally { releaseLock(idempotencyKey); } } private boolean acquireLock (String idempotencyKey) { String lockKey = LOCK_KEY_PREFIX + idempotencyKey; return redisTemplate.opsForValue() .setIfAbsent(lockKey, "locked" , LOCK_EXPIRE_TIME, TimeUnit.SECONDS); } private void releaseLock (String idempotencyKey) { String lockKey = LOCK_KEY_PREFIX + idempotencyKey; redisTemplate.delete(lockKey); } private <T> T getCachedResult (String idempotencyKey) { String cacheKey = IDEMPOTENCY_KEY_PREFIX + idempotencyKey; return (T) redisTemplate.opsForValue().get(cacheKey); } private <T> void cacheResult (String idempotencyKey, T result) { String cacheKey = IDEMPOTENCY_KEY_PREFIX + idempotencyKey; redisTemplate.opsForValue().set(cacheKey, result, RESULT_CACHE_TIME, TimeUnit.SECONDS); } private void waitForLockRelease (String idempotencyKey) { try { Thread.sleep(100 ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } private void saveOrder (String idempotencyKey, Object result) { } private Object mapOrderToResult (PaymentOrder order) { return null ; } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 package com.example.payment;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Service;@Service public class PaymentService { @Autowired private IdempotencyService idempotencyService; public PaymentResult processPayment (String idempotencyKey, PaymentRequest request) { return idempotencyService.execute(idempotencyKey, () -> { validateRequest(request); PaymentOrder order = createOrder(request); PaymentResult result = callPaymentProvider(order); updateOrderStatus(order, result); return result; }); } private void validateRequest (PaymentRequest request) { } private PaymentOrder createOrder (PaymentRequest request) { return null ; } private PaymentResult callPaymentProvider (PaymentOrder order) { return null ; } private void updateOrderStatus (PaymentOrder order, PaymentResult result) { } }
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 package com.example.payment;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.web.bind.annotation.*;import java.util.UUID;@RestController @RequestMapping("/api/payments") public class PaymentController { @Autowired private PaymentService paymentService; @PostMapping public PaymentResult createPayment ( @RequestHeader("X-Idempotency-Key") String idempotencyKey, @RequestBody PaymentRequest request) { if (idempotencyKey == null || idempotencyKey.isEmpty()) { idempotencyKey = UUID.randomUUID().toString(); } return paymentService.processPayment(idempotencyKey, request); } }
IdempotencyServiceTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 package com.example.payment;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;import org.mockito.InjectMocks;import org.mockito.Mock;import org.mockito.MockitoAnnotations;import org.springframework.data.redis.core.RedisTemplate;import java.util.concurrent.CompletableFuture;import java.util.concurrent.ExecutorService;import java.util.concurrent.Executors;import java.util.concurrent.atomic.AtomicInteger;import static org.junit.jupiter.api.Assertions.*;import static org.mockito.Mockito.*;class IdempotencyServiceTest { @Mock private RedisTemplate<String, Object> redisTemplate; @Mock private PaymentRepository paymentRepository; @InjectMocks private IdempotencyService idempotencyService; @BeforeEach void setUp () { MockitoAnnotations.openMocks(this ); } @Test void execute_shouldOnlyExecuteOnce_whenCalledConcurrently () throws Exception { String idempotencyKey = "test-key" ; AtomicInteger counter = new AtomicInteger (0 ); when (redisTemplate.opsForValue().setIfAbsent(anyString(), any(), anyLong(), any())) .thenReturn(true ).thenReturn(false ); when (redisTemplate.opsForValue().get(anyString())).thenReturn(null ); ExecutorService executor = Executors.newFixedThreadPool(5 ); CompletableFuture<?>[] futures = new CompletableFuture [10 ]; for (int i = 0 ; i < 10 ; i++) { futures[i] = CompletableFuture.runAsync(() -> { idempotencyService.execute(idempotencyKey, counter::incrementAndGet); }, executor); } CompletableFuture.allOf(futures).join(); assertEquals(1 , counter.get()); } }