RTB
实时竞价引擎讲透。这是广告技术(AdTech)的核心系统,它和金融交易一样追求极致延迟,但有个独一无二的硬约束:整个竞价必须在
100ms 内完成,超时就没了。 而这 100ms 里还要并发问一堆 DSP(广告主)"你出多少钱",这正是协程并发的完美战场。我先把 RTB
的运作和那个"100ms 死线"讲清楚,再给完整代码。
—
一、先搞懂:RTB 实时竞价到底怎么运作
大白话:你打开一个网页/APP,上面有个广告位。在这个广告展示出来之前的那一瞬间,后台发生了一场"拍卖"——多个广告主竞价,出价最
高的广告被展示。整个过程必须在你感知不到的时间内(通常 100ms)完成。
用户打开网页,有个广告位
│
▼发出竞价请求(Bid Request)
┌──────────────┐
│ ADX广告交易所 │ ←本篇要构建的引擎(SSP/ADX侧)
│ (竞价引擎) │
└──────┬───────┘
│ 100ms死线倒计时开始!
│ 同时群发询价给所有DSP
┌─────┼─────┬─────┐
▼ ▼ ▼ ▼
DSP1 DSP2 DSP3 DSP4 ←各广告主的出价系统
$2.1 $1.8 超时 $2.5 ←各自返回出价(或超时)
└─────┼─────┴─────┘
▼收集所有按时返回的出价
┌──────────────┐
│ 拍卖决策 │ ←选最高价(二价拍卖)
│ DSP4赢,$2.5 │
└──────┬───────┘
▼100ms内返回获胜广告
展示DSP4的广告
核心名词(大白话):
– ADX/SSP:广告交易所/供给方平台,就是本篇要建的"拍卖主持人"。
– DSP:需求方平台,代表广告主出价的系统,本篇要并发去问它们。
– Bid Request:竞价请求,包含用户信息、广告位信息。
– 二价拍卖(Second-Price):赢家付的是"第二高价 + 0.01",不是自己的出价——这是RTB 的标准规则,能让广告主诚实出价。
—
二、这个系统最难的地方:100ms 死线
大白话:这是 RTB 和普通系统最不一样的地方。整个竞价有一个硬性的 100ms
总预算,超过用户就看到别的内容了,这次竞价作废、没有收入。
100ms 要干完这些事:
收到竞价请求 →解析→筛选合格DSP →并发询价→收集出价 →拍卖→返回
~2ms ~3ms ~2ms ~80ms 死线 ~3ms ~2ms
↑最大头,而且要并发问N个DSP
两个关键设计决定成败:
1. 必须并发问所有 DSP,不能串行。 串行问5个DSP,每个20ms就是100ms,直接爆死线。协程并发问,总耗时 =
最慢那个DSP的响应时间,而不是相加。
2. 必须设超时,且超时即弃。 给DSP的时间只有比如80ms,哪个DSP
80ms没回,直接当它弃权,绝不能傻等。"等齐所有DSP"是错的,正确是"到点就截止,收到几个算几个"。 这叫"超时即收割(timeout
harvest)"。
协程在这里的价值无可替代:既要同时问N个DSP(并发),又要在总时间到了就停(超时控制),还要代码清晰。协程 + Channel + 超时
完美解决。
—
三、完整实现
第1步 环境
→第2步 竞价请求/响应数据结构
→第3步 DSP并发询价(核心:超时即收割)
→第4步 DSP筛选(定向匹配,只问相关的)
→第5步 拍卖决策(二价拍卖)
→第6步 竞价引擎主流程(100ms预算编排)
→第7步 HTTP服务 + 全链路超时 + 实测
—
第 1 步:环境
cat >> /etc/php/php.ini <<'EOF'
extension=swoole.so
swoole.enable_iouring = On
swoole.iouring_entries = 16384
swoole.iouring_flag = SWOOLE_IOURING_SQPOLL
EOF
<?php
Swoole\\Runtime::enableCoroutine(SWOOLE_HOOK_ALL); // HTTP调用走协程异步
—
第 2 步:竞价请求/响应数据结构(遵循 OpenRTB 标准)
大白话:RTB 有国际标准协议叫 OpenRTB,规定了竞价请求/响应的格式。我们遵循它,不自创格式,这样能和任何 DSP 对接。
<?php
// OpenRTB 竞价请求/响应结构(简化版,遵循行业标准)
class BidRequest
{
public function __construct(
public string $id, // 竞价请求唯一ID
public array $imp, // 广告位信息(impression)
public array $user, // 用户信息(地域、兴趣、设备)
public array $device, // 设备信息
public float $bidFloor, // 底价(低于这个价不卖)
public int $tmax = 100 // 最大超时ms(死线!),默认100ms
) {}
public static function fromJson(string $json): self
{
$d = json_decode($json, true);
return new self(
$d['id'],
$d['imp'] ?? [],
$d['user'] ?? [],
$d['device'] ?? [],
$d['bidfloor'] ?? 0.0,
$d['tmax'] ?? 100
);
}
}
class BidResponse
{
public function __construct(
public string $requestId,
public string $dspId,
public float $price, // 出价(CPM,千次展示价格)
public string $adMarkup, // 广告创意内容
public string $winNoticeUrl = '' // 竞胜通知地址
) {}
}
—
第 3 步:DSP 并发询价(核心:超时即收割)
大白话:这是整个引擎的心脏。给所有合格DSP同时发询价请求,设一个统一的超时(比如80ms),到点就收割——收到几个出价算几个,没 回
的当弃权。绝不等任何一个慢DSP拖垮全局。
<?php
// DSP并发询价引擎 ——超时即收割,这是RTB的灵魂
use Swoole\\Coroutine;
use Swoole\\Coroutine\\Channel;
use Swoole\\Coroutine\\Http\\Client;
class DspBidder
{
/**
* 并发向所有DSP询价,在deadline内收集出价
* @param array $dsps 合格的DSP列表 [['id'=>,'host'=>,'port'=>,'path'=>], ...]
* @param int $timeoutMs 给DSP的时间预算(必须<总死线,留余量给拍卖)
* @return BidResponse[] 按时返回的出价(超时的自动丢弃)
*/
public function bidAll(BidRequest $request, array $dsps, int $timeoutMs): array
{
// 用一个Channel收集所有DSP的返回结果
$resultCh = new Channel(count($dsps));
// ===== 给每个DSP开一个协程,同时发起询价(并发!) =====
foreach ($dsps as $dsp) {
Coroutine::create(function () use ($request, $dsp, $resultCh, $timeoutMs) {
$bid = $this–>askOneDsp($request, $dsp, $timeoutMs);
$resultCh–>push($bid); // 不管成功失败都push,保证收割逻辑能计数
});
}
// ===== 超时即收割:在deadline内能收几个收几个 =====
$bids = [];
$deadline = hrtime(true) + $timeoutMs * 1_000_000; // 纳秒死线
for ($i = 0; $i < count($dsps); $i++) {
// 计算还剩多少时间(动态超时):总死线减去已用时间
$remainingNs = $deadline – hrtime(true);
if ($remainingNs <= 0) break; // 到点了,不再等,立即收割已有的
$remainingSec = $remainingNs / 1e9;
// 最多再等剩余时间;超时返回false
$bid = $resultCh–>pop($remainingSec);
if ($bid === false) {
break; // 等不到了(剩余时间用完),收割结束
}
if ($bid !== null) {
$bids[] = $bid; // 有效出价收入囊中
}
// null表示该DSP超时/出错,跳过继续收下一个
}
return $bids; // 返回所有按时到达的有效出价
}
/**
* 向单个DSP发询价请求
* @return BidResponse|null 出价,或null(超时/拒绝/出错)
*/
private function askOneDsp(BidRequest $request, array $dsp, int $timeoutMs): ?BidResponse
{
$client = new Client($dsp['host'], $dsp['port']);
// 关键:给HTTP客户端设超时,绝不让单个DSP拖死
$client–>set([
'timeout' => $timeoutMs / 1000, // 秒
'connect_timeout' => 0.02, // 连接超时20ms
'keep_alive' => true, // 复用连接,省握手时间
]);
$client–>setHeaders([
'Content-Type' => 'application/json',
'x-openrtb-version' => '2.5',
]);
// 发竞价请求给DSP
$payload = json_encode([
'id' => $request–>id,
'imp' => $request–>imp,
'user' => $request–>user,
'device' => $request–>device,
'bidfloor' => $request–>bidFloor,
'tmax' => $timeoutMs,
]);
$ok = $client–>post($dsp['path'], $payload);
// DSP没按时返回 / 返回非200 / 无内容(no-bid)
if (!$ok || $client–>statusCode !== 200 || empty($client–>body)) {
$client–>close();
return null;
}
$data = json_decode($client–>body, true);
$client–>close();
// DSP明确不出价
if (empty($data['seatbid'][0]['bid'][0])) {
return null;
}
$bid = $data['seatbid'][0]['bid'][0];
// 出价低于底价,无效
if (($bid['price'] ?? 0) < $request–>bidFloor) {
return null;
}
return new BidResponse(
$request–>id,
$dsp['id'],
(float)$bid['price'],
$bid['adm'] ?? '',
$bid['nurl'] ?? ''
);
}
}
大白话把"超时即收割"再讲一遍(这是全篇最重要的逻辑):
– 5个DSP,我同时问。设80ms死线。
– 我不是"等5个都回来"——那万一第5个慢,就爆死线了。
– 我是"盯着时钟收割":80ms内回来几个我收几个。比如60ms时收到了3个,第70ms又收到1个,到80ms第5个还没来——我直接拿这4个去 拍
卖,第5个当它弃权。
– $resultCh–>pop($remainingSec) 这行是精髓:每次只等"剩余的时间",时间一到立即停止。这保证总耗时绝不超过死线。
—–
第 4 步:DSP 筛选(定向匹配,只问相关的)
大白话:不是每次都问所有DSP。要先筛选——这个广告位、这个用户,哪些DSP可能感兴趣就问哪些。比如卖母婴产品的DSP,没必要问它一
个游戏广告位。筛选能减少询价数量、降低延迟、省钱。
<?php
// DSP筛选 ——定向匹配,只问相关的DSP(减少无效询价)
class DspSelector
{
// DSP配置:每个DSP的定向条件
private array $dsps = [
['id' => 'dsp_a', 'host' => '10.0.0.1', 'port' => 8001, 'path' => '/bid',
'geo' => ['北京','上海'], 'categories' => ['母婴','教育'], 'qps_limit' => 10000],
['id' => 'dsp_b', 'host' => '10.0.0.2', 'port' => 8002, 'path' => '/bid',
'geo' => ['全国'], 'categories' => ['游戏','娱乐'], 'qps_limit' => 5000],
['id' => 'dsp_c', 'host' => '10.0.0.3', 'port' => 8003, 'path' => '/bid',
'geo' => ['全国'], 'categories' => ['母婴','电商'], 'qps_limit' => 8000],
];
/**
* 筛选出对这次请求可能感兴趣的DSP
*/
public function select(BidRequest $request): array
{
$userGeo = $request–>user['geo'] ?? '全国';
$adCat = $request–>imp[0]['category'] ?? '';
$matched = [];
foreach ($this–>dsps as $dsp) {
// 1. 地域匹配:DSP覆盖全国 或 包含用户所在地
$geoMatch = in_array('全国', $dsp['geo']) || in_array($userGeo, $dsp['geo']);
// 2. 类目匹配:广告位类目在DSP的兴趣类目里
$catMatch = empty($adCat) || in_array($adCat, $dsp['categories']);
// 3. 限流检查:DSP没超QPS(防止把DSP打爆,也是合作约定)
$qpsOk = $this–>checkQps($dsp['id'], $dsp['qps_limit']);
if ($geoMatch && $catMatch && $qpsOk) {
$matched[] = $dsp;
}
}
return $matched;
}
/** QPS限流检查(简化:实际用Redis滑动窗口) */
private function checkQps(string $dspId, int $limit): bool
{
// 用Swoole\\Atomic或Redis做每秒计数,这里简化为通过
return true;
}
}
—–
第 5 步:拍卖决策(二价拍卖)
大白话:收到所有出价后,选赢家。RTB 标准用"二价拍卖"——出价最高的赢,但实际付的是"第二高价+
0.01"。这能让广告主放心按真实价值出价,不用猜别人出多少。
<?php
// 拍卖引擎 ——二价拍卖(Second–Price Auction)
class AuctionEngine
{
/**
* 从所有出价中决出赢家
* @param BidResponse[] $bids
* @return array|null 赢家信息(含实际结算价),无有效出价返回null
*/
public function runAuction(array $bids, float $bidFloor): ?array
{
if (empty($bids)) {
return null; // 无人出价,流拍
}
// 按出价从高到低排序
usort($bids, fn($a, $b) => $b–>price <=> $a–>price);
$winner = $bids[0]; // 最高价者赢
// ===== 二价拍卖:实际结算价 = 第二高价 + 0.01,但不低于底价 =====
$secondPrice = isset($bids[1]) ? $bids[1]–>price : $bidFloor;
$clearingPrice = max($secondPrice + 0.01, $bidFloor);
// 结算价不能超过赢家自己的出价
$clearingPrice = min($clearingPrice, $winner–>price);
return [
'win_dsp' => $winner–>dspId,
'bid_price' => $winner–>price, // 赢家出价
'clearing_price' => round($clearingPrice, 2), // 实际付的价(二价)
'ad_markup' => $winner–>adMarkup, // 获胜广告创意
'win_notice' => $winner–>winNoticeUrl, // 竞胜通知地址
'total_bids' => count($bids), // 共几个DSP出价
];
}
}
—
第 6 步:竞价引擎主流程(100ms 预算编排)
大白话:把所有模块缝起来,严格按 100ms 预算分配时间:解析→筛选→并发询价(给80ms)→拍卖→返回。全程盯着总预算,绝不超时。
<?php
// RTB竞价引擎主流程 ——100ms预算严格编排
use Swoole\\Coroutine;
class RtbEngine
{
public function __construct(
private DspSelector $selector,
private DspBidder $bidder,
private AuctionEngine $auction,
private BidLog $log
) {}
/**
* 处理一次竞价(必须在 request->tmax 内完成)
*/
public function handleBid(BidRequest $request): array
{
$startNs = hrtime(true);
$totalBudgetMs = $request–>tmax; // 总死线,默认100ms
// ===== 1. 筛选合格DSP(~2ms) =====
$dsps = $this–>selector->select($request);
if (empty($dsps)) {
return $this–>noBid($request, '无匹配DSP');
}
// ===== 2. 计算给DSP询价的时间预算 =====
// 总预算 – 已用时间 – 预留给拍卖和返回的时间(15ms)
$usedMs = (hrtime(true) – $startNs) / 1e6;
$dspBudgetMs = (int)($totalBudgetMs – $usedMs – 15);
if ($dspBudgetMs < 20) {
return $this–>noBid($request, '时间预算不足'); // 时间不够了,放弃
}
// ===== 3. 并发询价所有DSP,超时即收割(最大头,~80ms) =====
$bids = $this–>bidder->bidAll($request, $dsps, $dspBudgetMs);
// ===== 4. 拍卖决策(~2ms) =====
$result = $this–>auction->runAuction($bids, $request–>bidFloor);
if ($result === null) {
return $this–>noBid($request, '无有效出价');
}
// ===== 5. 记录竞价日志(异步,不占用响应时间) =====
$totalMs = (hrtime(true) – $startNs) / 1e6;
$result['elapsed_ms'] = round($totalMs, 2);
$result['dsp_asked'] = count($dsps);
Coroutine::create(fn() => $this–>log->record($request, $result));
// ===== 6. 返回获胜广告(OpenRTB响应格式) =====
return [
'id' => $request–>id,
'bidid' => uniqid('bid_'),
'seatbid' => [[
'bid' => [[
'price' => $result['clearing_price'],
'adm' => $result['ad_markup'],
'dsp' => $result['win_dsp'],
]],
]],
'cur' => 'CNY',
'elapsed_ms' => $result['elapsed_ms'],
];
}
private function noBid(BidRequest $request, string $reason): array
{
return ['id' => $request–>id, 'nbr' => $reason]; // no-bid响应
}
}
竞价日志:
<?php
// 竞价日志 ——io_uring异步落盘(计费、对账、分析用)
class BidLog
{
private $fp;
public function __construct(string $path)
{
$this–>fp = fopen($path, 'a');
}
public function record(BidRequest $request, array $result): void
{
$entry = [
'request_id' => $request–>id,
'win_dsp' => $result['win_dsp'],
'clearing_price' => $result['clearing_price'],
'total_bids' => $result['total_bids'],
'dsp_asked' => $result['dsp_asked'],
'elapsed_ms' => $result['elapsed_ms'],
'ts' => hrtime(true),
];
fwrite($this–>fp, json_encode($entry, JSON_UNESCAPED_UNICODE) . "\\n");
// 计费数据重要,异步落盘(io_uring)
}
}
—
第 7 步:HTTP 服务 + 全链路超时 + 实测
<?php
// RTB竞价服务 + 实测
use Swoole\\Http\\Server;
use Swoole\\Coroutine;
// ===== 启动竞价服务 =====
$server = new Server('0.0.0.0', 9501);
$server–>set([
'worker_num' => 16,
'open_tcp_nodelay' => true, // 低延迟必开
'open_cpu_affinity' => true,
'max_coroutine' => 100000, // 支持高并发竞价
]);
$engine = new RtbEngine(
new DspSelector(),
new DspBidder(),
new AuctionEngine(),
new BidLog('/data/rtb/bid.log')
);
$server–>on('request', function ($req, $resp) use ($engine) {
// 每个竞价请求在独立协程处理
$request = BidRequest::fromJson($req–>rawContent());
$result = $engine–>handleBid($request);
$resp–>header('Content-Type', 'application/json');
$resp–>header('x-openrtb-version', '2.5');
$resp–>end(json_encode($result, JSON_UNESCAPED_UNICODE));
});
// $server–>start();
// ===== 模拟实测:验证100ms内完成 + 并发威力 =====
Coroutine\\run(function () use ($engine) {
$request = BidRequest::fromJson(json_encode([
'id' => 'req-001',
'imp' => [['id' => '1', 'category' => '母婴', 'banner' => ['w' => 300, 'h' => 250]]],
'user' => ['geo' => '北京', 'interests' => ['育儿']],
'device' => ['type' => 'mobile', 'os' => 'android'],
'bidfloor' => 1.0,
'tmax' => 100,
]));
// 跑100次竞价测延迟分布
$latencies = [];
for ($i = 0; $i < 100; $i++) {
$t0 = hrtime(true);
$result = $engine–>handleBid($request);
$latencies[] = (hrtime(true) – $t0) / 1e6; // ms
}
sort($latencies);
$n = count($latencies);
echo "=== RTB竞价延迟实测(必须<100ms) ===\\n";
printf("p50 : %.1f ms\\n", $latencies[(int)($n*0.5)]);
printf("p99 : %.1f ms\\n", $latencies[(int)($n*0.99)]);
printf("max : %.1f ms\\n", $latencies[$n-1]);
printf("是否全部<100ms:%s\\n", $latencies[$n-1] < 100 ? "✓ 达标" : "✗ 超时!");
});
—
四、整个引擎的逻辑链(一图背下)
竞价请求进来(100ms死线倒计时开始)
│ ~2ms
解析请求(OpenRTB)
│ ~2ms
筛选合格DSP(定向匹配,只问相关的)
│
┌───────────┼───────────────┐ 给80ms预算
▼ ▼ ▼
协程问DSP1 协程问DSP2 ... 协程问DSPn (全部并发!)
$2.1/15ms $1.8/22ms 超时/弃权
└───────────┼───────────────┘
▼超时即收割:到点收几个算几个
收集按时返回的出价
│ ~2ms
二价拍卖(最高价赢,付第二价+0.01)
│
io_uring异步落竞价日志(计费/对账)
▼~2ms
返回获胜广告(全程<100ms)
三句话讲透:
1. 100ms 死线是铁律:全程盯着时间预算,给 DSP 留 80ms,给拍卖和返回留余量,绝不超时。
2. 并发问 DSP + 超时即收割:协程同时问所有 DSP,到点就收割已返回的,慢 DSP 当弃权——这是RTB 的灵魂,串行必死。
3. 二价拍卖是标准规则:最高价赢但付第二价,让广告主诚实出价。
—
五、避坑 Top 10
1. 串行问 DSP →5个DSP各20ms就爆100ms死线。必须协程并发,总耗时=最慢一个。
2. 等齐所有 DSP 才拍卖 →一个慢DSP拖垮全局。必须"超时即收割",到点就停。
3. 不给 HTTP 客户端设超时 →单个DSP卡死整次竞价。connect/总超时都要设。
4. 时间预算不留余量 →询价用满100ms,拍卖和返回没时间了还是超时。预留15ms。
5. 不筛选DSP全量问 →无效询价多、延迟高、还可能把DSP打爆。先定向匹配筛选。
6. 不做QPS限流 →把DSP打爆,违反合作约定还被拉黑。每个DSP限流。
7. 用一价拍卖 →广告主会博弈压价、出价不稳定。用二价拍卖让其诚实出价。
8. 同步落计费日志 →占用宝贵的响应时间。日志异步落盘(io_uring)。
9. 不复用DSP连接 →每次重新握手浪费几十ms。keep_alive 复用连接。
10. 只看平均延迟 →RTB看p99,偶尔超时就是真金白银损失。盯p99/p999和超时率。
—
六、最优工具/库清单(全成熟,不自研)
┌────────────────┬─────────────────────────────────────┬──────────────────────────────┐
│ 任务 │ 最优选择 │ 理由 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 协程并发 │ Swoole 6.2 协程 │ 同时问N个DSP,总耗时=最慢一个 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 异步HTTP客户端 │ Swoole\\Coroutine\\Http\\Client │ 协程异步、可设超时、连接复用 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 超时收割 │ Swoole\\Coroutine\\Channel(带超时pop) │ "到点就停"的核心机制 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 协议标准 │ OpenRTB 2.5/3.0 │ 行业标准,对接任何DSP不自创 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 拍卖规则 │ 二价拍卖(Second-Price) │ 让广告主诚实出价的标准机制 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 异步落盘 │ io_uring(SWOOLE_HOOK_FILE) │ 计费日志不占响应时间 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ QPS限流 │ Swoole\\Atomic / Redis滑动窗口 │ 保护DSP不被打爆 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 低延迟网络 │ open_tcp_nodelay + busy_poll │ 压低网络延迟 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 时间测量 │ hrtime(true) │ 纳秒级死线控制 │
├────────────────┼─────────────────────────────────────┼──────────────────────────────┤
│ 连接池(DSP) │ 长连接 keep_alive │ 省握手时间 │
└────────────────┴─────────────────────────────────────┴──────────────────────────────┘
—
七、生产增强建议
这套已能跑通 RTB 核心。生产落地可再加:
– 频次控制(Frequency Cap):同一用户同一广告别反复展示,用 Redis 记录展示次数。
– 预算控制(Pacing):广告主每天预算有限,均匀消耗别一下花光,实时扣减预算。
– 竞胜/计费回调:赢了之后异步打竞胜通知(win notice),处理展示/点击回传做计费对账。
– DSP 健康熔断:某DSP持续超时/报错,自动熔断一段时间不问它,恢复后再放量。
– 超时预算自适应:根据各DSP历史响应时间动态调整给它的超时,快的多等、慢的少等。
—
需要我继续往下钻的话,告诉我:
– 要不要 DSP 熔断 + 自适应超时的完整实现?根据历史响应动态决定问哪些DSP、给多少超时,带熔断器代码。
– 要不要预算控制 + 频次控制?用 Redis 做实时预算扣减和用户频次,我给完整代码。
– 要不要竞胜通知 + 计费对账全链路?从竞价到展示到计费的完整闭环。
把方向告诉我,我直接给生产级的完整代码。



