如何防止 Play 2.1 应用中的并发 DB Transactions
结论
不要依赖应用层的“先查询、再插入”来避免重复付款记录。两个请求可能同时完成查询,都判断记录不存在。即使把查询和插入放进普通事务,也不一定能消除这种竞争。
建议这样处理:
- 在数据库中为
payment_id添加UNIQUE约束。 - 将支付通知和成功页处理设计为幂等操作。
- 在同一事务中完成插入或状态更新。
- 遇到唯一键冲突时重新查询,并按照状态规则合并。
- 确保
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 等高优先级状态被旧请求覆盖。
备注:内容仅供参考。
