各位老铁、各位后端界的扛把子们,大家晚上好!
今天咱们坐下来,聊点“重”的。不是加班的重,是技术深度的重。咱们来探讨一个在即时通讯(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,未读数是不是又变回去了?这就叫“回滚”,非常恶心。
为了解决这个问题,我们要用 延迟双删策略。
思路是这样的:
- 第一次删除: 用户点“清除未读”,先删除Redis里的计数器。
- 异步更新: 发送一条任务到队列,3秒后执行
incr(假设3秒后消息才真正展示出来)。 - 第二次删除: 3秒后,任务执行前,再删一次Redis里的计数器。
或者更简单的做法:发布订阅模式。
用户修改未读数时,往Redis里发个广播,告诉所有人:“我的数变了”。
但为了保障一致性,最稳妥的还是最终一致性。
第四章:Lua脚本 —— 原子性的保镖
你可能会问:“如果用户同时发消息,又清空消息,会不会出现竞态条件(Race Condition)?”
比如:
- 线程A:
get(value = 5) - 线程B:
get(value = 5) - 线程A:
set(value = 6) - 线程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会爆内存。
策略三:预计算 + 列表
这个是目前大厂常用的方案。
- 用户读消息时,把消息ID从“未读列表”移到“已读列表”。
- 消息ID从“未读列表”移除,计数器自动 -1。
- 如果用户未读数超过阈值(比如100),或者一定时间(比如2小时)没读,将这100条消息的数据快照持久化到数据库的一个临时表里,然后清空Redis里的列表。
这样Redis里永远只存最新的100条或最新的状态,内存可控。
第六章:一致性保障的最后一道防线 —— 数据库落盘
即使有了Redis,如果Redis崩了,或者断电了,我们怎么办?我们要保证在应用重启后,未读数不能丢。
方案:Redis RDB/AOF 持久化。
这个是基本功。但光持久化不够,还得有个补偿机制。
补偿脚本思路:
- 定时任务(比如每5分钟)扫描所有在线用户,或者扫描最近有活动的用户ID。
- 重新计算这些用户的未读数,然后写回Redis。
- 或者更狠的,利用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进程去消费。
推荐配置:
- 消息入队:
MessageJob::dispatch($userId); - Job处理:在
handle()方法里做Incr操作,并处理retryAfter逻辑(防止死循环重试)。
第八章:综合架构方案 —— 听我一句劝
讲了这么多,如果你要上这套系统,我给你一个“终极架构图”的文字描述:
- 网关层(Nginx/FPM): 负责拦截请求,做鉴权。
- 业务逻辑层:
- 当用户收到新消息:将消息存入MySQL消息表,同时向 Kafka/RabbitMQ 发送一条
IncrementUnread事件。 - 当用户点击“清除未读”:向Redis发送
del user:123:unread_count。
- 当用户收到新消息:将消息存入MySQL消息表,同时向 Kafka/RabbitMQ 发送一条
- 消息队列层: 缓冲海量的写入请求。
- 消费层:
- Worker A (高优先级): 消费队列消息,使用 Lua脚本 原子地更新Redis计数器。
- Worker B (低优先级/定时任务): 每小时执行一次全量扫描,将Redis里的计数差异同步到MySQL的
user_stats表(供报表系统使用)。
- 缓存层(Redis): 负责高并发读取。使用 Hash 结构存储每个用户的未读数,Key格式如
stats:user:123:fields:unread。
一致性保障的法则:
- 强一致性场景: 比如扣减库存。必须用 Lua脚本 + 乐观锁。
- 最终一致性场景(推荐): 比如未读数。允许短暂的数据不一致,只要在几十秒内对上即可。优先保证系统的可用性和性能。
结语:工程的艺术
各位,实现海量用户消息未读数的同步,本质上是在做流量削峰、状态管理和数据兜底的平衡。
不要迷信单一的技术,什么“数据库能搞定”、“Redis够用了”。在百万级DAU面前,任何一个微小的并发缺陷都会被放大成事故。
- 用数据库存计数器?那是在找死。
- 用Redis INCR不管不顾?那是在玩火。
- 用队列异步处理?那才是正道。
- 用Lua脚本保证原子性?那是专业。
- 用定时任务做兜底?那是良心。
做技术,就像过日子。光有快乐不行,得有记账本(数据库),得有备忘录(队列),还得有个靠谱的管家(Lua脚本),才能保证家里的财政状况永远清清楚楚。
好了,今天的讲座就到这里。希望大家以后写代码时,想起今天讲的内容,手别抖,心别慌。咱们下期再见!