简介:这份广告网站源码面向希望快速搭建在线广告展示与管理平台的个人站长、小型广告运营团队及ASP初学者,提供一套可直接部署并二次开发的基础系统。它包含前端广告展示界面与后台管理模块,可用于横幅、文字、弹窗等广告位的发布、计划设定与效果统计,后台操作路径相对直观,适合缺少专业编程经验的使用者维护日常广告业务。压缩包共927个文件,约10.43MB,以615个gif与95个jpg图片素材、178个asp动态页面为主体,辅以css样式、js脚本、swf动画及db、mdb数据库文件,覆盖页面外观、业务逻辑与数据存储等组成部分。已有2205人学习下载。源码基于ASP升级版,包含格子广告相关模块,读者可据此了解广告位管理、广告计划与后台管理的基本实现,并依据现有代码调整界面与功能,减少从零搭建广告平台的时间,适合作为广告系统原型或课程实践参考。
1. 广告站源码不是套个 banner 换个图,它的价值在「能算账」
做过投放页的都清楚,把一张图片链接塞进模板里花不了十分钟,真正难的是广告主第二天找上门问「昨天我那三百次点击到底去哪了」。这套广告网站源码解决的正是后半句:广告位怎么定义、同一个位置该给谁展示、点击怎么计数、钱怎么扣、账怎么对。它适合手里有中小流量站、想把站内广告位租出去的站长,也适合广告代理拿去做投放落地页,更适合想完整摸一遍「投放—统计—结算」链路的开发者拿来拆。下面从目录结构开始,一路讲到投放引擎、点击计费和二次开发,每段都落到能抄的代码上。
2. 目录结构与技术选型:一个 public 入口怎么撑起前后台
这套站没有用重量级框架,选的是原生 PHP 7.4 + PDO + 自建轻路由,前端 Bootstrap 5 配原生 JS,数据库 MySQL 5.7,Web 服务 Nginx 加 PHP-FPM。这个组合在广告站这个场景里其实是最务实的:广告位接口的 QPS 要求不高但响应要快,后台表单多但业务逻辑不复杂,上框架反而多一层编译和调试成本,出了问题还得先怀疑框架。原生写法的代价是所有约定都得自己定,所以目录划分必须先立住。
2.1 目录树逐层看:哪些能公开,哪些必须挡在 web 根之外
adsite/ ├── public/ # 唯一 web 根目录,只有这里能被外网访问 │ ├── index.php # 单入口,所有请求都从这里进 │ ├── static/ │ │ ├── css/ js/ │ │ └── ad-slot.js # 广告位渲染脚本,嵌入到投放方的页面里 │ └── uploads/ # 广告素材,按年月分目录 ├── app/ │ ├── Core/ # Router / Db / Cache / View / App │ ├── Controller/ │ │ ├── SlotController.php # 对外:按 slot_key 返回广告 JSON │ │ ├── ClickController.php # 对外:点击跳转与计费 │ │ ├── ApplyController.php # 对外:广告主投放申请 │ │ └── Admin/ # 后台:登录、广告审核、报表 │ ├── Service/ │ │ ├── AdSelector.php # 投放引擎 │ │ ├── Billing.php # 扣费 │ │ ├── AntiFraud.php # 防刷 │ │ └── UrlGuard.php # 跳转地址白名单 │ ├── Model/ │ └── View/ ├── config/ │ ├── app.php # 站点配置、点击密钥、缓存驱动 │ ├── database.php # 数据库连接 │ └── routes.php # 路由表 ├── runtime/ # 日志、文件缓存,必须可写但不可访问 ├── sql/adsite.sql # 建库脚本,含演示数据 └── cron/stat_aggregate.php # 日报表聚合,crontab 每天跑这个结构最关键的一条线是public/和其他目录的隔离。app/、config/、runtime/、sql/全部在 web 根之外,Nginx 的root只指向public,这样即使哪天路由写漏了一个入口,源码和配置文件也不会被直接下载走。我见过不止一个站把config/database.php放在能被访问的目录下,扫目录的脚本一抓一个准,血泪经验。runtime/需要 PHP-FPM 用户可写,但同样不能落在 web 根里。
app/Core里五个类撑起整个骨架:App负责引导,Router做正则匹配,Db是 PDO 单例,Cache抽象文件缓存和 Redis 两种驱动,View做模板渲染。没有容器、没有依赖注入,控制器里需要什么就 new 什么,对这套体量来说够用且好排查。真要挑毛病,就是单元测试不好写,但对一个以页面和接口为主的广告站,这笔账划得来。
2.2 环境与配置:PHP 7.4、MySQL 5.7、Nginx 的最小可用组合
跑起来之前先确认三件事:PHP 版本不低于 7.4(代码里用了类型声明和random_int),装了pdo_mysql、fileinfo、mbstring三个扩展,MySQL 的默认字符集是utf8mb4。少了fileinfo,后面的素材上传校验会直接报错,这个坑很隐蔽,因为它是运行时才触发,装环境时完全看不出来。
# 1. 建库并导入结构和演示数据 mysql -uroot -p -e "CREATE DATABASE adsite DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci;" mysql -uroot -p adsite < sql/adsite.sql # 2. 复制配置,改数据库账号和点击密钥 cp config/database.example.php config/database.php cp config/app.example.php config/app.php # 3. 本地开发:直接用内置服务器,前端静态资源会被自动处理 php -S 0.0.0.0:8080 -t public # 4. 验证:访问广告位接口,应该返回一条广告 JSON curl -s "http://127.0.0.1:8080/slot/home_top" | head -c 300config/app.php里有几个参数是必须改的,改错会直接影响计费和统计:
| 配置项 | 默认值 | 作用与改法 |
|---|---|---|
click_secret | 空 | 点击令牌签名密钥,必须换成 32 位以上随机串,泄露等于允许别人伪造点击 |
cache_driver | file | 单机用 file,多机部署必须换 redis,否则各机器缓存不一致 |
slot_cache_ttl | 30 | 广告位候选集缓存秒数,直接影响预算超投的幅度 |
click_token_ttl | 600 | 点击令牌有效期,用户开着一堆标签页隔很久才点会失效 |
生产环境用 Nginx,配置文件里有两段不能省:try_files把不存在的路径交给入口,以及上传目录禁止执行 PHP。第二段不是可选项,后面第 5 章会专门讲为什么。
server { listen 80; server_name ad.local; root /data/www/adsite/public; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { fastcgi_pass unix:/run/php/php7.4-fpm.sock; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; } # 上传目录只允许静态访问,任何 PHP 一律拒绝 location ^~ /uploads/ { location ~ \.php$ { deny all; } } }try_files那一行决定了路由能不能拿到原始 URI,PATH_INFO那一行决定了自建路由能不能解析出/click/abc123里的abc123。少任何一行,本地php -S跑得好好的,一上 Nginx 就整站 404,这个现象特别有迷惑性。
2.3 入口、路由与后台登录态:三段代码把请求接住
入口要干的事就三件:定义根路径、注册自动加载、把配置丢给 App。写得很薄,是因为所有业务都在控制器里,入口越薄越好排查。
<?php // public/index.php declare(strict_types=1); define('BASE_PATH', dirname(__DIR__)); require BASE_PATH . '/app/Core/Autoloader.php'; \App\Core\Autoloader::register(); // 按命名空间映射到 app/ 下 // 时区必须在这里定死,后面所有 NOW() 和 date() 都依赖它 date_default_timezone_set('Asia/Shanghai'); $config = require BASE_PATH . '/config/app.php'; $routes = require BASE_PATH . '/config/routes.php'; \App\Core\App::run($config, $routes);date_default_timezone_set这一行看着不起眼,但它决定了日志时间、令牌过期、日报表边界三件事。PHP 默认走 UTC,MySQL 如果也是 UTC 那还能自洽,可大多数机器的 MySQL 装完就是系统时区,两边一错位,凌晨那八小时的点击就会被算进前一天,账永远对不上。
路由表用最朴素的「方法 + 正则 + 回调」结构,不搞注解不搞缓存,四十来条路由匹配一次的开销在微秒级。
<?php // config/routes.php return [ // 对外接口 ['GET', '#^/slot/([a-z0-9_]+)$#', [\App\Controller\SlotController::class, 'show']], ['GET', '#^/click/([a-f0-9]{32})$#', [\App\Controller\ClickController::class, 'go']], ['POST', '#^/apply$#', [\App\Controller\ApplyController::class, 'submit']], // 后台 ['GET', '#^/admin/login$#', [\App\Controller\Admin\LoginController::class, 'form']], ['POST', '#^/admin/login$#', [\App\Controller\Admin\LoginController::class, 'doLogin']], ['GET', '#^/admin/ad/list$#', [\App\Controller\Admin\AdController::class, 'list']], ['POST', '#^/admin/ad/audit$#', [\App\Controller\Admin\AdController::class, 'audit']], ['GET', '#^/admin/report/daily$#', [\App\Controller\Admin\ReportController::class, 'daily']], ];正则里([a-f0-9]{32})这个约束是有意收窄的:点击 ID 只可能是 32 位十六进制,别的字符根本进不来,等于在路由层先挡掉一批扫描请求。匹配到的捕获组按顺序传给控制器方法,控制器签名写成go(string $clickId)就能直接接住。
后台的登录态用最传统的 session,没有 JWT 也没有 OAuth,因为这个后台只有内部几个人用,session 足够且更好控制。关键是把鉴权做成一个统一的前置检查,别在每个控制器里各写一遍。
<?php // app/Core/Auth.php namespace App\Core; class Auth { public static function check(): bool { if (empty($_SESSION['admin_id'])) { return false; } // 闲置 2 小时自动失效,避免公用电脑上忘记退出 if (time() - ($_SESSION['last_active'] ?? 0) > 7200) { session_destroy(); return false; } $_SESSION['last_active'] = time(); return true; } // 后台控制器构造函数里第一句调用,未登录直接跳登录页 public static function requireLogin(): void { if (!self::check()) { header('Location: /admin/login'); exit; } } }last_active这个字段很多人会漏。漏了之后 session 一直有效,直到 GC 回收,而 PHP 默认的 session GC 概率是 1/1000,等于在某些机器上可能几周都不清一次。后台账号被盗最常见的原因不是什么高级攻击,就是公用机器上没退出的登录态。
3. 投放引擎拆解:一次广告位请求怎么在 200 条候选里选中一条
投放引擎是这个项目里唯一算得上「有算法」的地方,也是二次开发时改动最频繁的模块。它的职责很单一:给定一个广告位,从所有符合条件的广告里挑一条返回。但「符合条件」这四个字背后有四道闸——状态、时间窗、预算、频次,任何一道没写对,要么广告主花了钱看不到曝光,要么站长的预算被瞬间跑光。
3.1 数据模型:ad_slot 与 ad 之间的三道准入闸
广告位是投放方在页面上占的一块地方,用slot_key对外标识,前端 JS 只认这个 key,不暴露自增 ID。广告是挂在广告位下的素材加规则,一个广告位可以挂多条,靠权重决定曝光比例。
CREATE TABLE `ad_slot` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `slot_key` varchar(64) NOT NULL COMMENT '对外唯一标识,如 home_top', `name` varchar(64) NOT NULL COMMENT '广告位名称', `width` smallint unsigned NOT NULL DEFAULT 0, `height` smallint unsigned NOT NULL DEFAULT 0, `status` tinyint NOT NULL DEFAULT 1 COMMENT '1 启用 0 停用', PRIMARY KEY (`id`), UNIQUE KEY `uk_slot_key` (`slot_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `ad` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `slot_id` int unsigned NOT NULL, `title` varchar(100) NOT NULL, `image` varchar(255) NOT NULL DEFAULT '', `target_url` varchar(512) NOT NULL COMMENT '落地页,必须过白名单', `weight` smallint unsigned NOT NULL DEFAULT 10 COMMENT '权重,越大曝光越多', `price_click` decimal(10,4) NOT NULL DEFAULT 0.0000 COMMENT '单次点击扣费', `budget` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '总预算,0 表示不限', `spent` decimal(12,2) NOT NULL DEFAULT 0.00 COMMENT '已消耗', `start_at` datetime NOT NULL, `end_at` datetime NOT NULL, `status` tinyint NOT NULL DEFAULT 0 COMMENT '0 待审 1 投放中 2 暂停 3 拒绝', PRIMARY KEY (`id`), KEY `idx_slot_status` (`slot_id`,`status`,`end_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三道闸分别是:status = 1保证只有审核通过的广告能投,start_at <= NOW() <= end_at保证在投放期内,budget = 0 OR spent < budget保证预算没跑完。这三个条件写在同一条 SQL 的WHERE里,让 MySQL 一次筛完,比在 PHP 里循环过滤快一个量级,因为候选集被索引缩小之后回表的数据量很小。
索引idx_slot_status的字段顺序有讲究:slot_id放第一位是因为查询永远带这个条件,选择性最高;status第二位;end_at放最后是因为它只用于范围过滤,放在中间会让索引失效。这是个很典型的联合索引顺序问题,写反了不报错,只是慢,慢到 QPS 上不去才会被发现。
weight默认 10 而不是 1,是留给「加权重」用的。运营想让某条广告多跑一点,改成 30 就是三倍曝光,不用改代码。如果默认设成 1,运营改起来没有量级感,容易一次调到几百把别的广告饿死。
3.2 AdSelector:带权重的随机怎么写得不偏也不慢
选广告的逻辑分两步:先按条件把候选集捞出来并缓存,再在候选集里按权重随机。第二步是整个引擎的核心,写法有讲究。
<?php // app/Service/AdSelector.php namespace App\Service; use App\Core\Db; use App\Core\Cache; class AdSelector { /** 广告位选广告主入口:候选集走缓存,随机每次现算 */ public function pick(int $slotId): ?array { $cacheKey = 'slot:ads:' . $slotId; $candidates = Cache::get($cacheKey); if ($candidates === null) { $candidates = $this->loadCandidates($slotId); // 空数组也要缓存,否则没广告的位子会被反复打库 Cache::set($cacheKey, $candidates, 30); } if (!$candidates) { return null; } return $this->weightedRandom($candidates); } /** 四道准入闸:状态、时间窗、预算,频次控制在点击侧做 */ private function loadCandidates(int $slotId): array { $sql = 'SELECT id, slot_id, title, image, target_url, weight, price_click FROM ad WHERE slot_id = :sid AND status = 1 AND start_at <= NOW() AND end_at >= NOW() AND (budget = 0 OR spent < budget) ORDER BY id DESC LIMIT 200'; return Db::conn()->select($sql, [':sid' => $slotId]); } /** 加权随机:按累计权重落区间,O(n) 且不会因取整产生偏差 */ private function weightedRandom(array $ads): array { $total = 0; foreach ($ads as $ad) { $total += max(1, (int)$ad['weight']); } $hit = random_int(1, $total); // 用 random_int 而不是 mt_rand $cursor = 0; foreach ($ads as $ad) { $cursor += max(1, (int)$ad['weight']); if ($hit <= $cursor) { return $ad; } } return $ads[0]; // 理论上到不了,兜底防浮点意外 } }weightedRandom用的是累加区间法:把所有权重加起来得到total,随机一个1..total的整数,再从前往后累加,第一次超过随机值的那条就是结果。这样每条广告被选中的概率精确等于weight / total,没有取整误差。另一种常见写法是「生成一个随机浮点数乘以总权重」,那个在 PHP 里会因为浮点精度在最边界的情况下落到数组外,虽然概率极低,但线上跑一个月总会遇到一次,然后就变成偶发的空白广告位,查起来是玄学。
max(1, (int)$ad['weight'])这个处理防的是运营把权重改成 0。权重为 0 理论上意味着永不曝光,但运营的本意往往是「暂停一下」,直接置 0 会让人困惑为什么广告消失了,兜底成 1 更符合直觉。真要停就用status = 2,语义清楚。
random_int和mt_rand的区别在这里不算安全需求,而是可预测性:mt_rand的种子如果不主动播种,同一台机器重启后的序列是固定的,对做 AB 测试或者排查「为什么总是同一条广告」会有干扰。random_int走的是系统熵源,慢一点点但没这个烦恼。
LIMIT 200是个经验值。一个广告位下挂超过 200 条广告的情况极少,真到那个量级,说明这个位子太抢手,应该拆成多个位子分区售卖,而不是让引擎在上面做一次 200 次的循环。200 次循环在 PHP 里大概几十微秒,可以忽略;但如果没这个上限,某天后台误操作批量导入几千条广告,接口响应时间会直接从 5ms 涨到 200ms。
3.3 缓存与降级:30 秒 TTL 换来的吞吐和代价
缓存键是slot:ads:{slotId},TTL 30 秒,缓存的是候选集而不是最终选中的那条。这个设计决策很关键:缓存候选集,随机逻辑每次现算,曝光分布才是均匀的;如果缓存最终结果,30 秒内所有用户看到的是同一条广告,权重就失效了。
30 秒这个值是个权衡。调大,站点 QPS 上去了,但预算控制会变粗——假设一条广告预算只剩 1 块钱、单次点击 0.1 元,缓存没刷新之前它还会被选中,等缓存过期时可能已经多跑了十几次曝光,虽然点击扣费时还有一层预算检查兜底,但曝光是白给的。调小,缓存命中率断崖下跌,一个日 PV 十万的站,TTL 从 30 秒降到 5 秒,数据库查询次数会从每天几千涨到几万。
<?php // app/Core/Cache.php —— 只摘文件驱动的核心部分 namespace App\Core; class Cache { private static string $dir; public static function init(string $dir): void { self::$dir = rtrim($dir, '/') . '/cache'; if (!is_dir(self::$dir)) { mkdir(self::$dir, 0775, true); } } public static function get(string $key) { $file = self::path($key); if (!is_file($file)) { return null; } $raw = @file_get_contents($file); if ($raw === false) { return null; } $data = @unserialize($raw, ['allowed_classes' => false]); if (!is_array($data) || $data['expire'] < time()) { @unlink($file); return null; } return $data['value']; } public static function set(string $key, $value, int $ttl): void { $payload = serialize(['expire' => time() + $ttl, 'value' => $value]); // 先写临时文件再 rename,避免读到写了一半的内容 $tmp = self::path($key) . '.' . getmypid() . '.tmp'; file_put_contents($tmp, $payload, LOCK_EX); rename($tmp, self::path($key)); } private static function path(string $key): string { return self::$dir . '/' . md5($key) . '.cache'; } }文件缓存这段有两个细节值得说。unserialize加了allowed_classes => false,防止缓存文件被篡改后反序列化出对象触发魔术方法,这是个很实在的加固。先写临时文件再rename是原子操作,避免并发下读到半截 JSON 或者空字符串——没做这一步的话,高峰期偶尔会出现「广告位返回空白」,日志里什么都看不到,属于典型的黑匣子问题。
多机部署时文件缓存必须换成 Redis,配置里cache_driver改成redis即可,Cache类内部按驱动分发。不换的后果不是报错,而是各台机器缓存内容不一致,用户刷新几次看到不同广告,测试同学会提「广告位闪烁」的 bug,开发查半天以为是前端问题。
降级策略写在SlotController里:pick()返回 null 时,返回一个静态占位图而不是 404 或者空白。这个占位图放在public/static/img/ad-placeholder.png,固定尺寸。广告位空着会在页面上留一个塌陷的洞,影响投放方页面的观感,有了兜底图至少布局是完整的。
<?php // app/Controller/SlotController.php 节选 public function show(string $slotKey): void { $slot = Db::conn()->first( 'SELECT id, width, height FROM ad_slot WHERE slot_key = ? AND status = 1', [$slotKey] ); header('Content-Type: application/json; charset=utf-8'); // 广告位可以被第三方页面嵌入,跨域放开到只读接口 header('Access-Control-Allow-Origin: *'); header('Cache-Control: no-store'); // 由服务端缓存控制,不让浏览器缓存 if (!$slot) { echo json_encode(['code' => 404, 'msg' => 'slot not found']); return; } $ad = (new AdSelector())->pick((int)$slot['id']); if (!$ad) { echo json_encode([ 'code' => 0, 'data' => ['placeholder' => '/static/img/ad-placeholder.png', 'w' => $slot['width'], 'h' => $slot['height']], ]); return; } echo json_encode(['code' => 0, 'data' => $this->buildPayload($ad, (int)$slot['id'])]); }Cache-Control: no-store是必须的。不写的话浏览器会缓存这个 JSON,用户切到别的页面再回来还是那条老广告,预算控制就彻底失效了。服务端那 30 秒已经是我们能接受的延迟上限,浏览器再叠一层就不可控了。
4. 点击链路与计费:从 /click 到落地页的那一次 302
投放引擎决定「给谁看」,点击链路决定「怎么算钱」。这一段是整个项目里最容易出事故的部分,因为它同时踩在三个敏感点上:并发、幂等、钱。写得好,一天几十万点击账目清清楚楚;写不好,要么重复扣费被广告主投诉,要么并发下预算超支,要么被脚本刷走一半预算。
4.1 令牌与签名:点击链接为什么不能直接写广告 ID
最省事的做法是把点击链接写成/click?ad_id=12,然后直接跳转加计数。这个写法有致命问题:ad_id是自增的,谁都能从 1 遍历到 10000,一天之内把所有广告的预算刷光。而且这种点击在日志里看起来和真实点击一模一样,事后根本没法区分。
所以广告位接口返回的点击地址里带的是 32 位随机令牌,服务端把令牌和广告的对应关系存进缓存,TTL 10 分钟。点进来先查令牌,查不到就是伪造或者过期,直接跳 404。
<?php // app/Controller/SlotController.php 里的 buildPayload private function buildPayload(array $ad, int $slotId): array { $clickId = bin2hex(random_bytes(16)); // 32 位十六进制 // 令牌对应的上下文全部落缓存,点击时以缓存为准,不信任 URL 参数 Cache::set('click:' . $clickId, [ 'ad_id' => (int)$ad['id'], 'slot_id' => $slotId, 'ts' => time(), ], 600); return [ 'ad_id' => (int)$ad['id'], 'title' => $ad['title'], 'image' => $ad['image'], 'clickUrl' => '/click/' . $clickId, 'w' => 0, 'h' => 0, ]; }前端ad-slot.js拿到clickUrl之后,把它挂到<a>的href上,同时加rel="nofollow noopener"和target="_blank"。noopener不能漏,否则落地页可以通过window.opener反向操作投放方的页面,这在第三方广告场景里是个真实的风险点。
缓存里存的是ad_id而不是整条广告记录,是因为广告的预算和状态随时会变,点击那一刻要以数据库为准。如果令牌里缓存了完整记录,用户在页面上停留十分钟再点击,扣费依据的可能是十分钟前的价格,这就出问题了。
令牌有效期 600 秒的取舍:太短,用户切走标签页聊会儿天回来点击就失效,体验差;太长,缓存里堆积的令牌多,而且过期广告的令牌在内存里赖着,占用空间。600 秒覆盖了绝大多数「看到广告后点击」的行为,够用。
4.2 扣费事务:唯一索引做幂等,行锁做并发
点击进来之后要做四件事:校验令牌、校验落地页、防刷判断、扣费。前三件都是无状态的判断,第四件是写操作,必须放在事务里,而且要同时解决幂等和并发两个问题。
<?php // app/Service/Billing.php namespace App\Service; use App\Core\Db; class Billing { /** * 扣费。返回 false 表示重复点击,调用方照常跳转但不重复计费。 * @throws \Throwable 数据库异常时向上抛,由控制器记日志 */ public function charge(array $ad, string $clickId, int $slotId, string $ip, string $ua): bool { $pdo = Db::conn()->pdo(); $pdo->beginTransaction(); try { // 1) 唯一索引 uk_click_id 做幂等:重复插入直接抛 23000 $ins = $pdo->prepare( 'INSERT INTO ad_click_log (ad_id, slot_id, ip, ua, click_id, cost, is_valid, created_at) VALUES (?, ?, ?, ?, ?, 0, 1, NOW())' ); $ins->execute([$ad['id'], $slotId, $ip, mb_substr($ua, 0, 255), $clickId]); // 2) 行锁读预算,防止两个并发请求都读到"没超" $sel = $pdo->prepare('SELECT spent, budget, price_click FROM ad WHERE id = ? FOR UPDATE'); $sel->execute([$ad['id']]); $row = $sel->fetch(\PDO::FETCH_ASSOC); if (!$row) { $pdo->rollBack(); return false; } $cost = (float)$row['price_click']; $budget = (float)$row['budget']; // 3) 预算封顶:最后一次点击最多扣到预算用完为止,不透支 if ($budget > 0) { $remain = $budget - (float)$row['spent']; if ($remain <= 0) { $pdo->rollBack(); return false; } $cost = min($cost, $remain); } $pdo->prepare('UPDATE ad SET spent = spent + ? WHERE id = ?') ->execute([$cost, $ad['id']]); // 4) 把这一笔的真实扣费回写日志,报表以它为准 $pdo->prepare('UPDATE ad_click_log SET cost = ? WHERE click_id = ?') ->execute([$cost, $clickId]); $pdo->commit(); return true; } catch (\PDOException $e) { $pdo->rollBack(); if ($e->getCode() === '23000') { return false; // 重复点击,不算错误 } throw $e; } catch (\Throwable $e) { $pdo->rollBack(); throw $e; } } }这段代码有几个设计点值得展开。
第一,幂等靠数据库唯一索引,不靠「先查再插」。先查再插在并发下有个经典的时间窗口:两个请求同时查,都发现不存在,然后都去插,两条都成功。唯一索引把这个窗口彻底关掉,第二条插入直接抛23000(完整性约束冲突),捕获之后返回 false 即可。这是最省事也最可靠的幂等方案。
第二,SELECT ... FOR UPDATE必须在事务里,而且WHERE id = ?必须走主键。走主键的加锁范围只有那一行,不会影响别的广告。如果条件写成WHERE slot_id = ?且slot_id没有索引,InnoDB 会退化成锁全表,高峰期整个广告表都卡住,后台审核都点不动,这个场景在第 5 章会详细讲。
第三,预算封顶用的是min($cost, $remain)
本文还有配套的精品资源,点击获取