PHP如何实现聊天消息已读未读状态与离线消息推送

各位老铁,下午好!我是你们的老朋友,那个曾经在服务器后面熬过无数个通宵,现在依然试图用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。这时候有两个动作:

  1. 把这条消息的is_read改成1。
  2. 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. 核心思想:生产者与消费者

我们把流程拆开:

  1. 生产者(PHP脚本):用户发消息 -> 把消息扔进“队列”。
  2. 队列(Redis/RabbitMQ):消息的停车场。
  3. 消费者(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把聊天的骨架搭起来了。

  1. 已读未读:靠数据库的is_read字段和专门的message_reads表,配合事务,保证数据不丢失。
  2. 离线推送:靠Redis队列,利用“生产者-消费者”模式,把PHP的“瞬时性”转化为“持续性”。
  3. 实时连接:如果你追求极致的实时,得祭出Swoole/Workerman这种神器,把PHP变成多线程怪兽。

PHP虽然不是造火箭的引擎,但它是宇宙中最通用的胶水。只要逻辑通顺,哪怕是用面条代码堆出来的架构,也能撑起一个几百万用户的大厂聊天室。

现在,拿起你的键盘,去实现你的聊天梦吧!记得把代码写得优雅点,别像我的代码那样,全是屎山。还有,别在女朋友面前装酷,聊天状态一定要实时同步,那是基本素养!

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注