业务代码里的“先查询再插入”在并发请求下并不可靠。本文以支付请求幂等为例,说明 MySQL 唯一约束、事务隔离、JPA 原生 SQL 与异常处理如何共同保证只创建一条业务记录,并梳理 ORM 使用中的常见陷阱。

在订单、支付、优惠券领取等业务中,经常需要根据一个业务请求号保证幂等:同一个请求重复到达时,系统只能创建一条记录,后续请求返回第一次处理的结果。

很多实现一开始都会写成这样:

@Transactional
public PaymentRequest create(String requestKey, Long userId, BigDecimal amount) {
    return repository.findByRequestKey(requestKey)
            .orElseGet(() -> repository.save(
                    new PaymentRequest(requestKey, userId, amount)
            ));
}

单线程测试通常没有问题,但这段代码无法解决并发创建。两个请求可能同时查询,都没有查到记录,然后分别执行插入。事务并不会自动把“查询和插入”变成一个不可分割的业务判断。真正可靠的幂等,需要让数据库参与这次判断。

问题背景:查询结果不是占位符

假设请求 A 和请求 B 携带相同的 requestKey,执行过程可能是:

  1. A 开启事务,查询不到记录。
  2. B 开启事务,也查询不到记录。
  3. A 准备插入。
  4. B 也准备插入。
  5. 如果表上没有唯一约束,最终会得到两条数据。

即使把事务隔离级别提高到 SERIALIZABLE,也不应该把它当成一般幂等方案。更高的隔离级别可能增加锁等待和死锁风险,吞吐量也会下降,而且它并没有表达“这个业务字段必须唯一”的数据约束。

正确的第一步,是在数据库中声明事实:一个请求号只能对应一条记录。

CREATE TABLE payment_request (
    id BIGINT NOT NULL AUTO_INCREMENT,
    request_key VARCHAR(100) NOT NULL,
    user_id BIGINT NOT NULL,
    amount DECIMAL(19, 2) NOT NULL,
    status VARCHAR(20) NOT NULL,
    created_at DATETIME(6) NOT NULL,
    updated_at DATETIME(6) NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_payment_request_key (request_key),
    KEY idx_payment_user (user_id)
) ENGINE = InnoDB;

唯一索引是并发安全的底线。应用层的查询可以优化体验,但不能代替它。任何绕过当前应用写入数据库的程序,也必须遵守这个约束。

一种实用实现:插入或确认已存在

对于幂等创建,可以使用 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE。第一次插入时创建记录;如果请求号已经存在,就执行一个无实际变化的更新,然后再查询已有记录。

下面的示例以 Spring Data JPA 为例,适用于使用 MySQL、InnoDB 的常见 Spring Boot 项目。代码使用 Java 17 文本块;如果项目仍是 Java 8,可以把 SQL 改成普通字符串拼接。

@Entity
@Table(name = "payment_request")
public class PaymentRequest {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "request_key", nullable = false, length = 100)
    private String requestKey;

    @Column(name = "user_id", nullable = false)
    private Long userId;

    @Column(nullable = false, precision = 19, scale = 2)
    private BigDecimal amount;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PaymentStatus status;

    @Column(name = "created_at", nullable = false)
    private LocalDateTime createdAt;

    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    protected PaymentRequest() {
    }

    public PaymentRequest(String requestKey, Long userId, BigDecimal amount) {
        this.requestKey = requestKey;
        this.userId = userId;
        this.amount = amount;
        this.status = PaymentStatus.CREATED;
        this.createdAt = LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    // 省略与业务无关的 getter

    public Long getId() {
        return id;
    }

    public String getRequestKey() {
        return requestKey;
    }

    public Long getUserId() {
        return userId;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public PaymentStatus getStatus() {
        return status;
    }
}
public enum PaymentStatus {
    CREATED,
    PROCESSING,
    SUCCESS,
    FAILED
}

Repository 中不依赖 save 的自动判断,而是直接执行数据库的幂等插入:

public interface PaymentRequestRepository
        extends JpaRepository<PaymentRequest, Long> {

    @Modifying
    @Query(value = """
        INSERT INTO payment_request
            (request_key, user_id, amount, status, created_at, updated_at)
        VALUES
            (:requestKey, :userId, :amount, 'CREATED', NOW(6), NOW(6))
        ON DUPLICATE KEY UPDATE id = id
        """, nativeQuery = true)
    int insertIfAbsent(
            @Param("requestKey") String requestKey,
            @Param("userId") Long userId,
            @Param("amount") BigDecimal amount
    );

    Optional<PaymentRequest> findByRequestKey(String requestKey);
}

id = id 是一个无实际变化的更新,用来让 MySQL 在唯一键冲突时把语句当作已处理。不要依赖 insertIfAbsent 的返回值来判断是否首次插入,因为不同 JDBC 配置下,重复更新可能返回 0 或 1。需要知道是否首次创建时,可以使用专门的状态字段、数据库返回值方案,或者在查询结果中判断创建时间和业务状态,但不要把受驱动配置影响的行数当作唯一依据。

Service 的事务边界应覆盖这次插入和随后查询:

@Service
public class PaymentRequestService {

    private final PaymentRequestRepository repository;

    public PaymentRequestService(PaymentRequestRepository repository) {
        this.repository = repository;
    }

    @Transactional
    public PaymentRequest getOrCreate(
            String requestKey,
            Long userId,
            BigDecimal amount) {

        repository.insertIfAbsent(requestKey, userId, amount);

        PaymentRequest request = repository.findByRequestKey(requestKey)
                .orElseThrow(() -> new IllegalStateException(
                        "payment request was not found after insert: " + requestKey));

        if (!request.getUserId().equals(userId)
                || request.getAmount().compareTo(amount) != 0) {
            throw new IllegalArgumentException(
                    "request key has been used with different payment data");
        }

        return request;
    }
}

两个并发事务使用相同请求号时,InnoDB 会在唯一索引上协调竞争。第一个事务成功插入后,第二个事务会等待相关锁;第一个事务提交后,第二个事务发现唯一键冲突并执行无变化更新,随后查询到已经存在的记录。如果第一个事务回滚,第二个事务仍有机会正常插入。

这里还有一个重要业务判断:请求号不能只代表“有没有这条记录”,还应该绑定请求内容。相同 requestKey 却携带不同用户或金额时,通常应该报参数冲突,而不是静默返回旧记录。否则调用方可能误以为新金额已经创建成功。

为什么不直接捕获唯一键异常

另一种常见写法是先直接 save,遇到 DataIntegrityViolationException 后再查询:

try {
    return repository.save(entity);
} catch (DataIntegrityViolationException e) {
    return repository.findByRequestKey(requestKey).orElseThrow();
}

这在事务中往往不能按预期工作。数据库唯一键冲突后,Hibernate、Spring 事务管理器或数据库连接可能已经把当前事务标记为回滚。此时在同一个事务里继续查询,可能得到“事务已回滚”的异常,而不是已有记录。

如果确实采用“异常竞争”模式,就需要把失败插入和后续查询拆到合适的事务边界中,例如使用独立事务或在异常事务结束后重新查询。但这会增加传播级别、异常分类和连接管理的复杂度。对于稳定的幂等创建,显式使用 MySQL 的冲突处理通常更容易维护。

ORM 使用中的几个坑

不要用应用层唯一索引声明替代数据库迁移

实体上的 @Column(unique = true) 可以表达意图,但生产环境通常由 Flyway、Liquibase 或人工审核的 DDL 管理表结构。应用启动时自动更新表结构不适合作为所有生产数据库的约束发布方案。应确认真实数据库中确实存在唯一索引,并检查历史脏数据是否会阻塞索引创建。

原生 SQL 不会自动同步已经加载的实体

JPA 的一级缓存只认识当前持久化上下文中已经加载或管理的对象。如果在同一个事务里先通过 JPA 查询了某个对象,再用原生 SQL 更新它,随后再次查询时可能拿到一级缓存中的旧对象。

本例把“幂等插入”和“查询已有记录”安排成清晰的步骤,且没有先加载同一请求号的实体。如果原生 SQL 还会修改已有记录,应该谨慎使用 clearAutomatically = true,或者在明确的边界调用 EntityManager.clear()。清理上下文会丢弃尚未刷新的实体修改,不能无条件添加。

不要误解事务隔离级别

MySQL InnoDB 默认隔离级别通常是 REPEATABLE READ,但具体项目仍应以实际配置为准。隔离级别解决的是并发事务之间的可见性与锁行为,不会替应用自动生成业务唯一性。请求号是否唯一,最终仍应由唯一索引保证。

唯一索引字段要和业务定义一致

如果幂等范围是“商户内请求号唯一”,唯一索引就不能只建在 request_key 上,而应该建联合索引:

ALTER TABLE payment_request
    DROP INDEX uk_payment_request_key,
    ADD UNIQUE KEY uk_merchant_request (merchant_id, request_key);

索引设计必须准确表达业务范围。过宽会错误拦截合法请求,过窄则无法真正保证幂等。

实践建议

首先,把幂等键定义成稳定且可重试的业务标识,不要使用每次重试都会变化的随机值。其次,数据库表中保存首次请求的关键参数,并在重复请求时校验参数一致性。再次,把“创建幂等记录”和“推进状态”分开设计:幂等插入只负责确保记录存在,状态流转则应通过明确的条件更新控制,例如只允许 PROCESSING 转为 SUCCESSFAILED

如果后续还要调用第三方支付接口,不要误以为数据库事务可以回滚第三方调用。数据库事务只能保证本地数据的一致性,外部调用需要依靠幂等请求号、状态机、重试策略和补偿任务共同完成。数据库记录可以先进入 CREATEDPROCESSING,再由可靠的业务流程推进,而不是把网络调用长时间包在数据库事务中。

总结

“先查询再插入”把并发判断放在了应用代码里,而查询结果本身不会为未来的插入保留位置。可靠的幂等创建应由三部分组成:用数据库唯一约束定义不可重复的事实,用 MySQL 冲突处理语句协调并发,用事务保证插入与读取处在清晰的一致性边界内。

ORM 负责映射对象和简化常规操作,但它不会替我们消除数据库竞争,也不会自动修正一级缓存、异常回滚和外部调用之间的边界。把真正的约束交给数据库,再让 Java 服务围绕这个约束组织流程,代码反而更简单,也更接近业务真实需要。

最后修改:2026 年 09 月 02 日
如果觉得我的文章对你有用,请随意赞赏