PAYATHON 2026

如何防止 Play 2.1 应用中的并发 DB Transactions

支付阿杰

结论

不要依赖应用层的“先查询、再插入”来避免重复付款记录。两个请求可能同时完成查询,都判断记录不存在。即使把查询和插入放进普通事务,也不一定能消除这种竞争。

建议这样处理:

  1. 在数据库中为 payment_id 添加 UNIQUE 约束。
  2. 将支付通知和成功页处理设计为幂等操作。
  3. 在同一事务中完成插入或状态更新。
  4. 遇到唯一键冲突时重新查询,并按照状态规则合并。
  5. 确保 active 不会被稍晚到达的成功页请求改回 pending。

数据库唯一约束是必须保留的最后一道防线。应用锁可以辅助处理,但不能替代它。

为什么普通事务还不够

当前逻辑本质上是存在竞争窗口的 check-then-act 操作:

请求 A:查询 payment_id,不存在
请求 B:查询 payment_id,不存在
请求 A:插入 pending
请求 B:插入 active

如果 payment_id 没有唯一约束,这两次插入都可能成功。

把查询和插入放进事务,只能保证单个事务内部的一致性,无法自动阻止另一个事务得出相同结论。在 MySQL 常用的隔离级别下,两个事务仍可能同时读到“不存在”。

SELECT ... FOR UPDATE 适合锁定已有记录,但此处要锁定的记录还不存在。使用 InnoDB 间隙锁处理这种情况,会受到索引和事务隔离级别的影响,实现也较为脆弱。对于业务上要求唯一的值,直接建立唯一索引更稳妥。

第一步:为 payment id 添加唯一约束

先清理历史数据中的重复值,再添加唯一索引:

ALTER TABLE payment
ADD CONSTRAINT uq_payment_payment_id UNIQUE (payment_id);

如果系统接入了多个支付渠道,或者不同渠道可能使用相同格式的 ID,唯一键通常还应包含渠道:

ALTER TABLE payment
ADD CONSTRAINT uq_payment_provider_id
UNIQUE (provider, payment_id);

具体选择取决于支付 ID 的业务范围,不能默认不同 provider 之间的 ID 永远不会重复。

Ebean 实体也可以声明唯一性,例如:

@Entity
@Table(
    name = "payment",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uq_payment_payment_id",
            columnNames = {"payment_id"}
        )
    }
)
public class Payment extends Model {

    @Id
    public Long id;

    @Column(name = "payment_id", nullable = false)
    public String paymentId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    public PaymentStatus status;
}

但仍要检查实际数据库中是否生成了唯一约束,不能只依赖 Java 注解。生产环境一般应通过受控的数据库迁移创建索引。

第二步:明确状态合并规则

两个入口提供的信息强度不同:

  • 成功页重定向只能说明浏览器返回了站点,最多创建 pending 记录。
  • 支付 provider 的服务端通知通过合法性校验后,可以把记录设为 active。
  • 已经处于 active 状态的记录,不能被后续请求改回 pending。

状态合并规则至少应满足:

不存在 + redirect      -> pending
不存在 + notification  -> active
pending + notification -> active
active + redirect       -> active
active + notification   -> active

不能采用“最后一次写入覆盖前一次写入”的方式,否则迟到的重定向请求可能把 active 降级为 pending。

第三步:使用数据库原子 Upsert

MySQL 可以使用 INSERT ... ON DUPLICATE KEY UPDATE,由数据库结合唯一键约束,原子地完成插入或更新。

处理成功页请求时:

INSERT INTO payment (payment_id, status)
VALUES (?, 'pending')
ON DUPLICATE KEY UPDATE
    payment_id = payment_id;

发生重复时,这条语句不会修改状态,因此已有的 active 不会被降级。

处理经过验证的支付通知时:

INSERT INTO payment (payment_id, status)
VALUES (?, 'active')
ON DUPLICATE KEY UPDATE
    status = 'active';

无论通知先到还是后到,最终都只会保留一条记录,状态为 active。

如果状态机以后加入 refunded、cancelled 等状态,就不能再无条件更新为 active,而要明确允许的状态迁移。例如:

INSERT INTO payment (payment_id, status)
VALUES (?, 'active')
ON DUPLICATE KEY UPDATE
    status = CASE
        WHEN status = 'pending' THEN 'active'
        ELSE status
    END;

实际允许哪些迁移,应以业务状态机为准。

使用 Ebean 事务执行原生 SQL

Play 2.1 使用的 Ebean 版本较旧,具体事务 API 可能因项目依赖而略有不同。下面是该时期 Ebean 常见的显式事务写法:

import com.avaje.ebean.Ebean;
import com.avaje.ebean.Transaction;

public void recordRedirect(String paymentId) {
    Transaction transaction = Ebean.beginTransaction();

    try {
        Ebean.createSqlUpdate(
            "INSERT INTO payment (payment_id, status) " +
            "VALUES (:paymentId, 'pending') " +
            "ON DUPLICATE KEY UPDATE payment_id = payment_id"
        )
        .setParameter("paymentId", paymentId)
        .execute();

        transaction.commit();
    } finally {
        transaction.end();
    }
}

支付通知可以这样处理:

public void activatePayment(String paymentId) {
    Transaction transaction = Ebean.beginTransaction();

    try {
        Ebean.createSqlUpdate(
            "INSERT INTO payment (payment_id, status) " +
            "VALUES (:paymentId, 'active') " +
            "ON DUPLICATE KEY UPDATE " +
            "status = CASE " +
            "    WHEN status = 'pending' THEN 'active' " +
            "    ELSE status " +
            "END"
        )
        .setParameter("paymentId", paymentId)
        .execute();

        transaction.commit();
    } finally {
        transaction.end();
    }
}

如果还要写入订单、流水或审计记录,应把这些操作放在同一个事务中,让付款状态及相关业务数据一起提交或回滚。

不使用 MySQL Upsert 时的处理方式

如果要保留 ORM 写法,可以先尝试插入,遇到唯一键冲突后再读取已有记录。前提仍然是数据库中已经存在唯一约束。

示意代码如下:

public Payment createPendingOrGetExisting(String paymentId) {
    try {
        Payment payment = new Payment();
        payment.paymentId = paymentId;
        payment.status = PaymentStatus.PENDING;
        payment.save();
        return payment;
    } catch (RuntimeException exception) {
        if (!isUniqueConstraintViolation(exception)) {
            throw exception;
        }

        return Payment.find
            .where()
            .eq("paymentId", paymentId)
            .findUnique();
    }
}

支付通知遇到冲突后,需要重新读取记录并更新:

public Payment activateOrCreate(String paymentId) {
    try {
        Payment payment = new Payment();
        payment.paymentId = paymentId;
        payment.status = PaymentStatus.ACTIVE;
        payment.save();
        return payment;
    } catch (RuntimeException exception) {
        if (!isUniqueConstraintViolation(exception)) {
            throw exception;
        }

        Payment existing = Payment.find
            .where()
            .eq("paymentId", paymentId)
            .findUnique();

        if (existing.status == PaymentStatus.PENDING) {
            existing.status = PaymentStatus.ACTIVE;
            existing.update();
        }

        return existing;
    }
}

isUniqueConstraintViolation 不能简单地把所有数据库异常都视为重复记录。应检查项目使用的 JDBC 驱动所暴露的异常类型、SQLState 或 vendor error code。不同版本的 MySQL Connector/J 和 Ebean 可能采用不同的异常包装方式,需要根据实际依赖确认。

捕获唯一键异常后,最好先结束或回滚失败的事务,再在新事务中查询和更新。在某些事务管理方式下,发生 SQL 异常后不应继续使用原事务。

状态更新也要防止并发覆盖

解决重复插入后,后续状态更新仍可能发生竞争。例如,两个工作线程同时读取到 pending,随后分别写入不同状态。

可以改用带条件的原子更新:

UPDATE payment
SET status = 'active'
WHERE payment_id = ?
  AND status = 'pending';

执行后检查受影响的行数:

  • 1 表示记录已从 pending 更新为 active。
  • 0 表示记录不存在,或者状态已经不是 pending,需要重新查询确认。

也可以在实体中增加乐观锁版本字段:

@Version
public Long version;

Ebean 更新记录时会检查版本号。如果另一事务已经修改了记录,当前更新会失败,应用可以重新读取,并根据状态机决定是否重试。乐观锁适合保护已有记录,但不能替代 payment_id 的唯一约束,因为它无法阻止两条记录同时进行首次插入。

不建议使用 JVM 内存锁

下面这种锁只能保护单个 JVM 进程:

synchronized (paymentId.intern()) {
    // check and insert
}

应用一旦运行在多个实例或多个进程中,这个锁就无法协调它们,进程重启后也不会继续生效。String.intern() 还可能造成额外的内存占用和锁竞争。

数据库是所有应用实例共享的协调点,因此唯一约束和原子写入更适合这个场景。即使确实需要分布式锁,也不能用它代替数据库约束,因为锁超时、进程崩溃和网络故障仍可能导致重复执行。

还需要注意的安全和业务问题

支付通知必须先完成 provider 要求的签名、金额、币种、商户号和订单归属校验,不能只凭客户端传来的 payment_id 就把付款设为 active。

成功页也不是可信的支付凭证。用户可以刷新、重复访问或伪造重定向请求,因此成功页最多只能用于展示等待确认状态。

支付 provider 可能重复发送通知,所以通知处理从一开始就应具备幂等性。除了付款表中的业务唯一键,还可以保存 provider 的事件 ID,并为事件 ID 添加唯一约束,避免同一通知重复触发发货、积分或邮件等副作用。

完整方案是:数据库唯一键保证“只能有一条”,原子 Upsert 或唯一键冲突重试负责处理并发,明确的状态机则防止 active 等高优先级状态被旧请求覆盖。

备注:内容仅供参考。