简介:面向具备 PHP 基础的学习者与中小站点开发者的黑色简洁短网址生成源码,适合搭建轻量短链接服务、课程设计或 Web 项目练手。前端提供短链创建、自定义别名、密码保护链接、访问统计、暗色主题、小书签及复制分享等功能,后台支持删除网址、修改站点设置、添加或编辑广告、查看分析数据与自定义 CSS,便于在基础短链能力上做运营扩展。压缩包共 103 个文件、约 681KB,以 34 个 PHP 文件承载核心业务与后台逻辑,配合 SQL 建库脚本,以及 SCSS、LESS、JavaScript、CSS 和字体图标资源完成页面样式与交互,源码结构覆盖前台展示、后台管理、数据库与静态资源。已有 214 人学习下载,适合需要一套前后台完整、可二次开发且带广告位与统计模块的 PHP 短链系统读者参考使用。
1. 一条 6 位短码背后,其实只有三样东西
一条s.xxx.com/aB3xY9这样的短链,拆开看并不神秘:一张存映射的表、一套把数字变成字符的编码、一条把/aB3xY9塞进入口文件的伪静态规则。这套黑色简洁风格的 PHP 短网址短链接生成源码,做的就是把这套流程打包成开箱能跑的形态——前台一个输入框,粘长链接、出短码,后台能看到列表和点击量,深色界面长时间看不刺眼。适合两类人:手里有台能跑 PHP 的机器、要给站内内容或渠道投放做跳转的从业者;以及想找个链路短、每一环都看得见的 PHP 小工具练手的开发者。下面按真实部署顺序拆:短码怎么发、伪静态怎么写、配色和字段怎么改、哪儿最容易出错。
2. 短码怎么发出来:Base62 编码、发号偏移与唯一索引的配合
短链系统最容易在“发号”这一步留下隐患。发号方式一旦定下来,后面所有短码都依赖它;中途改编码表或改发号规则,历史链接会大批不可用。所以在写第一行代码之前,先想清楚三件事:短码要不要能被反解、并发下怎么保证不重号、短码从几位开始。这三件事决定了表结构、索引和写入逻辑,也决定了你以后能不能平滑扩容。下面按“选方案 → 写编码 → 建表 → 落库”的顺序走一遍,每一步都给可抄的代码。
2.1 三种短码方案:先决定要不要“能被反解”
市面上短链脚本基本就三种发号思路,没有绝对优劣,取决于你是对内还是对外。
| 方案 | 短码形态 | 冲突处理 | 可枚举性 | 适用场景 |
|---|---|---|---|---|
| 自增 ID + Base62 | 1~7 位,数值连续 | 天然不冲突 | 高,短码加一就能逐个尝试 | 站内跳转、内部渠道、量级可控 |
| 随机 6 位 Base62 | 固定 6 位,跳变 | 靠唯一索引 + 重试 | 低,但量大了也会有碰撞成本 | 对外分享、不希望被人猜测 |
| 长链接哈希截断 | 固定长度 | 需要额外记录冲突 | 中 | 同一条长链接复用率极高 |
这套源码的定位是“简洁”,单表、无队列、无额外服务,自增 ID + Base62 是最省事也最好排错的一条路。它唯一的缺点是可枚举:短码是按顺序来的,别人从100001开始顺着加,就能把你所有链接都试一遍。解决办法不是换算法,而是发号时加一个偏移量,让第一个短码从 5 位起步,再用后台的黑名单和频率限制兜底。我一般会在配置里留一个CODE_OFFSET常量,默认1000000,对应 62 进制下的 5 位短码4c92,看起来不至于太短,也不浪费位数。
提示:偏移量只影响观感,不提供任何安全性。真要防枚举,得靠随机短码加访问限流,别把偏移量当成防护手段。
2.2 Base62 编解码:字符表顺序就是一份契约
编码函数是整套系统里最不能随手改的地方。字符表的顺序一旦上线,就成了历史数据的“解码密钥”,改了顺序,所有旧短码都会指向错误的行。
<?php // 62 个字符的顺序一旦上线就不能动,动了历史短码全部错位 const B62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; /** * 十进制转 62 进制 * @param int $n 非负整数,通常传 自增ID + CODE_OFFSET */ function base62_encode(int $n): string { if ($n === 0) { return B62[0]; } $s = ''; while ($n > 0) { $s = B62[$n % 62] . $s; // 取余拿低位,往前拼 $n = intdiv($n, 62); // 整数除法,别用 floor($n/62) 处理大数 } return $s; } /** * 62 进制转十进制 * @return int 非法字符返回 -1,调用方必须显式判断 */ function base62_decode(string $code): int { $n = 0; $len = strlen($code); for ($i = 0; $i < $len; $i++) { $pos = strpos(B62, $code[$i]); if ($pos === false) { return -1; // 大小写敏感,B62 里没有的字符直接拒绝 } $n = $n * 62 + $pos; } return $n; }两个函数都不长,但有几个参数层面的细节值得说清楚。第一,$n用int就够,PHP 在 64 位平台下int上限约 9.2×10^18,而 62 的 10 次方已经是 8.4×10^17 量级,实际业务很难撑到那个数。第二,base62_decode返回-1而不是false,是为了让调用方能和0(对应短码0)区分开,==比较时也不容易踩到 PHP 弱类型的坑。第三,容量要心里有数:6 位 62 进制约 568 亿个组合,7 位约 3.5 万亿,一个单表短链服务跑十年也未必用得完,所以不必急着上 8 位。
strpos在循环里逐字符查表,长度 6 的短码最多查 6 次,性能上完全不是瓶颈。真正拖慢跳转的是数据库查询和统计写入,那部分放到第 4 章讲。
2.3 建表:把 code 列设为 ascii_bin
很多短链脚本在建表时用了默认的utf8mb4_general_ci,本地测试一切正常,上线后开始出现“两条完全不同的短码打开同一个链接”的异常现象。根因是排序规则不区分大小写:在ci规则下,Ab3和aB3被当成同一个值,唯一索引直接把它俩判成重复行,查询时WHERE code = 'Ab3'也可能命中aB3那一行。这种事排查起来极其费劲,因为你看代码逻辑是对的。
CREATE TABLE `short_url` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, -- code 用 ascii + bin:省空间,且强制大小写敏感 `code` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, `url` VARCHAR(2048) NOT NULL DEFAULT '', -- 长链接指纹,用来做同链接复用,别直接拿 url 建索引 `url_hash` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL DEFAULT '', `clicks` INT UNSIGNED NOT NULL DEFAULT 0, `expire_at` DATETIME NULL DEFAULT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), KEY `idx_hash` (`url_hash`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;字段含义和参数取舍:
| 字段 | 类型 | 说明 |
|---|---|---|
id | BIGINT UNSIGNED | 发号源,短码由它经 Base62 得来 |
code | VARCHAR(16) ascii_bin | 短码本体,唯一索引;16 位是上限,实际用 5~7 位 |
url | VARCHAR(2048) | 长链接,别用 255,很多带参数的投放链接轻松破千 |
url_hash | CHAR(32) | md5($longUrl),查重用,比在 url 上建索引省得多 |
clicks | INT UNSIGNED | 点击计数,溢出上限约 42 亿,单条链接够用了 |
expire_at | DATETIME NULL | NULL 表示永久有效,跳转时由 SQL 判断 |
url列长 2048 不是随手写的:主流浏览器对地址栏的实际上限在 2000 字符上下,超过这个长度的链接发给别人多半也打不开,所以校验时就按 2048 截断,而不是等到入库报错。如果你确实要存更长的,改成TEXT并把 MD5 换成SHA-256截断,同时记得业务层加长度提示。
注意:
sql_mode一定要开严格模式。非严格模式下,超长的url会被静默截断成半条链接,用户点进去 404,你在后台看数据一切正常。
2.4 写入口:临时占位码 + 发号偏移
自增 ID 和 Base62 之间有个绕不过去的时序问题:id要等INSERT成功之后才有,可code是唯一索引、不能为空。常见做法有两种,一种是先插一条空code的行再UPDATE,但唯一索引会让第二条空code插入失败;另一种是先用随机临时码占位,拿到id后回填。
<?php const CODE_OFFSET = 1000000; // 让短码从 5 位起步 const MAX_RETRY = 5; function create_short(PDO $pdo, string $longUrl, string $customCode = ''): string { // 1) 协议白名单:javascript: / data: 这类必须挡在门外 $scheme = strtolower((string)parse_url($longUrl, PHP_URL_SCHEME)); if (!in_array($scheme, ['http', 'https'], true)) { throw new InvalidArgumentException('只接受 http/https 链接'); } if (strlen($longUrl) > 2048) { throw new InvalidArgumentException('原始链接过长'); } $hash = md5($longUrl); // 2) 同一条长链接复用已有短码,避免重复占号 $stmt = $pdo->prepare( 'SELECT code FROM short_url WHERE url_hash = ? AND (expire_at IS NULL OR expire_at > NOW()) LIMIT 1' ); $stmt->execute([$hash]); $exist = $stmt->fetchColumn(); if ($exist !== false && $exist !== '') { return $exist; } for ($i = 0; $i < MAX_RETRY; $i++) { $pdo->beginTransaction(); try { // 3) 临时占位码:t + 12 位十六进制,碰撞概率可以忽略 $tmp = 't' . bin2hex(random_bytes(6)); $pdo->prepare( 'INSERT INTO short_url (code, url, url_hash) VALUES (?, ?, ?)' )->execute([$tmp, $longUrl, $hash]); $id = (int)$pdo->lastInsertId(); // 4) 自定义短码优先,没有就走偏移后的 Base62 $code = $customCode !== '' ? $customCode : base62_encode($id + CODE_OFFSET); $pdo->prepare('UPDATE short_url SET code = ? WHERE id = ?') ->execute([$code, $id]); $pdo->commit(); return $code; } catch (PDOException $e) { $pdo->rollBack(); // 23000 = 唯一索引冲突,换一次号重试;其他错误直接抛 if ($e->getCode() !== '23000') { throw $e; } } } throw new RuntimeException('短码分配失败,请稍后重试'); }这段代码里几个参数值得单独交代。CODE_OFFSET决定了短码的起步位数和观感,改它只影响新链接,已发出去的短码不受影响。MAX_RETRY = 5是经验值,冲突只可能来自自定义短码撞车,随机临时码和 Base62 主键撞车的概率极低,重试五次还失败基本说明自定义短码被占用得很厉害,该给用户报“该短码已被使用”而不是继续空转。事务包住“插入 + 回填”两步,是为了避免回填失败时留下一条code还是t...的脏数据——这种行从后台看起来完全正常,点进去才 404。
url_hash复用逻辑也不是必须的:如果业务上同一个长链接需要生成多条不同短码(比如不同渠道要分开统计),把第 2 步整段删掉即可,代价是同一条链接会占多个号。判断依据很简单,看你的点击统计是按“链接”聚合还是按“渠道”聚合。
3. 部署与伪静态:让/aB3xY9走进入口文件
短链本地跑通、上传到服务器后所有短链接都 404,十次里有八次是伪静态问题。原因不复杂:/aB3xY9在服务器看来是一个不存在的目录下的不存在的文件,Web 服务器找不到,就直接返回 404,根本没机会交给 PHP。所以这一章的部署顺序是固定的一套流程——先核对环境、再导表改配置、再写伪静态、最后确认跳转状态码;把这些按顺序做完,能省掉大量来回试错。
3.1 环境核对:版本、扩展、目录权限
这类 PHP 短链脚本通常只依赖 PDO 和 MySQL,不乱装扩展,对环境的兼容面比较宽。我一般按 PHP 7.4 起步来配,PHP 8.x 也能跑,但要注意两个兼容点:一是老代码里可能用了each()、create_function()这类 8.0 已移除的函数,跑到就白屏;二是strlen(null)在 8.1 之后会报废弃警告,如果页面顶部出现一堆Deprecated,说明脚本年代偏早,需要顺手修一遍。
核对清单按顺序过:
php -v确认版本,php -m | grep -i pdo确认pdo_mysql在。php -i | grep -i "display_errors",部署初期先开On,跑通后立刻关掉,否则错误信息会直接输出到用户页面上。- 站点根目录归属改对:
chown -R www-data:www-data /var/www/short,配置文件如果可写,最好单独收紧权限。 - 时区设置成
Asia/Shanghai,否则后台列表里的“创建时间”和你的直觉差 8 小时,排查点击数据时容易被误导。
3.2 建库、导 SQL、改配置
建库这一步没什么花活,字符集统一utf8mb4,排序规则统一utf8mb4_general_ci,唯独short_url表里的code和url_hash两列要按 2.3 的写法设成ascii_bin。如果安装 SQL 里没这么写,手动ALTER一下,别嫌麻烦。
CREATE DATABASE `short_url` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; -- 如果已经建表且 code 是 utf8mb4_general_ci,这一句必须执行 ALTER TABLE `short_url` MODIFY `code` VARCHAR(16) CHARACTER SET ascii COLLATE ascii_bin NOT NULL;配置文件一般叫config.php、config/db.php或者includes/config.inc.php,不同打包习惯不一样,找的时候直接全文搜mysql:或host关键字最快。需要改的就四项:主机、库名、用户名、密码。有两点容易忽略:一是短链前缀SHORT_HOST(生成短链时拼在前面的域名)通常也在这个文件里,不改的话生成的短链还指向作者的演示域名;二是debug开关,部署完记得关。
提示:改完配置先访问一次首页,能出表单说明数据库通了;如果页面空白且没有任何报错,多半是
display_errors关着,去看 PHP 错误日志比在页面上瞎猜快得多。
3.3 Nginx 与 Apache:一条 rewrite 就够,但条件不能少
伪静态规则要解决的核心问题只有一个:把“看起来像短码”的请求交给入口文件,把真实存在的文件(后台页面、CSS、JS、图标)放行。少写放行条件,后台就会 404、样式全丢;多写几条正则,容易把/admin这种路径也当成短码吃掉。我的做法是让入口文件自己解析REQUEST_URI,Web 服务器只负责“文件不存在就转给 index.php”,规则少、冲突也少。
Nginx 站点配置大致这样:
server { listen 80; server_name s.example.com; # 换成自己的域名 root /var/www/short; # 指向源码解压后的目录 index index.php; location / { # 真实文件/目录直接返回,剩下的全部交给入口 try_files $uri $uri/ /index.php?$query_string; } # 静态资源单独放行并加缓存,别让它们走 PHP location ~* \.(css|js|png|jpg|jpeg|gif|svg|ico|woff2?)$ { expires 7d; access_log off; } location ~ \.php$ { include fastcgi_params; fastcgi_pass unix:/run/php/php-fpm.sock; # 按实际 sock 路径改 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } }Apache 环境对应的是根目录下的.htaccess:
<IfModule mod_rewrite.c> RewriteEngine On RewriteBase / # 这两行是后台和静态资源的保命条件,删了它们 /admin 会变成短码 RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [L,QSA] </IfModule>Apache 这边还有两个前置条件经常被漏掉:httpd.conf里mod_rewrite要启用,站点目录的AllowOverride必须从None改成All,否则.htaccess根本不生效,你会以为是规则写错了,其实是文件没被读取。
至于入口文件怎么解析路径,写法很短:
<?php // 只取路径部分,忽略查询串,避免 ?from=xxx 干扰路由 $path = trim((string)parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH), '/'); if ($path === '' || $path === 'index.php') { require __DIR__ . '/views/home.php'; // 首页表单 exit; } // 短码只允许字母数字,长度上限跟表结构对齐 if (!preg_match('/^[0-9a-zA-Z]{1,16}$/', $path)) { http_response_code(404); exit('Not Found'); } redirect_by_code($path); // 见 3.4这段路由的好处是跟 Web 服务器解耦:Nginx 和 Apache 用同一份入口逻辑,迁移时不用改代码。preg_match的白名单校验同时也是第一道防线,把/wp-admin、/../etc/passwd这类路径挡在查库之前,避免无谓的数据库查询和被扫描的风险。
3.4 301 还是 302:这个选择会影响统计和改链
跳转状态码是短链系统里最容易被随手一写、后面又改不动的地方。用 301,浏览器和中间层会长期缓存这条映射,用户第二次点击根本不会回你的服务器,点击统计直接失真,将来长链接要换目标也改不掉——用户浏览器里存着旧记录。用 302,每次点击都回到源站,统计准确、随时可改,代价是多一次请求。
<?php function redirect_by_code(string $code): void { $pdo = db(); // 过期判断放在 SQL 里做,别查出来再在 PHP 里比时间 $stmt = $pdo->prepare( 'SELECT id, url FROM short_url WHERE code = ? AND (expire_at IS NULL OR expire_at > NOW()) LIMIT 1' ); $stmt->execute([$code]); $row = $stmt->fetch(PDO::FETCH_ASSOC); if (!$row) { http_response_code(404); exit('链接不存在或已过期'); } // 302 + no-store:不让浏览器缓存跳转结果,统计才准 header('Location: ' . $row['url'], true, 302); header('Cache-Control: no-store, no-cache, must-revalidate'); header('X-Robots-Tag: noindex, nofollow'); // 别让搜索引擎收录跳转页 exit; }参数层面有两点:true, 302里的true是replace,表示覆盖之前可能设置的Location头,不加在某些框架里会出现两个Location;no-store比no-cache更彻底,直接告诉浏览器别存。如果你的短链是给站内导航用的、对统计没要求,301 能让二次访问少一次回源;但只要是给运营做渠道链接,一律 302,因为渠道数据错一次,后面三个月的报表都得返工。
4. 后台、模板与黑色主题:改配色、加字段、接统计
源码里的“黑色简洁”指的是前台和后台那套深色 UI,这部分是最好改也最容易改坏的地方。改配色不是把背景刷黑就完事,对比度不够用户看不清提示文字;加字段不是加一列就完事,表单、校验、跳转判断、后台列表四处都得跟着动;接统计更不能直接在主流程里同步UPDATE,那会把每次跳转的响应时间翻倍。这一章按“先认清结构 → 再改样式 → 再加字段 → 最后接统计”的顺序来。
4.1 先认清目录结构,再动手
这类单文件为主的 PHP 短链包,目录结构大同小异,实际以你手里的为准,但下面这几类位置基本都能对上号:
| 路径(常见命名) | 作用 | 改动频率 |
|---|---|---|
config.php/config/db.php | 数据库、站点域名、调试开关 | 部署时一次 |
index.php | 入口 + 路由 | 偶尔 |
admin/ | 后台登录、链接列表、删除 | 偶尔 |
assets/css/ | 深色主题样式 | 经常改 |
install.sql/sql/ | 建表语句 | 一次 |
include//lib/ | 编码函数、数据库连接 | 偶尔 |
动手前的第一件事不是改代码,是全文搜一遍example.com和localhost,把所有写死的域名、绝对路径改成自己的。这类遗留是上线后“前台生成的短链打不开、但手动改域名就能打开”的经典原因,找起来比想象的费时间。
4.2 把黑色主题改到能看清:用 CSS 变量收口
深色 UI 最容易出现的问题是正文够亮、辅助文字太暗,用户看不清“链接有效期”这类提示。做法上我不建议在几十处选择器里逐个改颜色,先把颜色抽成 CSS 变量,后面调色只动一处。
:root{ --bg: #0e0f12; /* 页面底色,别用纯黑 #000,长时间看反差太硬 */ --panel: #171a20; /* 卡片、表单容器 */ --line: #24272e; /* 分割线、输入框边框 */ --text: #e8eaed; /* 正文 */ --muted: #9aa0a6; /* 辅助文字、占位符 */ --accent: #4f8cff; /* 主按钮、聚焦边框 */ --danger: #ff5c5c; /* 删除、报错 */ } body{ background: var(--bg); color: var(--text); font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; } .card{ background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 24px; } /* 输入框聚焦时给一圈可见的边框,深色下默认样式几乎看不见 */ input[type="text"]:focus, input[type="url"]:focus{ outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(79,140,255,.18); } .hint{ color: var(--muted); font-size: 12px; }调参上有两个经验值:辅助文字亮度不要低于#8a9099,低于这个值在 12px 字号下基本糊成一片;卡片和页面底色的差值保持在 8~12 个色阶(比如#0e0f12到#171a20),差值太小看不出层次,太大又显得割裂。改完之后把页面缩放到 125% 看一眼,很多对比度问题在这个比例下才暴露。
4.3 加一个有效期字段:从表改到跳转的四处联动
“短链永久有效”在很多场景下不合适,比如活动页的分享链接。加一个有效期字段,改动点分布在四个地方,漏一处就会出现“后台填了日期但没生效”或者“过期了还能打开”的问题。
第一步,表里加列(2.3 的建表语句里已经带上了expire_at,这里说已有表的补法):
ALTER TABLE `short_url` ADD COLUMN `expire_at` DATETIME NULL DEFAULT NULL AFTER `clicks`;第二步,写入时接收并校验。日期格式不要相信前端,统一在服务端strtotime转换,转换失败就当没填:
$expireRaw = trim($_POST['expire_at'] ?? ''); $expireAt = null; if ($expireRaw !== '') { $ts = strtotime($expireRaw); // 只接受未来时间,过去时间会让链接一生成就失效 if ($ts !== false && $ts > time()) { $expireAt = date('Y-m-d H:i:s', $ts); } }第三步,写入语句带上这一列,把create_short()的 INSERT 改成五列。第四步也是最关键的一步,跳转查询必须带上过期条件——3.4 的 SQL 里那句(expire_at IS NULL OR expire_at > NOW())就是这个作用。把判断交给数据库的好处是:不用把行取出来再比时间,少一次分支,也不会因为 PHP 和 MySQL 时区不一致导致判断偏差。
后台列表页则相反,要把过期链接也显示出来,只是加一列状态标签(有效 / 已过期),方便运营复制或者续期。这里用LEFT JOIN或者直接查全表都行,列表页的数据量通常很小。
4.4 点击数别拖慢跳转:先响应再计数
在主流程里同步执行UPDATE ... SET clicks = clicks + 1是最直观的写法,问题在于每次跳转都要多等一次写操作,量一上来还会在clicks这一行上产生锁竞争。常用做法是把计数挪到响应之后。
<?php header('Location: ' . $url, true, 302); header('Cache-Control: no-store'); header('X-Robots-Tag: noindex, nofollow'); // 先把响应吐给浏览器,用户感知不到后面的耗时 if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } // 只有 php-fpm 环境才会走到这之后;Apache mod_php 下这段是同步执行的 try { $pdo->prepare('UPDATE short_url SET clicks = clicks + 1 WHERE id = ?') ->execute([$row['id']]); } catch (PDOException $e) { error_log('[shorturl] click count failed: ' . $e->getMessage()); }fastcgi_finish_request()是这套写法的核心,但它只在 php-fpm 下存在,Apache 的mod_php里没有这个函数,所以必须先function_exists判断,否则直接白屏。计数失败也不要影响跳转,捕获异常写日志即可——用户点开链接这件事比统计数字重要得多。
如果访问量到了单机扛不住的级别,再考虑把计数降级成“追加写日志 + 定时聚合”,那是另一套架构,单表短链在几千 QPS 以内用上面这段足够。真要压测,用ab -n 2000 -c 100 https://s.example.com/aB3xY9打一轮,注意 ab 默认不跟随跳转,看到的响应时间就是纯服务端耗时,正好符合我们要测的东西。
5. 避坑与排查:跑起来之后最常遇到的 5 个问题
下面这五条基本都是上线后一到两天内会遇到的,按现象、原因、解决三段写,照着对号入座能省不少时间。
5.1 所有短链 404,首页却正常
现象:首页能打开、能生成短码,复制出来的短链一访问就是 404,curl -I也是 404。
原因:伪静态没生效。可能是 Nginx 的location /被别的规则抢先匹配,也可能是 Apache 的AllowOverride还是None,.htaccess压根没被读取。
解决:先curl -I https://s.example.com/aB3xY9看返回头是404还是302,是 404 就去看 Web 服务器配置;Nginx 用nginx -T输出完整配置检查 location 顺序,Apache 看httpd.conf里对应目录的AllowOverride All有没有放开。这一步排除了,问题基本就解决了。
5.2 两条不同的短码打开同一个链接
现象:后台明明有两条记录,Ab3和aB3,点任何一个都跳到同一条长链接。
原因:code列用了utf8mb4_general_ci这类不区分大小写的排序规则,唯一索引把两个值当成同一个,查询WHERE code = 'Ab3'时可能命中另一行。
解决:按 3.2 的ALTER TABLE ... MODIFY code ... ascii_bin改列定义,然后把历史数据里大小写冲突的行找出来手动处理。改完之后 PHP 侧的比较也要用===,别留==的隐患。这条一旦踩上,数据是脏的,越早改越好。
5.3 长链接存进去被截断,跳转过去是半截地址
现象:短链能跳,但目标页打不开,把url列的内容复制出来看,明显少了后面一截。
原因:url列长度不够(常见是VARCHAR(255)),同时 MySQL 没开严格模式,超长数据被静默截断,INSERT不报错,问题被藏起来了。
解决:把列改成VARCHAR(2048)或TEXT,sql_mode里加上STRICT_TRANS_TABLES,PHP 侧按 2.4 的方式在写入前做长度校验并给出明确提示,让用户在生成阶段就知道链接太长,而不是点了才发现跳错。
5.4 后台登录页 404,或者样式全丢
现象:前台短链好使,进/admin却是 404 或者一片没有样式的白页。
原因:伪静态规则把所有不存在的路径都转给了入口文件,.css、.js和admin/目录也被当成短码处理,入口按短码规则解析失败,返回 404。
解决:把“真实文件/目录放行”的条件加回去,Nginx 用try_files $uri $uri/ /index.php?$query_string;,Apache 用RewriteCond %{REQUEST_FILENAME} !-f加!-d(3.3 已给出)。静态资源再用单独的location匹配扩展名,顺便把缓存加上。
5.5 短链被当成跳板,或者被人顺着号爬了一整遍
现象:后台出现大量你不认识的长链接;或者日志里有人以固定节奏请求连续的短码。
原因:一方面接口没有协议和域名限制,javascript:、data:以及任意站点都能生成;另一方面自增发号让短码连续,别人从偏移量起点顺着加就能遍历。
解决:协议白名单必须在服务端做(2.4 已给出),再加一层目标域名黑名单或白名单;发号上加偏移、必要时切换成随机短码;对生成接口按 IP 或账号限流;短链响应头带上X-Robots-Tag: noindex, nofollow,别让这些跳转页进搜索结果。已经产生垃圾数据的,按created_at和来源 IP 批量清理。
排查顺序上,我习惯从外往里推:先curl -I看状态码,确定是 Web 服务器还是 PHP 层的问题;再看 PHP 错误日志和慢查询日志;最后才去翻代码。反过来一上来就读代码,八成会在一堆无关文件里转圈。
6. 自定义短码与压测:把 302 的响应时间压下来
自定义短码是这套源码里最容易被做漏的功能——很多脚本允许你填,但不做校验,结果用户把admin、api、login这些路径抢走了,后台直接进不去。校验函数我一般这么写:
<?php function valid_custom_code(string $c): bool { if (!preg_match('/^[0-9a-zA-Z]{4,16}$/', $c)) { return false; // 位数下限 4,避免和系统短码撞得太多 } $reserved = ['admin', 'api', 'login', 'logout', 'static', 'assets', 'favicon', 'index']; return !in_array(strtolower($c), $reserved, true); // 系统路径优先,不让用户抢 }strtolower那一步不能省,因为Admin和admin在路由层面是两条不同路径,但在用户眼里是同一个东西,不统一比较就会留下绕过口子。位数下限设 4 而不是 2,是因为 1~3 位的自定义短码数量太少,很快会被占满,而且和系统发的 5 位短码挤在一起时,排查问题会变得很困难。
压测方面,我通常会先在本地打一轮再上线:ab -n 5000 -c 50 https://s.example.com/aB3xY9,重点看两件事——失败请求数是不是 0、P95 有没有超过 100ms。如果 P95 明显偏高,先看慢查询日志里UPDATE short_url SET clicks是不是排在前面,是的话说明计数还在主流程里,回去检查fastcgi_finish_request有没有生效(Apache 环境下它一定不生效,那就得换到 php-fpm 或者改成异步写日志)。压测时别用带-r的参数,短链本身是 302,跟不跟随跳转测出来的数字完全是两回事。
有个教训我记了很久:早期为了省事,把code列的排序规则留在了默认值上,本地测了二十几条数据全是小写,什么都没发现,上线第三天两条短码打开同一个页面,运营那边先发现的,我对着代码查了两个小时才想起去SHOW CREATE TABLE。从那以后我每次部署短链服务,都会先SHOW CREATE TABLE short_url确认code是ascii_bin,再curl -I打十次确认状态码是 302,最后才把域名发出去。这两步加起来不到一分钟,能挡掉后面一整天的问题。希望帮到你。
本文还有配套的精品资源,点击获取