各位老铁,下午好!我是你们的老朋友,那个曾经在服务器后面熬过无数个通宵,现在依然试图用PHP拯救世界的资深编程专家。
今天我们不聊虚的,也不整那些花里胡哨的微服务架构(除非你没得选)。今天我们直接切入痛点,聊聊PHP在处理“聊天消息已读/未读”以及“离线消息推送”这两大终极难题时,如何像外科医生一样精准,又像街头混混一样灵活。
第一部分:已读未读——一场关于“方框”的哲学思辨
首先,我们得搞清楚一个问题:已读未读到底意味着什么?
在微信里,它是一个灰色的勾;在钉钉里,它是一个蓝色的勾;在早期的PHP论坛里,它可能只是数据库里的一行记录。对于程序员来说,已读未读本质上就是一个状态变更。消息从“待发送”变成“发送中”,再变成“已送达”,最后变成“已阅读”。
但PHP有个死对头叫“无状态”。一旦你的PHP脚本执行完毕,它就像个走肾不走心的渣男,瞬间把内存里的东西全扔了。所以,我们要靠什么来记仇?靠数据库。
1. 数据库设计:怎么存才不乱?
我们得建两张表。别嫌多,这是为了以后好维护,就像你得把你乱扔的袜子分类放进抽屉一样。
表1:messages(消息本体表)
这就像是一个实体的包裹,不管谁读,包裹还在。
CREATE TABLE `messages` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`from_user_id` INT NOT NULL COMMENT '发信人',
`to_user_id` INT NOT NULL COMMENT '收信人',
`content` TEXT NOT NULL COMMENT '消息内容',
`is_read` TINYINT(1) DEFAULT 0 COMMENT '是否已读(0未读,1已读)',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
表2:message_reads(阅读状态明细表)
这表是干嘛的?为了记录谁在什么时候读了哪条消息。万一有人想伪造已读状态(比如老板假装没看到你的请假条),这张表就是铁证。
CREATE TABLE `message_reads` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`message_id` BIGINT UNSIGNED NOT NULL COMMENT '关联messages表',
`user_id` INT NOT NULL COMMENT '谁读的',
`read_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY `idx_msg_user` (`message_id`, `user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计思路解析:
你可能问,为什么不在messages表里直接存一个is_read字段?
好问题!如果你的聊天是“群聊”,A读了B的消息,C读了D的消息。如果只存一个全局的is_read,A读了,全群都变已读了吗?这显然不对。
还有,如果A读了,C再读,数据会覆盖吗?所以,用一张独立的message_reads表,专门记录“一对一”的阅读关系,这才是正道。
2. 发送消息:泼出去的水,泼多少?
当用户A给用户B发消息时,PHP脚本需要做什么?简单,插入数据。
<?php
// 脚本:sendMessage.php
function sendMessage($fromId, $toId, $content) {
$pdo = new PDO('mysql:host=localhost;dbname=chat_db', 'root', 'password');
// 开启事务,保证原子性。别问我为什么,问就是怕半路出岔子。
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("INSERT INTO messages (from_user_id, to_user_id, content, is_read) VALUES (?, ?, ?, 0)");
$stmt->execute([$fromId, $toId, $content]);
// 如果是系统推送,可能还要处理离线消息队列(后面细说)。
$pdo->commit();
return true;
} catch (PDOException $e) {
$pdo->rollBack();
error_log("发消息翻车了:" . $e->getMessage());
return false;
}
}
// 使用示例
sendMessage(1, 2, "今晚吃什么?");
?>
3. 查询未读数:这就是那该死的“红点”
当用户B打开APP,首先看到的不是消息内容,而是右上角那个像血管一样跳动的小红点。PHP需要负责告诉用户:“你漏了5条消息”。
这里有个技巧:UNION ALL。我们需要把发给B的消息,加上B发给别人的消息,混合在一起展示(通常是按时间倒序)。
<?php
function getConversation($userId) {
$pdo = new PDO('mysql:host=localhost;dbname=chat_db', 'root', 'password');
// 查询发给用户的未读消息
$sql = "
SELECT id, from_user_id, content, is_read, created_at
FROM messages
WHERE to_user_id = :uid AND is_read = 0
";
// 查询用户发出的消息(未读状态通常指对方没看)
$sql .= " UNION ALL
SELECT id, from_user_id, content, is_read, created_at
FROM messages
WHERE from_user_id = :uid AND to_user_id != :uid
";
// 排序,最新的在最上面
$sql .= " ORDER BY created_at DESC LIMIT 50";
$stmt = $pdo->prepare($sql);
$stmt->execute(['uid' => $userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
?>
4. 标记已读:把红点去掉
用户点开某条消息,然后PHP脚本执行markAsRead。这时候有两个动作:
- 把这条消息的
is_read改成1。 - 在
message_reads表里记录一条日志:用户ID=2,消息ID=100,时间=现在。
<?php
function markMessageAsRead($messageId, $userId) {
$pdo = new PDO('mysql:host=localhost;dbname=chat_db', 'root', 'password');
$pdo->beginTransaction();
try {
// 1. 更新主表的阅读状态
$stmt = $pdo->prepare("UPDATE messages SET is_read = 1 WHERE id = :mid");
$stmt->execute(['mid' => $messageId]);
// 2. 插入阅读记录表(防止并发重复插入)
$stmt = $pdo->prepare("INSERT IGNORE INTO message_reads (message_id, user_id) VALUES (:mid, :uid)");
$stmt->execute(['mid' => $messageId, 'uid' => $userId]);
$pdo->commit();
return true;
} catch (Exception $e) {
$pdo->rollBack();
return false;
}
}
?>
顺便插一句:这里用INSERT IGNORE是为了防止那个经典的Bug——用户手抖点了两下,数据库主键冲突报错。
第二部分:离线消息推送——如何给死人送信?
好了,现在我们已经解决了“死去的消息”(已读未读)。接下来,我们面对的是更难的问题:用户离线了怎么办?
这就是离线消息推送。你想,你发了一条消息,对方去上厕所了(或者睡着了,或者手机没电了)。当你醒来想看消息时,你当然希望手机能“蹦”出来告诉你:“嘿,你以前丢了3条消息。”
在PHP里,这事儿比较麻烦。因为PHP是“请求-响应”模型,发完消息就跑路了。它不像Node.js或者Go,可以抱着连接一直聊。PHP得换个思路:消息队列。
1. 核心思想:生产者与消费者
我们把流程拆开:
- 生产者(PHP脚本):用户发消息 -> 把消息扔进“队列”。
- 队列(Redis/RabbitMQ):消息的停车场。
- 消费者(PHP脚本,或者叫Worker):这个家伙是个死宅,整天守在服务器上,从队列里抓取消息,然后通过短信、邮件或者WebSocket推送给用户。
2. 利用Redis实现简单的离线推送
为什么用Redis?因为它快,像闪电一样快。而且PHP对Redis的支持那是出了名的友好。
第一步:发送消息时,把“推送任务”扔进去
<?php
// send_message_with_push.php
function sendMessageWithPush($fromId, $toId, $content) {
// 1. 先存数据库(确保消息不丢)
// ...(省略上面的数据库插入代码)...
$msgId = 1001; // 假设插入后ID是1001
// 2. 构造一个“推送任务”
// 我们把任务结构化,比如:{"type": "push", "uid": 2, "msg": "hello"}
$task = json_encode([
'type' => 'offline_msg',
'target_user' => $toId,
'content' => $content,
'msg_id' => $msgId
]);
// 3. 把任务塞进Redis队列
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
// LPUSH是推到队头,BRPOP是队尾取。这里随便。
$redis->LPUSH('message_queue', $task);
return true;
}
?>
第二步:编写一个守护进程(Worker)去处理
你需要在一个终端窗口运行这个脚本,让它一直跑,别停!
<?php
// offline_worker.php
// 这个脚本就像个自动售货机,一直在那等着有人买东西
$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
echo "离线消息监听器已启动,正在死守队列...n";
while (true) {
// BRPOP是阻塞读取,如果队列空了,它就停下来等(睡觉)。
// 当有新任务进来,它会“嘭”地一下醒来。
// 第一个参数是队列名,第二个是超时时间(秒),0表示永久阻塞。
$result = $redis->BRPOP('message_queue', 0);
// $result是一个数组:['message_queue', '任务内容']
// 我们取出第二个元素,就是JSON字符串
$taskJson = $result[1];
$task = json_decode($taskJson, true);
if ($task['type'] == 'offline_msg') {
$uid = $task['target_user'];
$msg = $task['content'];
// --- 这里是关键:如何推送给离线用户? ---
// 方案A:短信推送(需要接入阿里云/腾讯云短信SDK)
// sendSms($uid, "您有新消息:" . $msg);
// 方案B:邮件推送
// sendEmail($uid, "新消息提醒", $msg);
// 方案C:Web推送(稍微复杂点,需要结合FCM/APNs)
// pushToWeb($uid, $msg);
echo "已推送离线消息给用户 {$uid}: {$msg}n";
}
}
?>
进阶优化:消息堆积怎么办?
如果用户长时间不在线,Redis队列会不会被挤爆?会的。
这时候,你需要一个定时任务(Cron Job)。比如每5分钟跑一次脚本,把Redis里积压的几千条离线消息,打包成一个大的通知(例如:“你错过了15条消息,点击查看”),然后通过短信或者邮件一次性推过去。
这叫“消息聚合”,既节省了短信费,用户体验也更好。
第三部分:实时连接与心跳检测——不睡觉的PHP
上面说的都是“离线”的。现在,用户就在线呢。他拿着手机,屏幕亮着,看着你的网页。
这时候,你不能靠PHP去“问”他:“嘿,你有新消息吗?”
这就像你去追一个美女,你得拉着她的手(建立长连接),而不是隔三差五发个短信问“我在你心里有位置吗?”。
PHP在长连接这块,传统上是搞不定的。但时代在进步,虽然PHP还是那个PHP,但我们可以借助Swoole或者Workerman这些“外挂”,强行让PHP支持WebSocket。
1. 建立WebSocket连接
假设我们有个WebSocket服务,监听在9000端口。前端JS连接上来后,PHP就守着这个Socket。
// server.php (使用Swoole)
$server = new SwooleWebSocketServer("0.0.0.0", 9501);
// 当有新用户连接进来
$server->on('open', function ($server, $req) {
echo "用户 {$req->fd} 进入了聊天室n";
// 这里可以查数据库,把这个fd(文件描述符)和用户ID绑定,方便以后定向推送
});
// 当用户发消息过来
$server->on('message', function ($server, $frame) {
echo "收到来自 {$frame->fd} 的消息: {$frame->data}n";
// 解析消息,把消息存数据库...
// 然后广播给所有人(或者指定人)
// $server->push($frame->fd, json_encode(['msg' => '我收到了你的消息']));
});
// 当连接断开
$server->on('close', function ($server, $fd) {
echo "用户 {$fd} 离开了n";
});
$server->start();
2. 心跳检测:防止“僵尸连接”
WebSocket虽然像恋爱,但网络是个渣男。连接可能会突然断开,用户可能切屏了,或者路由器抽风了。
这时候服务器还以为用户在线,拼命往这个连接里发消息,结果全是“发送失败”。
我们需要心跳机制。
客户端每隔10秒发个包:“我还活着,别挂我。”
服务端如果15秒没收到包,就认为对方挂了,主动关闭连接,并从内存/数据库里把这个连接ID踢掉。
// 简单的心跳逻辑伪代码
$last_heartbeat = time();
$server->on('message', function ($server, $frame) use (&$last_heartbeat) {
$last_heartbeat = time(); // 收到消息就是活着的标志
// 处理消息逻辑...
});
// 在server的tick事件中定期检查(比如每5秒检查一次)
$server->on('tick', function ($server) {
if (time() - $last_heartbeat > 15) {
// 假设我们维护了一个$fd_map保存了fd->uid的映射
$uid = $fd_map[$fd];
echo "用户 {$uid} 心跳超时,断开连接n";
$server->disconnect($fd);
}
});
第四部分:那些年我们踩过的坑(避坑指南)
写代码嘛,不踩两个坑怎么叫资深?下面这几个雷区,如果你不注意,你的聊天系统可能明天就崩了。
1. 数据库锁死:SELECT FOR UPDATE
你想想这个场景:用户A发了100条消息,用户B正在看未读数。如果用户B的查询是SELECT * FROM messages WHERE to_user_id=2,MySQL会瞬间把整张表锁住(或者至少锁相关的索引)。
等到用户B读完了,用户A再想发消息?抱歉,排队去,锁没解开。
解决方案: 使用SELECT ... FOR UPDATE进行悲观锁,或者在代码逻辑上,先查总数,查完立刻释放连接。或者更狠一点,用Redis做计数器,先把计数器减掉,异步再去查详情。
2. 消息丢失:Redis断电
你用Redis做队列,结果服务器突然断电,Redis重启了。队列里的任务全没了!用户发消息了,数据库没存,Redis也没存(或者存了一半)。
解决方案: 永远不要相信Redis作为唯一的持久化存储。把数据库写入作为第一优先级,Redis只作为“加速器”和“缓冲区”。
3. 竞态条件:并发插入
两个用户几乎同时点击“已读”。A执行了UPDATE messages SET is_read=1,B也执行了。A改成了1,B看到还是1(因为数据库没刷新),于是B又执行了一次UPDATE。
解决方案: 数据库层面加唯一索引(像前面SQL里的UNIQUE KEY),或者应用层用锁。
4. 代码风格:不要在循环里查数据库
这可能是新手最容易犯的错误。
// 垃圾代码
foreach ($users as $user) {
$unread = queryDb("SELECT count(*) FROM messages WHERE to_user_id={$user['id']}");
// 发送通知...
}
如果有1000个用户,这个脚本会跑一辈子。
解决方案: 使用SQL的GROUP BY和子查询,或者在PHP里先把所有ID取出来,用WHERE id IN (...)一次性查出来。
结语
好了,老铁们。
我们今天用PHP把聊天的骨架搭起来了。
- 已读未读:靠数据库的
is_read字段和专门的message_reads表,配合事务,保证数据不丢失。 - 离线推送:靠Redis队列,利用“生产者-消费者”模式,把PHP的“瞬时性”转化为“持续性”。
- 实时连接:如果你追求极致的实时,得祭出Swoole/Workerman这种神器,把PHP变成多线程怪兽。
PHP虽然不是造火箭的引擎,但它是宇宙中最通用的胶水。只要逻辑通顺,哪怕是用面条代码堆出来的架构,也能撑起一个几百万用户的大厂聊天室。
现在,拿起你的键盘,去实现你的聊天梦吧!记得把代码写得优雅点,别像我的代码那样,全是屎山。还有,别在女朋友面前装酷,聊天状态一定要实时同步,那是基本素养!
下课!