1

In business logic, withdrawal requirements are often encountered. The realization of withdrawal is generally divided into two steps:

  • Deduct the balance
  • Call the third-party payment interface to withdraw cash (such as WeChat payment: enterprise payment to change)

Suppose we write (pseudo code) like this:

<?php

DB::beginTransaction();

try {
    $member = Member::find($id);
    $member->money -= $withdrawMoney;
    $member->save();

    $wechat->payment->pay($openid, $withdrawMoney);

    DB::commit();
} catch (\Exception $e) {
    DB::rollback();

    Log::error($e->getMessage());
}

What's the problem with writing this way? When the commit is executed, the database suddenly fails to connect or the database hangs due to network reasons, what should I do? What are the consequences?

It will cause the money to be paid, but the balance is not deducted.

Some students may have other questions. What happens if the database operation is successful and the interface call fails due to network reasons? As far as our code above is concerned, if the interface call fails and an exception will be thrown after the call fails, it will be caught, and the try will not even go to the commit and rollback directly. Therefore, when the interface call fails, there will be no atomicity problem.

How to ensure that the withdrawal operation is atomic. Either balance deduction and interface adjustment succeed at the same time, or fail at the same time?

Transform the operation of adjusting the WeChat interface into an asynchronous task.

<?php

class WechatWithdrawJob
{
    public static function withdraw($openid, $money)
    {
        $result = $wechat->payment->pay($openid, $money);

        if ($result->return_msg == 'SUCCESS') {
            return true;
        }

        return false;
    }
}
<?php

DB::beginTransaction();

try {
    $member = Member::find($id);
    $member->money -= $withdrawMoney;
    $member->save();

    $task = new Task;
    $task->callback = json_encode([WechatWithdrawJob::class, 'withdraw']);
    $task->params = json_encode([$openid, $withdrawMoney]);
    $task->add_time = time();
    $task->save();

    DB::commit();
} catch (\Exception $e) {
    DB::rollback();

    Log::error($e->getMessage());
}

Start an asynchronous task consumption process and continuously poll for consumption.

<?php

$tasks = Task::whereIsNull('finish_time')->where('retries', '<', self::MAX_RETRIES)->get();

foreach ($tasks as $task) {
    if ($task->retries == self::MAX_RETRIES - 1) {
        //notify administrator
        
    }
    $callback = json_decode($task->callback, true);
    $params = json_decode($task->params, true);

    if ($result = call_user_func_array($callback, $params)) {
        $task->finish_time = time();
    } else {
        $task->retries += 1;
    }

    $task->save();
}

In this way, distributed transactions are transformed into local transactions, ensuring the atomicity of withdrawals.

In addition to cash withdrawal, this programming method can also be used in all businesses that need to call external interfaces to ensure the atomicity of the business.

Based on this method, there are some optimization ideas.

  • If there are many asynchronous tasks, some tasks may be time-consuming. It is necessary to have several consumer processes, each of which is responsible for different types of asynchronous tasks.
  • You can also refer to GO's GPM model to add a type to asynchronous tasks, different processes consume different types, and set an upper limit for the amount of unfinished tasks of the same type, such as 100. After the upper limit is reached, the asynchronous tasks that are re-invented will set the type to global. When the type is global, any consumer process can consume. When consuming tasks whose type is global, remember to add a distributed lock to avoid concurrency problems.

church
3.6k 声望67 粉丝