积分流水"反向穿透"社区发帖接口:我如何用"预扣+回滚"模型解决并发刷分
最近在对接一个积分商城插件和社区发帖系统,遇到一个经典但容易踩坑的场景:用户发帖奖励积分,但帖子被删或审核不通过时要追回积分。最头疼的是并发——两个请求同时进来,积分表直接变负数。
先说说我的第一版"天真实现",估计不少人写过类似的:
// 发帖成功钩子
add_action('wp_insert_post', function($post_id, $post, $update) {
if ($update || $post->post_status !== 'publish') return;
$user_id = $post->post_author;
$current = get_user_meta($user_id, 'credits', true) ?: 0;
update_user_meta($user_id, 'credits', $current + 10); // +10分
}, 10, 3);
这代码在单机低并发能跑,但上了生产就是定时炸弹。get_user_meta 和 update_user_meta 之间没有原子性,两个请求同时读到 100,都写成 110,实际应该 120。更惨的是删帖追分时,如果用户已经把积分花掉了,直接扣成负数。
我后来改成了"预扣+回滚"模型,核心思路是积分变动先记流水,状态跟着业务走,而不是直接改余额:
/**
* 积分流水表结构
* id | user_id | amount | type | ref_type | ref_id | status | created_at
*
* status: pending -> committed / rolled_back
*/
// 发帖时预占积分(此时不可消费)
function reserve_credit($user_id, $amount, $ref_type, $ref_id) {
global $wpdb;
$wpdb->query('START TRANSACTION');
try {
// 先查真实可用余额(排除pending状态的预占)
$available = $wpdb->get_var($wpdb->prepare("
SELECT (
SELECT COALESCE(SUM(amount), 0) FROM {$wpdb->prefix}credit_logs
WHERE user_id = %d AND status = 'committed'
) - (
SELECT COALESCE(SUM(ABS(amount)), 0) FROM {$wpdb->prefix}credit_logs
WHERE user_id = %d AND status = 'pending' AND amount < 0
)
", $user_id, $user_id));
if ($available + $amount < 0) { // $amount 为负表示扣减
throw new Exception('积分不足');
}
$wpdb->insert("{$wpdb->prefix}credit_logs", [
'user_id' => $user_id,
'amount' => $amount,
'type' => $amount > 0 ? 'post_reward' : 'post_deduct',
'ref_type' => $ref_type,
'ref_id' => $ref_id,
'status' => 'pending',
'created_at'=> current_time('mysql')
]);
$log_id = $wpdb->insert_id;
$wpdb->query('COMMIT');
return $log_id;
} catch (Exception $e) {
$wpdb->query('ROLLBACK');
return new WP_Error('credit_error', $e->getMessage());
}
}
这里有个细节:可用余额的计算要排除 pending 状态的预扣项。如果是奖励积分(发帖成功给分),预占是正向的,不需要检查余额;如果是消费积分(比如发帖需要消耗积分才能发),预占是负向的,必须确保够扣。
然后和社区状态联动,用 transition_post_status 而不是 wp_insert_post,因为后者在自动保存、修订版时也会触发:
// 状态流转时真正确认或回滚
add_action('transition_post_status', function($new_status, $old_status, $post) {
// 找到对应的pending流水
$log = get_pending_log('post', $post->ID);
if (!$log) return;
if ($new_status === 'publish' && $old_status !== 'publish') {
// 正式发布:确认积分
commit_credit($log->id);
// 同时触发社区通知(解耦,用异步任务)
wp_schedule_single_event(time(), 'notify_credit_arrived', [
'user_id' => $post->post_author,
'amount' => $log->amount
]);
} elseif ($new_status === 'trash' || $new_status === 'draft' && $old_status === 'publish') {
// 删除或下架:回滚积分
rollback_credit($log->id);
// 但如果用户已经花掉了部分呢?这里需要"追债"逻辑
$current_available = get_available_credit($post->post_author);
if ($current_available < 0) {
// 标记欠费,限制部分功能
set_user_debt_flag($post->post_author, abs($current_available));
}
}
}, 10, 3);
commit 和 rollback 的实现很简单,就是改状态,但有个坑:如果同一笔流水被重复 commit 怎么办?比如网络抖动导致钩子执行两次。我的做法是用 status = 'pending' 作为乐观锁条件:
function commit_credit($log_id) {
global $wpdb;
$affected = $wpdb->update(
"{$wpdb->prefix}credit_logs",
['status' => 'committed', 'committed_at' => current_time('mysql')],
['id' => $log_id, 'status' => 'pending'] // 关键:只有pending才能改
);
return $affected === 1; // 返回是否真正修改成功
}
再分享一个和回帖联动的场景。回帖奖励积分通常有"防刷"限制,比如同一帖子多次回帖只奖一次,或者每日上限。这些规则如果写在发帖钩子里,每次都要查历史,越来越重。我拆成了"事件产生"和"规则判定"两个阶段:
// 阶段一:只产生原始事件,不管规则
add_action('comment_post', function($comment_id, $comment_approved, $commentdata) {
if ($comment_approved !== 1) return; // 只处理已审核的
// 插入一条"待判定"的积分事件,不直接算分
wp_insert_post([
'post_type' => 'credit_event', // 自定义事件队列
'post_title' => 'comment_reward',
'post_content'=> json_encode([
'user_id' => $commentdata['user_id'],
'comment_id' => $comment_id,
'post_id' => $commentdata['comment_post_ID'],
'created_at' => time()
]),
'post_status' => 'pending',
'post_author' => $commentdata['user_id']
]);
});
// 阶段二:定时任务或异步消费,应用规则
add_action('process_credit_events', function() {
$events = get_pending_credit_events(50); // 批量处理
foreach ($events as $event) {
$payload = json_decode($event->post_content, true);
// 规则引擎:同一帖子今日是否已奖?
$today_rewarded = get_today_comment_rewards($payload['user_id'], $payload['post_id']);
if ($today_rewarded > 0) {
mark_event_skipped($event->ID, 'duplicate_post_reward');
continue;
}
// 规则:每日回帖奖励上限
$today_total = get_today_total_rewards($payload['user_id']);
if ($today_total >= 50) { // 每日上限50分
mark_event_skipped($event->ID, 'daily_limit_reached');
continue;
}
// 通过规则,真正发分
$log_id = reserve_credit($payload['user_id'], 2, 'comment', $payload['comment_id']);
commit_credit($log_id); // 回帖即时确认,不需要pending
mark_event_processed($event->ID, $log_id);
}
});
这个拆法的好处是规则可以热更新,不用改发帖逻辑。比如运营突然说"周末双倍积分",改规则引擎就行,comment_post 钩子完全不用动。
最后说个踩过的坑:社区接口和积分系统如果是两个插件,或者积分是第三方服务,网络超时怎么办?我早期用同步调用,社区发帖卡 3 秒等积分接口返回。后来改成上面的事件队列,发帖瞬间完成,积分异步到账,用户感知是"发帖成功,积分稍后到账",体验反而更好。
你们对接社区和积分时,是怎么处理"业务成功但积分失败"这种不一致状态的?我目前的做法是允许短暂不一致,靠对账任务兜底,但感觉还有优化空间。

