PHP如何实现海量用户消息未读数实时同步与一致性保障

各位老铁、各位后端界的扛把子们,大家晚上好!

今天咱们坐下来,聊点“重”的。不是加班的重,是技术深度的重。咱们来探讨一个在即时通讯(IM)、电商、社交软件里头,天天见、天天想、天天让人头秃的问题——海量用户消息未读数实时同步与一致性保障

别以为这是个小问题。你要是做C端产品,没搞好这个,你后台那个统计图表就是一个“薛定谔的猫”:用户A看着5条,用户B看着3条,用户C(管理员)看着“由于网络波动,数据丢失”。这就尴尬了。

我们要解决的核心矛盾是:高并发写入 vs 实时读取一致性。这就像是理发店门口那个“已理发 X 人”的牌子,顾客进进出出,理发师手起刀落,牌子上的数字必须跟实际理发人数对得上,还得是在顾客头还没抬起来之前就变好。

好,咱们废话不多说,直接开整。


第一章:你以为的“简单”与实际上的“灾难”

很多刚入行的兄弟,一上来就写:

// 糟糕的代码示例
$user_id = 123;
$sql = "UPDATE users SET unread_count = unread_count + 1 WHERE id = {$user_id}";
$db->query($sql);

听听这SQL,多么朴实无华。但在高并发场景下,这就是一记直球。假设你现在是一个百万级用户的App,每秒钟有5000个人发消息。这就意味着你的数据库每秒钟要执行5000次 UPDATE 操作。如果你的数据库每秒钟能扛住5000次写,那是神仙,不是人。

更别提一致性了。如果这5000个请求,有的成功,有的因为网络抖动超时了,有的被数据库锁给堵住了,用户还没来得及看到“+1”,刷新一下页面,数字又回滚了。这种“翻车”现场,会让产品经理在群里把你踢出项目组。

核心痛点: 数据库是瓶颈,且强一致性(ACID)在高并发下就是缓刑。


第二章:Redis —— 我们的第一道防线

既然数据库扛不住,咱们得找个“健壮”的家伙。大家都说Redis好,那咱们就用Redis当计数器。

这时候代码变成了这样:

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// 增加未读数
$redis->incr("user:{$user_id}:unread_count");

这看起来很美,快,快到飞起。但是,问题来了。Redis虽然是内存数据库,但也是有内存限制的。如果用户从来不读消息,也不删消息,这个 user:123:unread_count 的Key就一直长存。

几个月后,这个Key还在,值可能已经是几亿了。而且,如果服务器挂了,Redis还没配置RDB/AOF持久化,这个数字就烟消云散了。这叫什么?这就叫“内存里的幽灵”。

更重要的是,如果用户在客户端点击“清除未读”,客户端怎么知道Redis里存了多少?总不能每次都发个请求去Redis查吧?那样延迟(RTT)又上来了。

所以,单靠Redis的简单 INCR,只能解决“写”的问题,解决不了“读”和“一致性”的问题。


第三章:延迟双删与异步队列 —— 真正的魔法

咱们得换个思路。不要实时同步,要准实时,或者准异步。

这就引出了两个神器:消息队列延迟双删

1. 消息队列:把“打车”变成“预约”

当用户发送消息时,我们不要直接去改Redis里的数。我们发一条消息给队列:“嘿,用户123多了1条消息”。

// 使用 Laravel Queue 示例
$messageQueue->push(new IncrementUnreadJob($userId, 1));

然后前端立马返回成功。用户觉得神速,毫秒级响应。

后台有个“消费者”,它趴在队列上,慢慢数。

class IncrementUnreadJob implements ShouldQueue
{
    public function handle()
    {
        // 这里可以加锁,防止同一个人发100条消息,队列里堆积100个任务
        // 甚至可以做批处理,把100个任务合并成1个任务
        $this->redis->incr("user:{$this->userId}:unread_count");
    }
}

这种方法的优点是什么?

  • 削峰填谷: 高峰期,消息量瞬间爆炸,数据库/Redis扛不住?没事,队列慢慢吃。
  • 解耦: 发消息的人和更新计数器的人不用知道对方在哪,只管发和吃。

2. 延迟双删:死神的凝视

刚才提到了一个问题:如果用户点开消息,把未读数清零了,但后台那个队列里的任务还在慢慢爬,这时候再执行 incr,未读数是不是又变回去了?这就叫“回滚”,非常恶心。

为了解决这个问题,我们要用 延迟双删策略

思路是这样的:

  1. 第一次删除: 用户点“清除未读”,先删除Redis里的计数器。
  2. 异步更新: 发送一条任务到队列,3秒后执行 incr(假设3秒后消息才真正展示出来)。
  3. 第二次删除: 3秒后,任务执行前,再删一次Redis里的计数器。

或者更简单的做法:发布订阅模式

用户修改未读数时,往Redis里发个广播,告诉所有人:“我的数变了”。

但为了保障一致性,最稳妥的还是最终一致性


第四章:Lua脚本 —— 原子性的保镖

你可能会问:“如果用户同时发消息,又清空消息,会不会出现竞态条件(Race Condition)?”

比如:

  1. 线程A:get (value = 5)
  2. 线程B:get (value = 5)
  3. 线程A:set (value = 6)
  4. 线程B:set (value = 6)

看起来没问题,但如果你中间要判断逻辑,比如“如果未读数大于100,就报警”,那出事就晚了。因为A和B读到的值可能不同,导致并发下的逻辑错误。

这时候,Redis的 Lua脚本 就是救世主。

Lua脚本在Redis中是原子性执行的,也就是“一条龙服务”,中间绝对不会被插入其他命令。

咱们来写一个“安全增加未读数”的Lua脚本。假设我们要实现:只有当消息确实存在且该消息未被标记为已读时,才增加未读数。

// Lua脚本
$luaScript = "
local msgId = KEYS[1]
local userId = KEYS[2]
local redis = redis.call('hmget', 'msg:'..msgId, 'status', 'receiver_id')
local status = redis[1]
local receiverId = redis[2]

-- 1. 校验消息是否存在且状态正确
if status == 'unread' and receiverId == userId then
    -- 2. 原子性增加计数器
    redis.call('incr', 'user:'..userId..':unread_count')
    return 1
else
    return 0
end
";

$redis->eval($luaScript, 2, $messageId, $userId);

这个脚本非常硬核。它在一个网络往返的时间内,完成了“查消息状态”和“加计数器”两个动作。这基本杜绝了并发下的数据不一致。


第五章:聚合计算 —— 这里的数字不只是一堆数字

如果用户量是亿级,千万级,你的Redis里全是 user:123:unread_count 这种Key。存几千万个Key,Redis本身的开销(内存碎片、查找开销)就会变得巨大。

这时候,咱们得玩点高端的:聚合策略

不要为每个用户存一个Key,咱们存一个“全局计数器”,或者按“小时”存。

策略一:按小时聚合

每次用户发消息,只更新一个汇总Key:
increment:2023-10-27:10:00:total_count

然后,后台有个定时任务,每分钟或者每小时,遍历这个Key,算出每个用户的未读数。

但这有个问题:用户问“我现在有5条未读”,你告诉他“我算出来是5条”,但你要先去查数据库把他的历史记录都拉一遍算一遍,这就慢了。

策略二:列表 + 计数器(Redis Set + ZSet)

  • User:1001:UnreadMsgs: 一个Set,存所有未读消息的ID。
  • User:1001:UnreadCount: 一个String,存Set的大小。

这样,读取未读数非常快:scard User:1001:UnreadMsgs
缺点:如果用户有100万条未读消息,这个Set会爆内存。

策略三:预计算 + 列表

这个是目前大厂常用的方案。

  1. 用户读消息时,把消息ID从“未读列表”移到“已读列表”。
  2. 消息ID从“未读列表”移除,计数器自动 -1。
  3. 如果用户未读数超过阈值(比如100),或者一定时间(比如2小时)没读,将这100条消息的数据快照持久化到数据库的一个临时表里,然后清空Redis里的列表。

这样Redis里永远只存最新的100条或最新的状态,内存可控。


第六章:一致性保障的最后一道防线 —— 数据库落盘

即使有了Redis,如果Redis崩了,或者断电了,我们怎么办?我们要保证在应用重启后,未读数不能丢。

方案:Redis RDB/AOF 持久化。

这个是基本功。但光持久化不够,还得有个补偿机制

补偿脚本思路:

  1. 定时任务(比如每5分钟)扫描所有在线用户,或者扫描最近有活动的用户ID。
  2. 重新计算这些用户的未读数,然后写回Redis。
  3. 或者更狠的,利用Redis的 Keyspace Notifications。开启Redis的通知功能,当某个Key被修改时,把操作日志写入到一个List里。然后有一个Worker监听这个List,把操作日志持久化到数据库。

代码示意(伪代码):

// 监听 Redis Key 变化事件
// 需要 config redis notify-keyspace-events Ex
// 这里的代码是伪代码,实际应用较复杂
$redis->psubscribe(['__keyevent@0__:incr', '__keyevent@0__:decr'], function ($pattern, $key, $channel) {
    // 记录日志到数据库
    $log = [
        'event_type' => substr($pattern, -3), // incr or decr
        'key' => $key,
        'timestamp' => time()
    ];
    $db->insert('count_logs', $log);
});

但是要注意,Keyspace Notifications 性能消耗比较大,一般不建议在生产环境大范围使用,除非你为了极致的可靠性愿意牺牲一点性能。


第七章:PHP 生态下的进阶玩法

既然咱们是PHP专场,就得聊聊PHP在处理这种场景时的独门秘籍。

1. Swoole / Workerman:摆脱单进程限制

传统的PHP是短链接,请求一来,进程跑死,请求走人。处理这种高并发,PHP通常依赖Nginx的Master进程分发给很多个Worker进程。

但在处理“长连接”的IM场景,或者需要在一个脚本里同时做“监听队列”和“处理请求”时,Swoole是神器。

用Swoole写一个简单的定时任务和长连接服务:

$server = new SwooleServer("0.0.0.0", 9501);

$server->on('receive', function ($server, $fd, $from_id, $data) {
    // 前端发来消息
    $msg = json_decode($data, true);

    // 1. 异步更新未读数
    SwooleAsync::writeFile('/tmp/msg.log', json_encode($msg) . PHP_EOL);

    // 2. 推送成功
    $server->send($fd, "Message received, unread count updated.");
});

// 开启一个定时任务,做数据补偿
SwooleTimer::tick(60000, function () {
    echo "Running consistency check...n";
    // 这里写你的聚合计算逻辑
});

$server->start();

2. Laravel 的队列系统

如果你不想造轮子,Laravel的队列系统已经帮我们封装好了Redis驱动。

  • sync 驱动:用于开发调试,所有任务在当前请求内同步执行。
  • redis 驱动:用于生产环境,任务丢给Redis,由Supervisor管理的PHP进程去消费。

推荐配置:

  1. 消息入队:MessageJob::dispatch($userId);
  2. Job处理:在 handle() 方法里做 Incr 操作,并处理 retryAfter 逻辑(防止死循环重试)。

第八章:综合架构方案 —— 听我一句劝

讲了这么多,如果你要上这套系统,我给你一个“终极架构图”的文字描述:

  1. 网关层(Nginx/FPM): 负责拦截请求,做鉴权。
  2. 业务逻辑层:
    • 当用户收到新消息:将消息存入MySQL消息表,同时向 Kafka/RabbitMQ 发送一条 IncrementUnread 事件。
    • 当用户点击“清除未读”:向Redis发送 del user:123:unread_count
  3. 消息队列层: 缓冲海量的写入请求。
  4. 消费层:
    • Worker A (高优先级): 消费队列消息,使用 Lua脚本 原子地更新Redis计数器。
    • Worker B (低优先级/定时任务): 每小时执行一次全量扫描,将Redis里的计数差异同步到MySQL的 user_stats 表(供报表系统使用)。
  5. 缓存层(Redis): 负责高并发读取。使用 Hash 结构存储每个用户的未读数,Key格式如 stats:user:123:fields:unread

一致性保障的法则:

  • 强一致性场景: 比如扣减库存。必须用 Lua脚本 + 乐观锁
  • 最终一致性场景(推荐): 比如未读数。允许短暂的数据不一致,只要在几十秒内对上即可。优先保证系统的可用性和性能。

结语:工程的艺术

各位,实现海量用户消息未读数的同步,本质上是在做流量削峰状态管理数据兜底的平衡。

不要迷信单一的技术,什么“数据库能搞定”、“Redis够用了”。在百万级DAU面前,任何一个微小的并发缺陷都会被放大成事故。

  • 用数据库存计数器?那是在找死。
  • 用Redis INCR不管不顾?那是在玩火。
  • 用队列异步处理?那才是正道。
  • 用Lua脚本保证原子性?那是专业。
  • 用定时任务做兜底?那是良心。

做技术,就像过日子。光有快乐不行,得有记账本(数据库),得有备忘录(队列),还得有个靠谱的管家(Lua脚本),才能保证家里的财政状况永远清清楚楚。

好了,今天的讲座就到这里。希望大家以后写代码时,想起今天讲的内容,手别抖,心别慌。咱们下期再见!

发表回复

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