简介:这是一份面向PHP入门者与轻量级Web应用开发者的在线聊天室源码,采用单文件、无数据库设计,适合快速搭建临时聊天页面、课程演示或测试环境。压缩包仅含1个PHP文件,大小约8KB,部署时上传到支持PHP的服务器即可运行,省去建库与多文件配置。源码围绕请求处理、消息轮询、聊天记录暂存和在线用户展示展开,并提供字体样式自定义入口,可在PHP5与PHP7环境下使用,便于理解无数据库条件下实时通信的基本实现。已有1879人学习下载,可作为PHP基础、Ajax轮询与简易聊天室结构的参考案例;同时也需注意单文件结构在可维护性和数据持久化方面的限制,适合先跑通功能再按需扩展。
1. 单文件 PHP 聊天室:为什么“无数据库”反而更考验文件锁
PHP 在线聊天室源码(单文件无数据库版)这类资源,最容易被误判成“玩具”:上传一个入口文件,打开网页就能发言,看起来没有工程含量。真把它放进临时协作页、内网活动页或演示大屏时,问题立刻变成并发写文件、轮询频率、消息完整性和目录权限。单文件、无数据库的取舍很明确:省掉独立数据库服务的安装与运维,把状态压到磁盘上的消息文件;代价是并发控制和清理策略要自己扛。它适合需要快速上线一个小范围聊天窗口、又不想引入数据库的开发者,也适合拿它拆解 PHP 文件锁和前后端轮询链路;不适合高并发公网群聊,也不适合需要审计、检索、多房间权限的正式系统。
2. 单文件架构拆解:入口路由、消息落盘与轮询链路
单文件不等于“只有一段代码”。真正能跑稳的版本,入口文件里通常做三件事:识别请求类型、把消息追加到磁盘、把增量消息返回给前端。把这三件事拆开看,才能判断这份 PHP 在线聊天室源码是“能演示”还是“能短时间扛住几个人同时说话”。我拿到这类包,第一反应不是改界面,而是先看消息文件用什么格式、读写有没有加锁、前端轮询有没有游标。下面按请求进入、落盘、拉取三段拆。
2.1 入口路由:一个 index.php 怎么区分拉取和发送
常见做法是用action参数区分:GET 请求走poll,POST 请求走send。这样不需要 URL 重写,放到子目录也能跑,单文件部署最省事。入口处先固定 JSON 响应头和禁用缓存,再根据请求方法路由。下面这段是路由骨架,函数appendMessage和readMessages在 2.2 节补全。
<?php declare(strict_types=1); header('Content-Type: application/json; charset=utf-8'); header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); $dataDir = __DIR__ . '/data'; $msgFile = $dataDir . '/messages.jsonl'; $action = $_GET['action'] ?? $_POST['action'] ?? 'poll'; try { if ($_SERVER['REQUEST_METHOD'] === 'POST' && $action === 'send') { // 发送分支:只接受表单字段,长度在服务端再截一次 $user = trim((string)($_POST['user'] ?? '')); $text = trim((string)($_POST['text'] ?? '')); if ($user === '' || $text === '') { throw new RuntimeException('empty user or text'); } if (mb_strlen($text, 'UTF-8') > 500) { $text = mb_substr($text, 0, 500, 'UTF-8'); } $msg = [ 'id' => bin2hex(random_bytes(8)), 'user' => mb_substr($user, 0, 20, 'UTF-8'), 'text' => $text, 'ts' => time(), ]; appendMessage($msgFile, $msg); echo json_encode(['ok' => true, 'message' => $msg], JSON_UNESCAPED_UNICODE); } else { // 拉取分支:since 是游标,limit 防止一次拉太多 $since = (string)($_GET['since'] ?? ''); $limit = max(1, min(100, (int)($_GET['limit'] ?? 50))); $rows = readMessages($msgFile, $limit, $since); echo json_encode(['ok' => true, 'messages' => $rows], JSON_UNESCAPED_UNICODE); } } catch (Throwable $e) { http_response_code(400); echo json_encode(['ok' => false, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE); }这段路由的关键参数是action、since和limit。action决定读还是写;since是上一次拿到的最后一条消息 ID,用来做增量拉取,避免每次把全部历史重新传一遍;limit限制单次返回条数,避免消息文件很大时一次响应过重。发送端用bin2hex(random_bytes(8))生成消息 ID,不依赖自增序列,两个请求同时写入也不会因为抢 ID 打架。mb_substr是防止用户把昵称和正文写成超长字符串,把消息文件撑爆。
提示:单文件源码里经常把
action写成$_REQUEST['action'],这会把 Cookie 和 GET/POST 混在一起,排查问题时更难定位。入口处明确用$_GET或$_POST,后面会省很多事。
2.2 消息落盘:JSON Lines 比 JSON 数组更适合追加写
单文件无数据库聊天室常见的存储格式有两种:一个 JSON 数组整体读写,或者一行一条 JSON 的 JSON Lines。前者看起来整齐,但每次发送都要把整个数组读出来、反序列化、追加、再整体写回去,消息一多就变成“全量重写”,并发时更容易互相覆盖。后者只追加一行,读的时候逐行解析,坏掉一行不影响其他行,轮转也简单。我一般会选 JSON Lines,字段固定为id、user、text、ts,需要扩展再加room或color。
function appendMessage(string $file, array $msg): void { $dir = dirname($file); if (!is_dir($dir)) { mkdir($dir, 0775, true); } $fp = fopen($file, 'c+'); if ($fp === false) { throw new RuntimeException('open message file failed'); } try { if (!flock($fp, LOCK_EX)) { throw new RuntimeException('lock failed'); } fseek($fp, 0, SEEK_END); $line = json_encode($msg, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); fwrite($fp, $line . PHP_EOL); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); } } function readMessages(string $file, int $limit, string $since = ''): array { if (!is_file($file)) { return []; } $fp = fopen($file, 'r'); if ($fp === false) { return []; } $rows = []; try { if (flock($fp, LOCK_SH)) { while (($line = fgets($fp)) !== false) { $line = trim($line); if ($line === '') { continue; } $row = json_decode($line, true); if (!is_array($row) || !isset($row['id'])) { continue; // 跳过坏行,不让一行脏数据拖垮整个聊天室 } if ($since !== '' && $row['id'] === $since) { $rows = []; continue; } $rows[] = $row; } flock($fp, LOCK_UN); } } finally { fclose($fp); } return array_slice($rows, -$limit); }appendMessage用c+打开,指针移到末尾后写入,flock($fp, LOCK_EX)是排他锁,保证同一时刻只有一个请求在追加。fflush不能省,尤其是消息很短、PHP 进程又频繁结束时,不刷新缓冲区可能出现“返回成功但文件里没有”的假象。JSON_UNESCAPED_UNICODE让中文按原样写入,不变成\uXXXX,排查消息文件时肉眼能读;JSON_UNESCAPED_SLASHES只是让内容更干净,不影响解析。
readMessages用共享锁LOCK_SH,允许多个读请求同时进行,但挡住正在写的请求。since的逻辑是:读到上次最后一条 ID 后清空已收集结果,只保留它之后的消息;如果since不在文件里,比如消息被轮转清理了,就把当前文件里的最近limit条返回,前端靠 ID 去重。这个实现会全量扫描消息文件,几千行没问题,上万行就要配合第 4 章的轮转,或者改成从文件尾部倒读。单文件方案不怕代码短,怕的是没有边界。
2.3 轮询链路:短轮询、长轮询与 SSE 的边界
单文件 PHP 聊天室最常用的是短轮询:前端每隔两三秒发一次 GET,把since带上,后端返回增量。它的好处是部署门槛低,普通 PHP 进程就能跑,不需要额外服务;缺点是空轮询多,消息延迟等于轮询间隔。长轮询会让后端挂住请求等新消息,实时性更好,但会占用 PHP 进程,几个人还行,几十个人同时挂住就把进程池吃满。SSE 是服务器推流,PHP 端要持续输出并关闭缓冲,Web 服务器也要配合,单文件包如果没做这层适配,直接开 SSE 很容易变成“页面一直转圈”。
下面这段前端是短轮询写法,间隔 2500 毫秒,带lastId游标、busy防请求堆积、document.hidden暂停。渲染用textContent,不拼innerHTML,这是防 XSS 的底线。
const box = document.querySelector('#messages'); let lastId = ''; let busy = false; async function poll() { if (busy || document.hidden) return; busy = true; try { const url = new URL('index.php', location.href); url.searchParams.set('action', 'poll'); url.searchParams.set('limit', '50'); if (lastId) url.searchParams.set('since', lastId); const res = await fetch(url, { cache: 'no-store' }); const data = await res.json(); if (!data.ok) throw new Error(data.error || 'poll failed'); for (const m of data.messages) { const row = document.createElement('div'); const user = document.createElement('strong'); user.textContent = m.user + ':'; const text = document.createElement('span'); text.textContent = m.text; row.append(user, text); box.append(row); lastId = m.id; } box.scrollTop = box.scrollHeight; } catch (e) { console.error(e); } finally { busy = false; } } setInterval(poll, 2500); document.addEventListener('visibilitychange', () => { if (!document.hidden) poll(); });参数上,2500毫秒是内网小范围聊天比较稳的折中:再低到 500 毫秒,请求数会明显上升,PHP 进程和磁盘都白忙;再高到 10 秒,对话节奏就断了。busy是防止上一次请求还没回来又发下一次,网络慢的时候没有它会出现请求堆积。document.hidden让页面切到后台时暂停轮询,减少无意义请求。cache: 'no-store'和入口处的Cache-Control要成对出现,只写一边都可能被浏览器或中间层缓存住,出现“发送成功但对方不刷新”的经典现象。
如果要把短轮询换成长轮询,后端需要在新消息出现前循环检查文件修改时间,并设置最大等待时间,比如 20 秒;同时 Web 服务器的fastcgi_read_timeout要大于这个等待时间,否则网关先断,前端收到 504。SSE 则要关闭fastcgi_buffering,并处理客户端断开。单文件源码如果没有明确说明支持哪种模式,先用短轮询跑通,再考虑替换,不要一上来就改传输层。
3. 把源码跑起来:PHP 环境、目录权限与 Web 服务器配置
单文件包拿到手,最怕直接扔到线上目录就打开。先确认 PHP 能不能解析、消息目录能不能写、Web 服务器有没有把.jsonl暴露出去,这三步没过,后面改界面都是白费。下面按环境检查、权限、服务器配置、curl 联调四段走,命令里的路径按实际部署目录替换。
3.1 环境检查:先看语法,再看扩展
不要凭标题猜 PHP 版本。入口文件如果用了match、构造器属性提升、str_contains,至少需要 PHP 8.0;用了箭头函数或类型属性,至少 7.4;这些是语法决定的,不是源码说明里写没写。先跑语法检查,再列扩展。单文件聊天室通常只强依赖json,mbstring用于中文截断,session用于频率限制或在线状态,fileinfo一般用不上。
php -v php -m | grep -E 'json|mbstring|session' php -l index.phpphp -l只做语法检查,不执行代码,适合在部署前跑一遍。php -m看扩展是否加载,json在现代 PHP 里通常内置,mbstring如果没装,mb_substr会直接报致命错误,表现为发送时 500、页面空白。遇到这种情况不要先怀疑前端,先看 PHP 错误日志里有没有 “Call to undefined function mb_substr”。
注意:有些单文件包为了兼容老环境,会用
substr截中文,结果把多字节字符切成乱码。可以在入口处判断function_exists('mb_substr'),没有就退回substr,但更稳妥的是把mbstring装上。
3.2 目录权限与消息文件初始化:777 是反面教材
消息文件必须可写,但不应让 Web 服务器拥有整个项目目录的写权限。常见做法是只给data目录写权限,入口文件保持只读。不要用chmod 777图省事,那等于让同服务器上任何进程都能改消息文件。下面命令以常见 Web 用户www-data为例,实际用户看你的 PHP-FPM 配置。
cd /path/to/chat mkdir -p data touch data/messages.jsonl chown -R www-data:www-data data chmod 775 data chmod 664 data/messages.jsonl775给目录,意思是属主和属组可写,其他用户只读执行;664给文件,属主和属组可写。如果 PHP 进程和你的登录用户不在同一组,可以用setfacl单独给 Web 用户写权限,而不是把权限放到 777。消息文件要先touch出来,避免第一次并发发送时两个请求同时mkdir或同时创建文件,出现权限归属不一致。
还要阻止消息文件被直接访问。单文件方案常把data放在 Web 根下,如果服务器没配规则,访问/data/messages.jsonl就能把聊天记录整份拉走。Nginx 下加一条按后缀拒绝的规则,或者把data移到 Web 根外,用入口文件里的绝对路径指过去。后者更干净,但改路径时注意别把__DIR__写死成开发机路径。
3.3 Web 服务器配置:轮询超时、缓冲与静态缓存
短轮询对服务器配置要求不高,但有两个点要确认:PHP 请求的超时时间要大于前端轮询间隔,消息文件不能被当成静态资源缓存。如果后面要试长轮询或 SSE,还要关掉反向代理缓冲,否则后端一直在输出,前端却收不到。下面是一段 Nginx 配置骨架,fastcgi_pass按实际套接字或地址改。
location ~ \.jsonl$ { deny all; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass unix:/run/php/php-fpm.sock; fastcgi_read_timeout 30s; fastcgi_buffering off; }deny all挡住.jsonl直接访问;fastcgi_read_timeout 30s给长轮询留余量,短轮询用不到这么长但不会坏事;fastcgi_buffering off主要针对 SSE 或流式输出,普通 JSON 响应关不关影响不大。client_max_body_size默认值通常够聊天文本用,如果发现长消息发送后 413,再单独调这个值,不要一上来放到很大。
PHP 侧还要看max_execution_time。短轮询脚本很快结束,不受影响;长轮询会把脚本挂住,超过上限会被终止。session_write_close()也值得注意:如果入口文件开了 session 又在长轮询里一直不关,同一个用户的其他请求会被 session 文件锁挡住,表现为“发消息没反应,刷新才好”。
3.4 首次联调:用 curl 验证发送与拉取
页面能打开不代表链路通。先用 curl 直接打接口,把前端变量排除掉,确认 POST 能写、GET 能读、返回是 JSON 而不是 HTML 错误页。下面两条命令分别测发送和拉取,127.0.0.1按实际地址替换。
curl -i -X POST 'http://127.0.0.1/index.php' \ --data-urlencode 'action=send' \ --data-urlencode 'user=test' \ --data-urlencode 'text=hello' curl -s 'http://127.0.0.1/index.php?action=poll&limit=10'第一条看 HTTP 状态码和响应体,正常应返回{"ok":true,"message":{...}};如果返回 500 且内容是 HTML,说明 PHP fatal error,去错误日志看具体行。第二条看messages数组里有没有刚写入的记录,没有就检查data/messages.jsonl是否真的多了一行。--data-urlencode会自动处理中文和特殊字符,比手拼 URL 可靠。
联调时还可以故意把data目录改成只读,再发一次消息,确认接口返回明确错误而不是空白页。这个动作能提前暴露权限问题,比上线后用户反馈“发不出去”再查要省时间。错误信息不要在生产环境把文件路径和堆栈直接吐给前端,入口处 catch 之后返回简短错误,详细内容写日志。
4. 并发写入与消息完整性:flock、截断和轮转的工程化处理
单文件聊天室的“单文件”只是部署形态,运行起来仍然有多个请求同时读写同一个消息文件。两个人同时说话,两个 PHP 进程同时fwrite,没有锁就可能一行变两半,或者后写的覆盖先写的。这一章把锁、轮转、损坏恢复三件事拆开,目标是让消息文件在几十个人短时间刷屏时仍然可读、可恢复。
4.1 flock 锁住的是文件句柄,不是“消息队列”
flock是建议锁,锁的是打开的文件句柄,不是文件名。两个进程都按同样的规则flock才有效;一个进程用file_put_contents直接写,另一个用flock,两者并不互斥。所以单文件源码里最怕混用:发送用file_put_contents($file, $line, FILE_APPEND | LOCK_EX),读取用file_get_contents,读的时候可能正好读到写了一半的行。统一做法是读写都走fopen+flock,写用LOCK_EX,读用LOCK_SH。
$fp = fopen($file, 'c+'); if ($fp === false) { throw new RuntimeException('open failed'); } try { if (!flock($fp, LOCK_EX)) { throw new RuntimeException('lock failed'); } fseek($fp, 0, SEEK_END); fwrite($fp, $line . PHP_EOL); fflush($fp); flock($fp, LOCK_UN); } finally { fclose($fp); }LOCK_EX是排他锁,同一时刻只允许一个写者;LOCK_SH是共享锁,多个读者可以同时读,但写者要等读者释放。fflush把 PHP 缓冲区刷到操作系统,减少“接口返回成功但文件没内容”的窗口。flock在本地文件系统上通常可靠,在网络文件系统上行为不稳定,单文件聊天室如果部署在共享挂载目录,锁可能失效,消息完整性就没有保障。遇到这种环境,要么换本地磁盘,要么换存储方案,不要只改代码。
还有一个细节:锁的粒度是整个消息文件。消息量大时,读请求拿共享锁也会挡住写请求,写请求拿排他锁也会挡住读请求。短轮询场景下每次读写都很快,影响不明显;如果要做长轮询,不要让长轮询一直持有共享锁不放,否则发送端会被卡住。正确做法是长轮询循环里短暂加锁检查,释放后再usleep,不要抱着锁睡觉。
4.2 轮转策略:按行数还是按天
消息文件不能无限长。按天轮转适合有“今天/昨天”概念的聊天室,按大小轮转适合控制单文件扫描成本。单文件方案没有定时任务也能做惰性轮转:每次发送前检查文件大小,超过阈值就改名备份,再创建新文件。阈值不要设得太小,1 MB 到 5 MB 对文本聊天足够用;太小会频繁轮转,历史消息断得太快;太大则每次拉取都要扫更多行。
function rotateIfNeeded(string $file, int $maxBytes = 1048576): void { if (!is_file($file) || filesize($file) < $maxBytes) { return; } $lockFile = $file . '.lock'; $lf = fopen($lockFile, 'c'); if ($lf === false) { return; } try { if (!flock($lf, LOCK_EX)) { return; } clearstatcache(true, $file); if (filesize($file) >= $maxBytes) { $bak = $file . '.' . date('YmdHis') . '.bak'; rename($file, $bak); touch($file); chmod($file, 0664); } flock($lf, LOCK_UN); } finally { fclose($lf); } }这段用单独的.lock文件协调轮转,避免两个请求同时判断“该轮转了”然后一个把文件改名、另一个往旧句柄里写。锁外先做一次filesize是快速返回,锁内再clearstatcache后判断一次,防止竞态。rename在同目录内是原子操作,读请求要么读到旧文件,要么读到新文件,不会读到半个文件。新文件创建后要补chmod,否则可能继承到奇怪权限,导致后续写入失败。
轮转之后要清理旧备份,不然磁盘会被.bak慢慢吃满。清理可以放在同一个函数里,按文件修改时间删掉超过 N 天的备份,也可以单独写一个维护脚本。清理时不要按文件名排序删最新,按filemtime更稳。单文件方案没有数据库的归档能力,保留多少历史要在部署时就定好,不要等磁盘报警再处理。
4.3 原子替换与损坏恢复:临时文件、备份和校验
JSON Lines 的优点是坏一行只丢一条,但如果是整体重写历史、裁剪消息,就不能直接file_put_contents覆盖原文件。写到一半进程被杀,原文件就毁了。正确做法是写临时文件,fflush后rename替换。rename在同一文件系统内是原子的,读请求不会看到写了一半的目标文件。
function rewriteLines(string $file, array $lines): void { $tmp = $file . '.tmp.' . getmypid(); $fp = fopen($tmp, 'wb'); if ($fp === false) { throw new RuntimeException('tmp open failed'); } try { foreach ($lines as $line) { fwrite($fp, $line . PHP_EOL); } fflush($fp); } finally { fclose($fp); } if (!rename($tmp, $file)) { @unlink($tmp); throw new RuntimeException('rename failed'); } }getmypid()加到临时文件名里,避免两个请求同时写同一个临时文件。rename失败时删掉临时文件,避免留下垃圾。这个函数适合裁剪历史、合并备份,不适合高频发送;发送仍然走追加。整体重写期间,读请求可能读到旧文件,这是可接受的,因为旧文件至少是完整的。
如果消息文件已经出现坏行,不要急着手工删。先用逐行解析把能恢复的行捞出来,再写回新文件。坏行通常是并发写没加锁、磁盘满、进程被杀导致的一行截断。恢复脚本可以保留原始备份,解析失败的行走日志,人工确认后再决定丢弃。
$good = []; foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) { $row = json_decode($line, true); if (is_array($row) && isset($row['id'], $row['text'], $row['ts'])) { $good[] = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); } } rewriteLines($file, $good);这段只保留结构完整的消息,id、text、ts缺一个就丢弃。恢复前先复制一份原文件,别在唯一副本上操作。恢复后让前端重新拉全量,不要继续用旧游标,否则since找不到会触发全量返回,看起来像“消息重复”。单文件方案没有事务,靠的是写入顺序、锁和备份三件套,少一件都可能在并发时翻车。
5. 避坑与排查:单文件聊天室最常见的五类翻车现场
单文件聊天室的故障往往不是“代码不会跑”,而是“跑起来后某个环节悄悄失效”。下面五类现象在部署这类 PHP 在线聊天室源码时出现频率很高,每条按现象、原因、解决写,方便对照排查。先看现象能定位到哪一层,再决定改前端、改权限还是改服务器配置。
5.1 发送成功但对方不刷新:轮询缓存与 opcache 的锅
现象是发送者页面出现自己的消息,接口也返回ok:true,但另一个浏览器窗口一直不更新,手动刷新后才看到。常见原因是拉取接口被缓存:入口没有发Cache-Control,或者前端fetch没带cache:'no-store',浏览器直接把上一次 GET 的 JSON 返回了。还有一种情况是中间层开了页面缓存或fastcgi_cache,把index.php?action=poll当成普通页面缓存住。
解决分三层查。先看响应头有没有Cache-Control: no-store,没有就在入口处补;再给轮询 URL 加一个无意义的时间戳参数,比如_t=Date.now(),排除中间层缓存;最后检查前端定时器是否被异常清掉,setInterval的返回值有没有被别处clearInterval。如果改的是 PHP 文件但行为没变化,检查opcache.validate_timestamps是否被关掉,必要时重启 PHP 进程。别把“缓存”当成玄学,逐层看响应头和日志,很快能定位。
5.2 中文变问号或乱码:Content-Type、JSON_UNESCAPED_UNICODE 与文件编码
现象是英文消息正常,中文发送后变成???、\u4e2d或者一串乱码。原因通常有三个:入口没有设Content-Type: application/json; charset=utf-8;json_encode没加JSON_UNESCAPED_UNICODE,前端又按文本直接展示转义串;消息文件本身被编辑器存成了非 UTF-8 编码。还有一种隐蔽情况是用substr截断中文,把一个多字节字符切成半个。
解决时先统一编码:入口固定 UTF-8 响应头,json_encode带JSON_UNESCAPED_UNICODE,消息文件用 UTF-8 无 BOM 保存。截断用mb_substr($text, 0, 500, 'UTF-8'),长度判断用mb_strlen。如果数据库或文件里已经有乱码,先备份再转码,不要直接覆盖。前端渲染用textContent,不要用innerHTML拼字符串,后者还可能把转义字符当 HTML 解析。
5.3 消息串行、丢失或文件变空:flock 用错与并发写
现象是两个人同时发送,消息文件里出现半行 JSON,或者后一条把前一条覆盖,严重时文件变成 0 字节。原因多半是写入没有统一加锁,或者用file_put_contents覆盖模式而不是追加模式。另一个常见错误是读取时用file_get_contents不加共享锁,正好读到写入中的半行,json_decode失败后前端整批消息都不显示。
解决方式是读写都走fopen+flock:写用LOCK_EX,读用LOCK_SH,写完fflush再释放。不要在flock之前用file_get_contents读同一文件,那等于绕过锁。消息文件变空时先停写,复制备份,再用 4.3 的恢复脚本逐行捞。若部署在网络挂载目录,flock可能不可靠,建议换本地磁盘或改用其他存储。并发问题不要靠“加大重试”掩盖,锁的规则不统一,重试只会让坏行更多。
5.4 页面能打开但发送 500:权限、磁盘配额与 PHP 错误日志
现象是聊天页面正常显示,一按发送就 500,响应体可能是空白或 HTML 错误页。原因可能是data目录对 Web 用户不可写、磁盘满、open_basedir限制了消息文件路径、json_encode遇到非法 UTF-8 返回 false 后继续fwrite空行。还有可能是random_bytes在某些环境抛异常,入口没有 catch 到。
排查先看 PHP 错误日志,找不到就看 Web 服务器错误日志;curl -i看状态码和响应体,确认是 PHP 层还是网关层。临时在本地开display_errors可以快速定位,线上不要开,避免路径泄露。检查df -h看磁盘,检查ls -ld data看权限,检查php -i | grep open_basedir看路径限制。发送接口返回错误时给前端一个可读的短消息,详细堆栈写日志,不要把文件路径直接吐给用户。
5.5 被刷屏和 XSS:前端转义、长度限制与频率限制
现象是聊天室突然被同一账号刷屏,或者消息里带<script>标签导致页面弹窗。原因是前端用innerHTML渲染消息,后端又没有做长度和频率限制。单文件无数据库方案没有统一的用户体系,攻击者可以反复 POST,靠前端按钮禁用挡不住直接调接口。
解决分三层:渲染层用textContent或innerText,不把用户内容当 HTML;服务端限制昵称 20 字、正文 500 字,超长截断而不是报错;频率限制用 session 记录上次发送时间,比如 2 秒内只允许一条,或者用消息文件按用户统计最近 10 秒条数。需要更严一点可以加 CSRF token,token 放 session,表单和接口都校验。单文件方案不适合做复杂风控,但最起码的转义、长度和限频要有,否则一旦被刷,消息文件会迅速膨胀,轮询也会被拖慢。
6. 从能跑到好用:历史消息裁剪、在线人数与部署边界的验证
单文件聊天室能跑通之后,下一步不是加表情和皮肤,而是验证它的边界。短轮询 2.5 秒、二十人以内的小范围场景,这套结构通常够用;五十人
本文还有配套的精品资源,点击获取