ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

零依赖PHP短链服务实战:原生代码+SQLite打造极简URL缩短器

2026/10/7 10:30:38 拓冰建站 浏览量
零依赖PHP短链服务实战:原生代码+SQLite打造极简URL缩短器 最近 caveman 这个词在热搜上露脸的时候我还愣了一下——这不就是我折腾过好几次的那套极简思维吗项目本身不大但背后的取舍过程倒是很值得拆开来讲讲。这里说的 caveman是我给自己的一个 URL 短链服务起的名字。它没有用 Laravel、ThinkPHP 这类全家桶框架没引任何 Composer 第三方包数据库用的也不是 MySQL而是 PHP 自带的 PDO SQLite。整套东西的核心思想就一句话用最原始、最少的工具把一个需求做到能用、够用、可控。你要是在找那种零依赖手写小服务的参考或者想搞清楚短链背后的路由、短码生成、跳转、统计到底怎么一回事这篇文章应该能给你省不少时间。1. 项目是怎么来的为什么非得穴居人式开发1.1 最初只是不想为一个小需求开个大工程事情起因很简单。我当时需要给一批线下物料印二维码原始链接又长又丑而且带着一堆追踪参数。常规做法是找个短链服务但第三方服务要么有有效期要么有跳转广告要么接口要钱。我又不想为了一个把长链接变短的需求去搭一个十几兆依赖的 Web 框架——那感觉就像为了开个罐头买了一整套厨房设备。后来转念一想短链服务的本质不就是三件事嘛——接收长链、生成短码、访问时跳转。这三件事用 PHP 原生代码写核心逻辑撑死两百行。于是我就决定手搓一个命名 caveman讽刺一下自己这种回到原始社会的开发方式。如果你也有类似需求比如内部系统分享链接、活动落地页跳转、二维码管理或者只是想自己掌控数据、不想把链接行为交给第三方那这种极简方案非常合适。1.2 caveman 这个名字代表的三个原则名字不是随便起的它代表了我给自己定的三条开发纪律不引框架路由、请求解析、响应输出都自己写不依赖框架的生命周期和容器。不引外部包加密、编码、HTTP 跳转这些用 PHP 内置函数解决。不搞复杂存储单文件 SQLite 搞定数据备份就是复制一个文件。这三条纪律执行下来项目从一开始就保持着一种原始但清晰的状态。没有自动加载器没有依赖树没有永远升不完的版本。打开代码任何一行都能在三秒钟内说出它在干什么。有人可能会问这算不算重复造轮子我的看法是造轮子的前提是你知道轮子为什么是圆的。当你自己实现过一遍路由分发、短码生成、请求校验之后你再去用那些重型框架才会明白它们替你承担了什么也才知道哪些能力是你这场景根本用不上的。下表是我做选型时给自己列的对比分享出来给同样纠结过的人参考方案依赖数量部署体积上手成本可控性适合场景全套框架 MySQL几十个包数十 MB中高低复杂业务系统微框架 SQLite个位数包几 MB低中小型应用纯原生 SQLite零几百 KB极低极高单一功能小服务我最终选了最下面那一档实话说至今没有后悔过。2. 设计思路与关键取舍2.1 技术选型背后的三个为什么先解决最容易引起争议的问题为什么是 PHP不是 Python 或 Node因为短链服务本质上是一个启动即服务、请求即响应的东西PHP 在这种场景有一个天然优势每次请求都是独立的生命周期不需要常驻进程管理也就不需要考虑内存泄漏和进程保活。对于一个可能要跑好几年的小工具来说这意味着极低的运维成本。你甚至可以在一个廉价的虚拟主机上跑起来连 Docker 都不用上。第二个问题是为什么存储用 SQLite 而不是 JSON 文件我最初确实试过纯 JSON 文件存储——每条记录一行 JSON读写用 file_put_contents 和 file_get_contents。但很快发现两个问题一是并发写入时容易丢数据两个请求同时写文件各自读取-修改-写回后写的会把先写的覆盖掉二是数据量上来之后查询短码只能线性遍历性能越来越差。SQLite 完美解决了这两件事它本质就一个文件却提供了事务、索引和并发控制。对短链这种读多写少、总数据量不大的场景SQLite 就是物理意义上的完美存储。第三个问题是路由到底怎么写这里我选择了最简单的前端控制器模式配合 Web 服务器的 rewrite 规则把所有请求统一转发到 index.php。这样做的核心原因是短链的跳转路径是动态的必须由 PHP 解析 URL 来定位 slug而不是让 Web 服务器直接找静态文件。2.2 短码生成既要短又要防猜短码是短链服务的核心资产。它的设计要求很明确——长度短、可读性好、碰撞概率低、不能被人顺序遍历。我采用的是自增 ID 进制转换 混淆映射方案。流程是这样的数据库自增主键拿到一个整数 ID。把十进制 ID 转换成 62 进制字符串0-9a-zA-Z。对字符表做一次洗牌映射让生成的短码看起来不规律。比如 ID 为 125 时转换成 62 进制是 212×62 1但这太规律了。把字符表洗牌成另一套顺序之后21 可能就对应成了 qK别人就猜不出规律。这里洗牌的算法很简单?php // 62进制字符表打乱顺序 $alphabet str_shuffle(0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ); function idToSlug(int $id, string $alphabet): string { $base strlen($alphabet); $slug ; while ($id 0) { $slug $alphabet[$id % $base] . $slug; $id intdiv($id, $base); } return $slug ? $alphabet[0] : $slug; }有人会问为什么不直接生成随机 8 位短码也可以我们来算一笔账。8 位字符、每一位 62 种可能总空间是 62^8 ≈ 2.18×10^14理论上极其充足。但随机方案有两个隐藏问题一是必须做碰撞检测数据量越大重试次数越多二是无法保证短码的单调性你没法知道哪条记录更早。自增方案的好处是 ID 自带顺序性短码长度也会随着数据增长缓慢变长前 62 条记录都是单字符短码体验更好。碰撞概率也可以量化。假设采用随机方案短码空间为 N 62^8已经插入了 k 条记录下一次插入碰撞概率约为 k/N。当 k 到 10000 时碰撞概率约 4.6×10^-9。听起来很低对吧但如果你的服务跑得足够久数据量到几百万条碰撞概率就到了万分之几的级别那时候随机方案的优势就不存在了。而自增混淆的方案因为底层是唯一主键碰撞概率严格为零数据库层面就保证了。2.3 数据库表结构少即是多表结构我没有过度设计总共就一张表CREATE TABLE links ( id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT NOT NULL UNIQUE, target_url TEXT NOT NULL, created_at INTEGER NOT NULL, hit_count INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX idx_links_slug ON links(slug);slug 加了唯一索引这是防重复的关键防线。hit_count 用来记录访问次数方便后面做统计。created_at 存 Unix 时间戳而不是 datetime 字符串是为了排序和比较方便也避免时区问题。有的短链服务会把 IP、UA 这些访问日志单独建表但我一开始没做——因为这个项目强调够用即可。真到了需要分析访问来源的时候再加一张访问日志表也不迟这就是极简架构的好处核心不动扩展自由。3. 核心功能实现全流程3.1 入口路由一个文件接管所有请求我的目录结构非常简单caveman/ ├── index.php # 前端控制器所有请求都先进这 ├── db.php # 数据库连接与初始化 ├── functions.php # 短码生成、URL校验等公共函数 └── data/ └── caveman.sqlite # SQLite数据库文件既然是穴居人风格没有繁琐的框架目录核心代码按功能拆成三个文件就够了。入口 index.php 做的事只有一件根据请求方法和路径分发给对应的处理函数。?php // index.php require_once __DIR__ . /db.php; require_once __DIR__ . /functions.php; $path parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH); $path rtrim($path, /); if ($path /api/shorten $_SERVER[REQUEST_METHOD] POST) { handleShorten(); } elseif ($path /api/stats $_SERVER[REQUEST_METHOD] GET) { handleStats(); } elseif (preg_match(#^/([A-Za-z0-9])$#, $path, $matches)) { handleRedirect($matches[1]); } else { http_response_code(404); echo json_encode([error not found]); }这里有个细节值得注意短码的正则我限制为 [A-Za-z0-9]。这看起来是常识但很多人会写成 . 导致各种非法路径也被当成短码去查库等于给攻击者留了一个探测数据库的入口。做路由匹配时永远把你能接受的格式缩到最小而不是把你能容忍的乱入放到最大。3.2 创建短链校验、入库、返回创建短链的接口是 POST /api/shorten接收 JSON 请求体核心步骤就三步校验 URL、生成短码、写库。?php function handleShorten() { $input json_decode(file_get_contents(php://input), true); $url $input[url] ?? ; // 校验必须是合法HTTP地址 if (!filter_var($url, FILTER_VALIDATE_URL) || !preg_match(#^https?://#i, $url)) { http_response_code(400); echo json_encode([error invalid url]); return; } // 防止SSRF过滤内网地址按需开启 $host parse_url($url, PHP_URL_HOST); if ($host localhost || filter_var($host, FILTER_VALIDATE_IP)) { http_response_code(400); echo json_encode([error blocked host]); return; } $slug createSlug(); // 自增ID 混淆 insertLink($slug, $url); http_response_code(200); echo json_encode([ slug $slug, short_url https://your.domain/ . $slug ]); }URL 校验有两个容易被忽略的点。第一filter_var 判断 URL 是宽松的javascript:alert(1) 这种协议也能通过所以必须额外强制 http/https 头。第二如果这个服务是公网可访问的SSRF 风险必须考虑——攻击者可能借你的接口去探测内网资源。我这里简单判断了主机名是 IP 还是 localhost 就拦截更严格的做法还可以把内网 IP 段全列出来。入库与短码生成放在同一事务里。createSlug 实际上是先拿到自增 ID再转换成混淆短码。因为 SQLite 的 lastInsertRowID 能拿到刚插入的自增 ID所以这里是先插一条占位记录拿到 ID再更新 slug。更优雅的做法是先算出下一个自增 ID但 SQLite 没有可靠的取下一个 ID 但不占用的办法所以我采用了先插入空 slug 再更新的方式逻辑如下?php function createLink(string $url): string { $db getDb(); $db-beginTransaction(); // 先插入拿到自增ID $stmt $db-prepare(INSERT INTO links (target_url, created_at) VALUES (?, ?)); $stmt-execute([$url, time()]); $id $db-lastInsertRowID(); // 再把ID转成slug $alphabet getAlphabet(); $slug idToSlug($id, $alphabet); $stmt $db-prepare(UPDATE links SET slug ? WHERE id ?); $stmt-execute([$slug, $id]); $db-commit(); return $slug; }有人说先插入空 slug 再 update 不够优雅但对于这种量级的服务事务内两步操作耗时在毫秒级完全无感。牺牲一点点的理论优雅换取实现上的直白本身就是 caveman 的哲学。3.3 跳转逻辑301 还是 302跳转是整个服务最核心的路径。用户访问 /{slug}服务端查数据库拿到目标 URL 后发起 HTTP 重定向。?php function handleRedirect(string $slug) { $db getDb(); $stmt $db-prepare(SELECT target_url FROM links WHERE slug ?); $stmt-execute([$slug]); $row $stmt-fetch(); if (!$row) { http_response_code(404); echo link not found; return; } // 点击计数 $db-prepare(UPDATE links SET hit_count hit_count 1 WHERE slug ?) -execute([$slug]); // 禁止缓存避免统计失真 header(Cache-Control: no-store, no-cache, must-revalidate); header(Location: . $row[target_url], true, 302); exit; }这里我选择了 302。原因很简单短链服务经常要修改目标链接如果用 301浏览器和搜索引擎都会缓存这个跳转结果等你更新了目标地址用户那边还停留在旧地址上。长期运营下来301 会带来无尽的为什么链接没改过来的困扰。用 302 还有一个附带好处每次访问都会先经过服务器方便做点击统计。反过来如果某个短链是要永久迁移网站301 反而更合适因为它会把旧地址的权重传给新地址。判断标准一句话临时中转用 302永久迁移用 301短链服务绝大多数是前者。3.4 统计接口给数据一个出口没有统计的短链服务就像蒙眼开车。我加了一个简单到不行的统计接口?php function handleStats() { $path parse_url($_SERVER[REQUEST_URI], PHP_URL_PATH); preg_match(#^/api/stats/([A-Za-z0-9])$#, $path, $matches); // $matches[1] 就是slug $db getDb(); $stmt $db-prepare( SELECT slug, target_url, hit_count, created_at FROM links WHERE slug ? ); $stmt-execute([$matches[1]]); $row $stmt-fetch(PDO::FETCH_ASSOC); if (!$row) { http_response_code(404); echo json_encode([error not found]); return; } echo json_encode([ slug $row[slug], short_url https://your.domain/ . $row[slug], target_url $row[target_url], hit_count (int)$row[hit_count], created_at date(Y-m-d H:i:s, $row[created_at]) ]); }统计虽然简单但日常完全够用。每次跳转时 hit_count 加一想知道哪条链最受欢迎一眼便知。如果你想要更细粒度的数据比如来源、设备、地理位置那就要引入访问日志表了。我建议先把基础的计数做好数据有了之后再考虑怎么展示千万别反过来。4. 部署配置与性能实测记录4.1 不用框架部署反而要更细心极简代码对部署环境的要求极低但这不代表可以乱配。我在 Nginx 下的 rewrite 配置如下server { listen 80; server_name your.domain; root /var/www/caveman; index index.php; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass unix:/run/php/php8.1-fpm.sock; } }如果没接触过 Nginx这段配置里最关键的就是 try_files 那行。它的作用是先看请求路径是不是真实存在的文件不存在就交给 index.php 去处理。也就是说你访问 /abc123服务器找不到 abc123 这个文件就会把请求交给 PHP然后 PHP 解析出短码 abc123去做数据库查询和跳转。这就是前面说的前端控制器模式落地的关键配置。Apache 用户则是在 .htaccess 里写RewriteEngine On RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [QSA,L]开发环境偷懒时还可以直接用 PHP 内置服务器php -S 0.0.0.0:8080 -t . data/router.php不过内置服务器只建议本地调试压测和生产环境还是用 Nginx PHP-FPM。4.2 压测100 并发下的真实表现完工之后我做了两轮压测。工具用的是 Apache 自带的 ab命令长这样ab -n 10000 -c 100 -k http://127.0.0.1:8080/abc123解释一下参数-n 表示总共发 10000 个请求-c 表示同时保持 100 个并发连接-k 开启 Keep-Alive。这是在本地一台 4 核 8G 的虚拟机上跑的PHP-FPM 子进程默认配置没动。结果如下压测场景请求数并发QPS平均响应时间本地 Nginx FPM10000100约 3200约 31ms同机压测 1000 条数据10000100约 2800约 35ms加开 SQLite WAL 模式后10000100约 3400约 29ms这个数据比我预想的好。QPS 3000 意味着这种极简服务单机能撑住日均千万级请求的访问压力当然这是理想情况真实网络环境下会有波动。内存占用更夸张PHP-FPM 各进程加起来不到 80MBSQLite 文件几 MB 就算很大了。如果你也想压测自己的服务我建议先压测再优化。不要一上来就上 Redis、加缓存先用原生状态压一遍用数据说话往往你会发现瓶颈根本不在代码而在某些配置细节上。4.3 影响范围这套方案到底能扛多大盘子这个项目的影响范围拆开来说分三个层次个人工具层给自己或团队内部用比如内部文档、协作工具分享链接毫无压力。数据量在几百万条以内都能轻松运行。中小业务层可以为某个独立业务提供短链能力比如活动页、二维码跳转。只要不是全国性大促那种级别的流量洪峰单机完全够。服务化平台层如果你想把它包装成对外开放的短链服务就需要认真考虑限流、鉴权、后台管理、监控告警这些能力了。这时候极简的代价就体现出来了——所有东西都要自己补。我的判断是caveman 的定位是个人/中小业务的短链基础设施而不是高可用云服务。这并不丢人反而是一种清醒。很多项目的失败恰恰是试图用一套架构同时服务所有场景最后什么都做不好。5. 实战中踩过的坑与排查记录5.1 问题速查表最常见的四个坑现象根本原因排查方式解决方案短码偶尔重复随机短码碰撞查看数据库 unique 约束日志改用自增ID混淆或捕获异常重试并发写入报 database is lockedSQLite 写锁冲突看 PHP 错误日志启用 WAL 模式并设置 busy_timeout访问短链总是跳到首页Nginx rewrite 未生效检查 try_files 顺序确保把 /index.php?$query_string 放最后链接太长时被截断服务端头字段默认限制查看 response header确认跳转链接放 Location 头而非 HTML这个表是我实际调试血泪的浓缩版每一项都能展开讲一大段。5.2 三个让我印象深刻的案例第一个案例是并发写入引发的丢数据恐慌。当时我小批量导入一批链接脚本开多线程去调接口结果日志里疯狂出现 database is locked。查了一圈发现 SQLite 默认情况下写事务会持有整个数据库的排他锁并发写就会撞车。解决方式很简单两条 SQLPRAGMA journal_mode WAL; PRAGMA busy_timeout 5000;第一条把日志模式改成 WAL读操作和写操作不再互相阻塞第二条是告诉 SQLite如果遇到锁最多等 5 秒而不是立刻报错返回。加了这两行之后并行写入稳定很多。第二个案例是精心设计的短码突然跳过序。排查时我发现短码长度对不上比如上一秒创建的是七位下一秒就跳到十位。后来发现是因为插入占位记录时事务失败回滚了但 SQLite 的自增 ID 不会因回滚而重用所以 ID 就跳号了。这个在单用户短链场景完全无所谓但你如果拿 ID 做业务统计或者希望短码连续生成就得注意这个表现自增跳号在数据库里是正常现象不是 bug。第三个案例是URL 里的参数被吞了。有一次我缩短一个带查询参数的链接结果跳转后参数丢了。排查发现是我在拼接 header Location 时自己多了一步 urlencode把原本是 ? 和 的字符全部转义成了 %3F 和 %26。修复就是直接使用数据库里原始 URL 作为 Location 值不做任何二次处理。这种问题通常是自己给自己加的戏保持数据原始性就能避免。5.3 后续演进极简不是终点但起点要好caveman 用了一段时间后我给它加了一些功能但每加一个都会问自己一句这符合穴居人哲学吗我在 SQLite 之外加了 Redis 做短码到目标地址的缓存跳转路径变成先查缓存未命中再查 SQLite。这个加得值因为短链的访问模式天然是热点集中一条病毒传播的链接可能贡献 90% 的流量缓存能极大降低数据库压力。但我始终没加用户系统和权限管理因为目前没有外部用户加了就是过度设计。一个功能是否应该加入我习惯用一条标准判断这个功能会不会改变现有代码的简单性如果加入后需要引入依赖、需要改表结构、需要增加状态管理那就要停下来重新评估。这就像穴居人的生活你不需要囤积一堆用不上的工具但磨快那把最常用的石斧却永远是值得的。最后分享一个我在这套小东西里得到的最深体会极简不等于简陋。把需求拆到不能再拆每一个留下来的部分都做到扎实可靠这种少而全的完成度反而比大而全的系统更令人舒适。很多问题不是靠加东西解决的而是靠不加东西避免的。