Skip to content

DRAFT System Design: Stepped Backoff Polling — A Smart Strategy for State Sync

About 1083 wordsAbout 4 min

LaravelPHPSystem Design

2024-10-07

Consider an e-commerce system that must keep order payment status in realtime sync with an upstream payment system: check once per second; if still unpaid, keep checking until the order completes.

Checking every second per order puts heavy load on the server and the upstream API.

If we can dynamically adjust check frequency — e.g. every 1 second for the first five attempts, then every 5 seconds for the next five, and so on — we cut server and external API load while still updating in time.

We call this stepped backoff polling.

A real example: Alipay’s async payment-success notifications gradually reduce request frequency toward merchants:

Alipay docs example During async notification, if Alipay does not receive success as the response, it treats the notification as failed and retries on a schedule. Intervals: 4m, 10m, 10m, 1h, 2h, 6h, 15h.

This tutorial walks you through a second-level stepped backoff dequeue design.

Live effect:

logo

logo

Prerequisites

Create an empty Laravel 11 project. We will sync order status with the upstream API every few seconds while controlling frequency — not too many requests, and no duplicate requests in a short window.

Setting up the queue job

Create a base job class

First we need a base job that implements polling. Here is a BasePollingJob example with basic polling:

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\MaxAttemptsExceededException;
use Illuminate\Support\Facades\Log;

abstract class BasePollingJob implements ShouldQueue, ShouldBeUnique
{
    use Queueable;

    protected $jobDesc = '任务轮询队列';
    protected $jobPayload;
    const TIMEOUT_SECOND = 60 * 3;

    public function retryUntil()
    {
        return now()->addSeconds(self::TIMEOUT_SECOND);
    }

We define a job description, payload, and retry timeout. retryUntil sets the maximum retry window; if the job is still unfinished past that time, laravel-queue throws MaxAttemptsExceededException. In business logic you can mark timeout in fail().

Schedule the next run

Next, scheduleNextRunning adjusts the next interval by attempt count:

    protected function scheduleNextRunning()
    {
        $attempts = $this->job->attempts();
        if ($attempts <= 5) {
            $this->release(1); // 前5次,每隔1秒执行一次
        } elseif ($attempts <= 10) {
            $this->release(5); // 接下来5次,每隔5秒执行一次
        } elseif ($attempts <= 20) {
            $this->release(10); // 接下来10次,每隔10秒执行一次
        } else {
            $this->release(30); // 超过20次后,每隔30秒执行一次
        }
    }

Based on current attempts, this sets the next delay — short intervals early, then gradually less frequent.

Handle job failure

On failure or timeout, log and run follow-up logic:

    public function failed(\Throwable $exception)
    {
        if ($exception instanceof MaxAttemptsExceededException) {
            Log::info($this->jobDesc . '超时退出执行', [
                'payload' => $this->jobPayload,
                'error' => $exception->getMessage(),
            ]);
            $this->afterMaxAttemptsExceeded();
        } else {
            Log::error($this->jobDesc . '异常失败', [
                'payload' => $this->jobPayload,
                'error' => $exception->getMessage(),
                'file' => $exception->getFile(),
                'line' => $exception->getLine(),
                'trace' => $exception->getTrace()
            ]);
        }
    }

Here we log errors and run whatever follow-up is needed.

Implementing the polling job

Create a concrete job PollingOrderStatusJob to check order status:

namespace App\Jobs;

use App\Enums\OrderStatus;
use App\Models\Order;
use Illuminate\Support\Facades\Log;

class PollingOrderStatusJob extends BasePollingJob
{
    protected $jobDesc = '订单同步队列';
    private Order $order;

    public function __construct(Order $order)
    {
        $this->order = $order;
        $this->jobPayload = $order->toArray();
    }

We define the job description and order object. The constructor stores order data as the payload for later use.

To ensure the same order number is not enqueued twice, use the order number as the unique lock key.

	// 使用订单号来获取唯一锁
    public function uniqueId()
    {
        return $this->order->trade_no;
    }

Job logic

In handle, implement the concrete flow:

    public function handle()
    {
        try {
            Log::info($this->jobDesc . '开始执行', [$this->order->id, $this->order->trade_no]);

            // 前置检测:订单状态如果已是终态,无需操作,退出队列
            if (!$this->checkOrderBeforePolling($this->order)) {
                $this->delete();
                return;
            }

            // 查询订单状态:比如发起一个HTTP请求,获取最新状态
            if (!$this->orderQuery($this->order)) {
                $this->delete();
                return;
            }

            // 调度下次运行的时机
            $this->scheduleNextRunning();
        } catch (\Throwable $e) {
            $this->fail($e);
        }
    }

We log start, then call checkOrderBeforePolling to see if polling should continue. If not, delete the job and return. Then orderQuery fetches status, and finally we schedule the next run.

Pre-check

In checkOrderBeforePolling, if the order is already terminal (timed out or completed), sync is unnecessary. This also guards against upstream business anomalies.

    private function checkOrderBeforePolling(Order $order): bool
    {
        $orderStatus = OrderStatus::from($order->status);
        if (!in_array($orderStatus, [OrderStatus::DEFAULT, OrderStatus::PAYING])) {
            return false;
        }

        return true;
    }

Query order status

orderQuery simulates querying order status:

    private function orderQuery(Order $order)
    {
        // 模拟订单查询请求
        sleep(1);

        // 支付成功返回
//        $result = [
//            'amount' => 100,
//            'payment_no' => 'p123456',
//            'status' => 'SUCCESS'
//        ];

        // 正在支付中返回
        $result = [
            'amount' => null,
            'payment_no' => null,
            'status' => 'PENDING'
        ];

       return $result['status'] == 'PENDING';
    }

This is a simple simulation; production would call an external API.

Handle timeout

    public function afterMaxAttemptsExceeded()
    {
        try {
            $this->order->status = OrderStatus::TIMEOUT->value;
            $this->order->save();
            Log::info($this->jobDesc . '-更新订单状态为超时', [$this->order->trade_no]);
        } catch (\Throwable $e) {
            Log::error($this->jobDesc . '-超时后置逻辑执行异常', [$this->order, $e->getMessage()]);
        }
    }

Wrapping up

Full code — logging, DB bootstrap, step-by-step tutorial — is ready to use and open-sourced here: Github Gitee

Distilled from a project with hundred-million-scale GMV. Open source is hard — a star helps a lot. Your support keeps this going.

Changelog

8/27/26, 2:39 PM
View All Changelog
  • 68955-Translate all 14 blog posts to English for Plume i18n.on
  • b7b2c-启用文章变更历史并升级主题配置。on
  • edd67-chore: 置顶 AI 写真和终端设计文章,隐藏阶梯降频轮询文章on
  • b19ea-重构: 将文章迁移至 blog 目录,更新配置on
  • 5cafa-upon
  • 2969a-upon
  • 64f6a-upon
  • 1963f-upon
  • a10e0-upon
  • 03090-upon