本文目录导读:

在PHP项目中实现补偿机制(Compensation Mechanism),通常是为了处理分布式事务、异步任务失败后的数据一致性保障,或者回滚已执行的“预留”操作。
核心思想是:先执行一个“可补偿”的操作(预留资源),如果后续流程失败,则调用一个“补偿操作”来撤销之前的效果。
以下是几种常见的实现方案,从简单到复杂:
手动补偿(数据库事务 + 状态机)
适用于单体应用,逻辑清晰,但需要手动编写回滚代码。
原理:为每个业务流程定义状态(如:待处理、处理中、成功、失败、补偿中、已补偿),在失败时,根据当前状态执行反向SQL。
// 示例:转账业务
class TransferService {
public function transfer($from, $to, $amount) {
$db->beginTransaction();
try {
// 1. 扣钱(可补偿操作)
$db->exec("UPDATE accounts SET balance = balance - $amount WHERE id = $from");
// 2. 记录操作日志(用于补偿)
$logId = $db->insert("INSERT INTO transfer_log (from, to, amount, status) VALUES (?,?,?, 'pending')");
// 3. 模拟调用外部服务(可能失败)
$this->callExternalBankApi($to, $amount); // 假设这里抛异常
// 4. 加钱
$db->exec("UPDATE accounts SET balance = balance + $amount WHERE id = $to");
$db->update("UPDATE transfer_log SET status = 'success' WHERE id = $logId");
$db->commit();
} catch (\Exception $e) {
$db->rollback(); // 数据库事务自动回滚,扣钱也被撤销
// 但如果是调用外部API已经成功了(比如短信发送),需要手动补偿
// 这里可以调用补偿函数:$this->cancelExternalApiCall($to, $amount);
}
}
}
缺点:跨服务、跨数据库时无法使用本地事务回滚,需要显式调用补偿接口。
基于消息队列的异步补偿(Saga 模式)
适用于微服务或跨系统调用,通过消息队列发送“补偿命令”来撤销已执行的操作。
方案:使用 TCC(Try-Confirm-Cancel) 或 Saga 模式。
- Try:执行资源预留(扣库存、冻结金额)。
- Confirm:确认执行(实际扣减、发货),如果Confirm失败,进入Cancel。
- Cancel:释放资源(退回库存、解冻金额)。
示例:使用 RabbitMQ 或 Redis 队列
// 1. 预留资源(创建订单时冻结库存)
class OrderService {
public function createOrder($productId, $userId) {
// Try:冻结库存
$this->inventoryService->freezeStock($productId, 1);
// 记录待补偿的任务到队列
$compensationPayload = [
'action' => 'unfreeze_stock',
'product_id' => $productId,
'user_id' => $userId,
'expires_at' => time() + 300 // 30分钟超时
];
$this->queue->push('compensation_tasks', json_encode($compensationPayload));
// 后续处理(支付、发货),如果失败则触发补偿
try {
$this->paymentService->charge($userId, 100);
$this->shippingService->ship($productId, $userId);
// 成功,移除补偿任务(或标记为已完成)
$this->queue->remove('compensation_tasks', $compensationPayload);
} catch (\Exception $e) {
// 失败,启动补偿:执行库存解冻
$this->executeCompensation($compensationPayload);
}
}
// 补偿执行函数
private function executeCompensation($payload) {
if ($payload['action'] === 'unfreeze_stock') {
$this->inventoryService->unfreezeStock($payload['product_id'], 1);
}
}
}
配合定时任务:还可以定一个 cron job(如每分钟一次)扫描“补偿队列”中过期未完成的任务,自动执行补偿。
利用设计模式:策略模式 + 事件监听
可以将补偿逻辑封装成独立的 CompensationHandler,通过事件驱动。
// 定义补偿处理器接口
interface CompensationHandler {
public function compensate($originalPayload): bool;
}
// 具体的补偿:库存解冻
class UnfreezeStockHandler implements CompensationHandler {
public function compensate($payload) {
// 调用库存服务的解冻API
return true;
}
}
// 使用容器注册
class CompensationManager {
private $handlers = [];
public function register($actionName, CompensationHandler $handler) {
$this->handlers[$actionName] = $handler;
}
public function compensate($actionName, $payload) {
if (isset($this->handlers[$actionName])) {
return $this->handlers[$actionName]->compensate($payload);
}
}
}
// 在业务代码中触发
$compensationManager->compensate('unfreeze_stock', ['product_id' => 1]);
保证幂等性(补偿的关键前提)
任何补偿操作都必须幂等(执行一次和多次结果一样)。
- 解冻库存:如果库存已经解冻,再次解冻不能报错(最好返回成功)。
- 取消订单:如果订单已取消,再次取消不能重复退款。
实现方法:
// 为每个补偿请求生成唯一ID(业务流水号)
$compensationId = uniqid('comp_', true);
// 在补偿执行前,检查数据库/Redis中是否已执行过
$cacheKey = "compensated:{$compensationId}";
if ($redis->exists($cacheKey)) {
return true; // 幂等,直接返回成功
}
// 执行补偿逻辑...
// 执行成功后,标记已补偿(设置过期时间,比如7天)
$redis->setex($cacheKey, 7 * 86400, 1);
最终方案:综合架构图
[用户操作]
|
v
[PHP业务逻辑]
|--- 1. 执行Try操作(预留资源) ---> [数据库/Redis]
|--- 2. 记录操作日志(含补偿信息) ---> [日志表/补偿队列]
|
|--- 3. 执行Confirm操作(实际提交)
| 成功 --> 删除补偿记录 / 标记完成
| 失败 -->
| |
| v
| [补偿调度器]
| |--- 调用Cancel接口(解冻、退款)
| |--- 重试机制(指数退避)
| |--- 最终告警(人工介入)
|
v
[最终一致性]
总结建议
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 单体应用,数据库事务能覆盖 | 数据库事务 + 手动回滚 | 最简单,利用 rollback |
| 跨服务调用,高一致性要求 | TCC 模式 + 消息队列 | 需要开发完善,但最可靠 |
| 异步任务(如发送邮件、通知) | 消息队列 + 失败重试 + 定时扫描 | 消息消费失败后,放到死信队列或补偿队列 |
| 要求零人工干预 | Saga + 事件驱动 + 补偿日志 | 每个操作都预先定义好补偿 |
核心要点:
- 先设计补偿:在写业务逻辑之前,先想清楚“如果这一步失败了,怎么撤销?”
- 幂等性:补偿操作和业务操作都要幂等。
- 日志记录:记录每一次尝试和补偿的详细信息,方便排查。
- 最终告警:如果补偿多次失败,一定要通知开发或运维人员人工介入。