Go IM 源码实战(7):断线三天重连,水位算错就漏消息

你好,我是利威尔xu。

这是**「Go IM 源码实战」系列第 7 篇**。前 6 篇讲完了"发"和"存",这一篇讲**"拉"**------用户断线三天后重新登录,怎么把这三天的消息一条不多、一条不少地补回来。

这个问题看起来简单(select * where seq > 300),但真实系统里它有四个边界要同时满足:

边界 谁定的
上界 会话最大 seq 和用户最大 seq,取小的那个
下界 会话最小 seq 和用户最小 seq,取大的那个
数量 单次最多 N 条,超了截断
方向 升序拉新消息 / 降序翻历史,isEnd 判定完全反过来

只要少夹一层边界,就会出现两种线上事故:要么把用户"已经清空"的旧消息又拉回来,要么让客户端无限翻页。

源码位置(全系列同一份):

  • GitHub:https://github.com/CYQ111999/IM-Server
  • 基线:OpenIM Server v3.8 瘦身版,main @ 07f9ff6
  • 本篇涉及:internal/rpc/msg/sync_msg.go、internal/rpc/msg/as_read.go、pkg/common/storage/controller/msg.go、pkg/common/storage/cache/redis/seq_user.go

前 4 篇:1 全局地图、2 WS 网关、3 HTTP API 层、4 发送链路。第 5 篇起全部在同名专栏里,见作者文章列表;系列导航在《新粉必看:技术博客学习路线》。

文章目录

一、先把"同步"拆成三种

新手容易把"同步"当成一件事,实际上至少要拆成三种,它们的机制完全不同:

类型 同步什么 锚点 频率
会话列表同步 有哪些会话、每个会话最后一条、未读数 会话的"版本号"(incrversion) 每次登录/下拉刷新
消息同步 某个会话里 seq > 本地水位 的消息 每会话一个 seq 水位 进会话 / 收到推送后补拉
已读同步 我读到哪、对方读到哪 每会话每用户一个已读水位 点开会话 / 滚动

三种同步各有各的水位,混用一定会出错。比如把"会话列表的 maxSeq"当成"我的消息水位",就会一直重复拉同一批消息。

这一篇主要讲后两种(消息 + 已读),会话列表的版本号在系列后面单独讲。

面试追问:客户端本地要存几个数?

最小集合是"每会话一个 localMaxSeq(我已经拿到哪)+ 一个 localHasReadSeq(我读到哪)"。会话列表还要一个 version。这三个数就是客户端的全部同步状态,丢了任意一个,都要全量重拉。

二、为什么时间戳不能当"上次的状态"

最直觉的做法是记住"我上次拉到的时间",然后 send_time > lastTime。三个致命问题:

问题 后果
同毫秒多条 边界上的消息会重复拉或漏拉
客户端时间不可信 用户改系统时间 → 永远拉不到新消息
服务端时间也不严格单调 多机时钟漂移,后写的消息时间可能更早

所以必须用一个服务端生成、单调递增、按会话聚合 的数------就是 seq。这也是第 5 篇那个"两段式 Lua"存在的全部理由:seq 的唯一使命是给同步提供一个可靠的锚点。

三、水位模型与四道边界

水位(watermark) 这个词很形象:像水库的水位线,你只需要知道"到哪了",不需要记住"都有哪些"。

客户端的请求长这样:我要 conversationID=A 的 seq 10001~10100,最多 50 条,升序。服务端要做四件事,一件都不能省:

# 边界 规则 漏了会怎样
1 下界取最大 minSeq = max(会话minSeq, 用户minSeq) 把用户"清空过"的旧消息又拉回来
2 上界取最小 maxSeq = min(会话maxSeq, 用户maxSeq) 把用户"还没资格看"的消息泄露出去
3 数量截断 超过 num 只保留最靠近 end 的一段 一次返回十万条,直接把客户端撑爆
4 越界返回空 minSeq > end 直接返回空 构造出一个巨大的无效区间

第 1 条最容易理解:用户"清空聊天记录"就是把自己的 minSeq 抬高了(第 6 篇讲过,1 次写代替 10 万次)。如果服务端不夹这一层,用户清空之后一刷新,历史消息全回来了------用户会认为"删除没生效",这是最典型的投诉。

第 2 条是很多人想不到的:用户也有"上界"。被踢出群的人,userMaxSeq 设成他被踢前的最大 seq,他就只能看到入群到被踢之间的消息。同一个机制,既做"清空"又做"权限",这就是水位设计的漂亮之处。

四、范围收敛:本篇最核心的 30 行

pkg/common/storage/controller/msg.go:370,GetMsgBySeqsRange:

go 复制代码
func (db *commonMsgDatabase) GetMsgBySeqsRange(ctx context.Context, userID string, conversationID string,
	begin, end, num, userMaxSeq int64) (int64, int64, []*sdkws.MsgData, error) {

	// ① 下界:用户水位与会话水位取大
	userMinSeq, err := db.seqUser.GetUserMinSeq(ctx, conversationID, userID)   // 我清空到哪了
	minSeq, err := db.seqConversation.GetMinSeq(ctx, conversationID)           // 会话被删到哪了
	if userMinSeq > minSeq {
		minSeq = userMinSeq
	}
	if minSeq > end {                       // 请求区间整体在可见范围以下
		return 0, 0, nil, nil
	}

	// ② 上界:会话 maxSeq 与用户 maxSeq 取小
	maxSeq, err := db.seqConversation.GetMaxSeq(ctx, conversationID)
	if userMaxSeq != 0 && userMaxSeq < maxSeq {
		maxSeq = userMaxSeq
	}

	// ③ 把区间夹进 [minSeq, maxSeq]
	if begin < minSeq { begin = minSeq }
	if end > maxSeq { end = maxSeq }
	if end < begin { return 0, 0, nil, errs.ErrArgs.WrapMsg("seq end < begin") }

	// ④ 按 num 截断:只保留最靠近 end 的一段
	var seqs []int64
	if end-begin+1 <= num {
		for i := begin; i <= end; i++ { seqs = append(seqs, i) }
	} else {
		for i := end - num + 1; i <= end; i++ { seqs = append(seqs, i) }
	}
	msgs, err := db.GetMessageBySeqs(ctx, conversationID, userID, seqs)
	return minSeq, maxSeq, msgs, err
}

为什么截断要保留"最靠近 end 的那一段"

假设请求是 begin=1, end=100000, num=50。你有两种截断方式:

方式 返回 用户感受
保留 [1, 50] 最早 50 条 打开聊天窗看到三天前的消息,"我的新消息呢?"
保留 [99951, 100000] 最新 50 条 打开就是最近的消息,符合直觉

所以是 end-num+1 ~ end。这个细节在面试里被问到时,能答出"保留靠近 end 的一段"就说明你真读过代码,因为大部分人只会说"限制返回条数"。

返回 minSeq 和 maxSeq 干什么

函数不只是返回消息,还把服务端真实的边界返回给调用方。客户端拿到之后可以:

  • 修正自己的 localMaxSeq(原来以为到 10100,实际只到 10098);
  • 给"向上翻到头了"提供依据(isEnd)。

"把边界一起返回"是分布式接口的通用好习惯:因为客户端的认知永远可能过期,服务端顺手告诉它真相,能省掉一轮重试。

五、两个用户级水位,服务两个相反的需求

函数注释里写得很清楚(msg.go:355 附近),值得原样抄下来:

userMaxSeq:用户在群内可拉取的消息序号上限。未设置(默认 0)时,与会话的 maxSeq 相同。

userMinSeq:用户在群内拉取消息的起始序号。未设置(默认 0)时,与会话的 minSeq 相同。

典型场景:

  • 被踢出群的用户,userMaxSeq 可设为其被踢出前的最大序号,限制其只能拉到某个时间点之前的消息。
  • 新入群用户如果不需要历史消息,userMinSeq 可设为入群那一刻会话的 maxSeq。

这张表建议收藏,因为它把"水位"从"计数器"升级成了"可见性窗口":

业务需求 配置 效果
用户清空聊天记录 抬高 userMinSeq 旧消息"消失",数据一条没删
被踢出群的用户 冻结 userMaxSeq 只能看到被踢前的消息
新入群不看历史 抬高 userMinSeq 到当前 max 群里 10 万条历史,他只看入群之后
付费解锁历史 调低 userMinSeq 能看到更早的消息(水位可回退是特性不是 bug)

同一套字段,四个业务需求。 这就是"好的抽象不是把所有情况都编码进去,而是找出那个能被多种业务复用的维度"。

面试追问:为什么不用权限表?

权限表是"能不能",水位是"能看到哪一段"。IM 的绝大部分限制都是"区间"而不是"是/否",用区间表达的好处是:判定只做两次比较,不需要查表。这就是为什么 seq 必须稠密------不稠密的序号没法比较区间。

六、IsEnd 的两个方向是镜像的

go 复制代码
switch req.Order {
case sdkws.PullOrder_PullOrderAsc:
	isEnd = maxSeq <= seq.End    // 升序拉新:我要的终点已经超过服务端最大值
case sdkws.PullOrder_PullOrderDesc:
	isEnd = seq.Begin <= minSeq  // 降序翻历史:我要的起点已经低于服务端最小值
}
方向 场景 判"到底了"看哪个界
Asc(升序) 登录后补新消息 看上界 :maxSeq <= end
Desc(降序) 往上翻历史 看下界 :begin <= minSeq

这两个条件写反,客户端会无限翻页:它永远以为"还有更多",一直请求同一段,直到把服务端打出一个 QPS 尖峰。这类 bug 特别隐蔽------功能看起来正常(消息确实能拉到),只是流量翻倍。

📌 排查技巧:"客户端无限翻页"类问题,第一件事是抓包看 isEnd 是不是永远 false,再对一遍上下界方向。

七、未读数 = maxSeq - hasReadSeq,凭什么能成立

第 1 篇里我给过这个公式,这一篇补上它成立的前提条件:

go 复制代码
currentMaxSeq, err := db.seqConversation.Malloc(ctx, conversationID, int64(len(msgs)))
for _, m := range msgs {
	currentMaxSeq++
	m.Seq = currentMaxSeq
	userSeqMap[m.SendID] = m.Seq   // ← 发送者自己的已读水位推到这条消息
	...
}

调用方随后把 userSeqMap 写进 hasReadSeq。"自己发的消息不算未读"不是靠减法时排除,而是靠写入时把自己的水位推到最新。

这个设计的价值在于:计算未读数的路径完全不需要知道"谁是发送者",只要两个数字相减。 如果改成"减法之后再排除自己发的 N 条",你就要遍历区间、过滤发送者------成本从 O(1) 变成 O(N)。

⚠️ 但它有一个必然的副作用:如果 A 手上有 500 条未读,他发一条消息,未读会直接清零 (因为水位被推到了自己这条)。微信不是这么做的(发消息不清未读)。这是减法模型的固有取舍,不是说它错,而是要知道它的边界在哪。

面试追问:那怎么才能做到"发消息不清未读"?

需要把"未读计数"从"减法"改成"独立计数"(比如 Redis 里维护每个会话的 unread 计数器,收到消息 +1,读了 -N)。代价是多一份状态要维护、要能重建。减法是"用 seq 免费得到未读数",独立计数是"用额外状态换精确语义"。 这是一个典型的设计权衡题。

八、已读:四个接口,三种语义

internal/rpc/msg/as_read.go 里有四个接口,别看名字都差不多,语义完全不同:

接口 语义 谁调 会不会写 Mongo
MarkConversationAsRead "这会话我读到 seq N 了",会补齐区间 点进会话 单聊会(批量打 is_read)
MarkMsgsAsRead "这 3 条我读了",精确列表 滚动到某几条 单聊会
SetConversationHasReadSeq "直接设成 N",不校验、不补 管理端/特殊场景 不会
GetConversationsHasReadAndMaxSeq 批量读水位 + maxSeq 登录/下拉刷新 只读

MarkConversationAsRead 为什么还要"补齐区间"

go 复制代码
if conversation.ConversationType == constant.SingleChatType {
	// 上次水位 +1 一直补到本次水位
	for i := hasReadSeq + 1; i <= req.HasReadSeq; i++ {
		seqs = append(seqs, i)
	}
	// 再并上客户端显式报的 seqs(去重)
	...
	m.MsgDatabase.MarkSingleChatMsgsAsRead(ctx, req.UserID, req.ConversationID, seqs)
}

原因很实际:单聊要在 Mongo 里给每条消息打 is_read(对方看到的"已读"标记)。客户端可能只报"我读到 100 了",但中间有些消息上次请求失败没打上标记。服务端按水位差补齐,保证不漏。

⚠️ 但这段代码有个明显的性能炸弹:

go 复制代码
for i := hasReadSeq + 1; i <= req.HasReadSeq; i++ { ... }

如果 hasReadSeq = 0、客户端上报 HasReadSeq = 100000,就会构造一个 10 万元素的切片,再对 1000 个文档做 BulkWrite。一个恶意(或 buggy)客户端就能让这台机器内存飙起来。

📌 正确的做法是限制单次补齐的数量 (比如超过 1000 就只补最近 1000 条 + 直接设水位),或者改成"按区间 $set"而不是逐条枚举。这是这个项目里我认为最值得提的一个改进点。

单聊写 Mongo,群聊不写

go 复制代码
filter := bson.M{
	"doc_id": docID,
	fmt.Sprintf("msgs.%d.msg.send_id", index): bson.M{"$ne": userID},   // 不标记自己发的
}
update := bson.M{"$set": bson.M{fmt.Sprintf("msgs.%d.is_read", index): true}}

三个细节:

  1. send_id != userID :单聊里的"已读"语义是"对方读了",自己的消息不该被自己标记成已读;
  2. BulkWrite:一次往返提交一批,不是 N 次 update;
  3. 函数名带 SingleChat:这个限制是刻意的。

群聊为什么不写? 500 人的群,每人读一次要给 100 条消息各写一次"谁读了"------一个 bool 字段根本存不下 500 个人的状态。就算用数组,写放大也是 500×。

所以群聊只更新水位,不落库任何消息级已读。这就是"群已读回执"这个功能在存储层被放弃的证据。(产品上通常只显示"3 人已读",靠水位 + 人数近似算。)

一次完整的「进会话 → 读消息 → 上报已读 → 再同步」闭环

把前面所有零件串起来,客户端进一个会话时的真实顺序是这样的(每一步都能在代码里找到对应函数):

步 客户端动作 打到哪个接口 服务端干了什么 水位怎么变
1 点开会话 PullMessageBySeqs(begin=userMaxSeq+1) 读水位四取边界 → 定位文档 → 投影改写 不变
2 渲染完列表 MarkConversationAsRead(hasReadSeq=最新seq) Redis 写已读水位(只前进)+ 单聊批量打 is_read hasReadSeq 前进
3 下拉刷新 GetConversationsHasReadAndMaxSeq 批量读 hasReadSeq + 会话 maxSeq 不变
4 列表算红点 不需要请求 maxSeq - hasReadSeq 客户端本地算 不变
5 退到后台 无 WebSocket 关闭,userMaxSeq 已在第 1 步写回 不变
6 隔天再进 同第 1 步 从 userMaxSeq+1 继续增量 不变

⚠️ 第 2 步和第 3 步之间有个容易忽略的竞态 :客户端上报已读是异步的,如果第 3 步的请求先到,拿到的还是旧水位,红点会"闪一下又消失"。所以第 2 步必须等上报成功再刷新列表,或者本地先乐观更新。

⚠️ 第 5 步是另一个坑:userMaxSeq 是在拉取时 写入的,不是"消息到达时"。如果用户一直在线收推送、但从不打开这个会话,userMaxSeq 会一直是旧值------下次进会话会把推送期间的消息再拉一遍。不丢消息,但会重复。这也解释了为什么"离线消息"在客户端要做 seq 去重,而不能只靠服务端保证。

九、已读水位存 Redis 为主、Mongo 为备,和一个没接上的开关

pkg/common/storage/cache/redis/seq_user.go:

go 复制代码
func NewSeqUserCacheRedis(rdb redis.UniversalClient, mgo database.SeqUser) cache.SeqUser {
	return &seqUserCacheRedis{
		mgo:               mgo,
		readSeqWriteRatio: 100,                    // ← 声明了,但没人用
		expireTime:        time.Hour * 24 * 7,
		readExpireTime:    time.Hour * 24 * 30,    // 已读水位缓存 30 天
		rocks:             newRocksCacheClient(rdb),
	}
}

写水位的逻辑:

go 复制代码
func (s *seqUserCacheRedis) SetUserReadSeq(ctx context.Context, conversationID, userID string, seq int64) error {
	if s.rocks.GetRedis() == nil {
		return s.SetUserReadSeqToDB(ctx, conversationID, userID, seq)   // 没 Redis → 直接落库
	}
	dbSeq, err := s.GetUserReadSeq(ctx, conversationID, userID)
	if err != nil { return err }
	if dbSeq < seq {                     // ← 只允许前进,不允许回退
		s.rocks.GetClient().RawSet(ctx, s.getSeqUserReadSeqKey(...), strconv.Itoa(int(seq)), s.readExpireTime)
	}
	return nil
}

三个设计意图:

设计 原因
只写 Redis 已读水位是高频写(每读几条就更新一次),丢一点没关系
只允许前进 dbSeq < seq 防止乱序请求把水位打回去(比如并发上报 100 和 90)
30 天过期 冷会话不常驻内存

⚠️ 但 readSeqWriteRatio: 100 这个字段在整个仓库里没有任何地方被读取 (grep readSeqWriteRatio 只有"声明 + 赋值"两处)。从命名推断,原设计是"每 100 次写落一次 Mongo"(write-behind 批量落库),但这个机制没实现。

后果推演:

  1. 已读水位只在 Redis 里,Mongo 里是"很久以前的值";
  2. 如果这个会话 30 天没动,Redis key 过期;
  3. 用户再打开时 GetUserReadSeq 回源 Mongo,拿到旧值;
  4. 未读数突然变成几百条 (maxSeq - 旧hasReadSeq)。

📌 这不是"必现的线上故障",而是一个设计意图没有落地的证据 。面试里讲这种发现,比画十个架构图都有说服力:"代码里声明了 readSeqWriteRatio: 100,但全仓库没有一处读取它,说明 write-behind 批量落库没实现。已读水位只活在 Redis 里,30 天过期后回源 Mongo 会拿到旧值,未读数会突然变大。"

十、踩坑记录:三个坑,一共 13 小时

坑一:清空记录后历史消息又回来了(6 小时)

现象:用户点"清空聊天记录",退出重进,消息全回来了。

定位:拉取接口只夹了会话级 minSeq,没夹用户级 userMinSeq。清空写的是用户水位,但读取时没读它。

修复:minSeq = max(会话minSeq, 用户minSeq),并且取大(不是取小!)。

教训:"多个水位取最严格的那个"------下界取 max,上界取 min。 记不住就想一想:既要满足会话可见,又要满足用户可见,两个条件同时成立,就是交集。

坑二:isEnd 写反,客户端无限翻页(4 小时)

现象:用户往上翻历史,翻到最早之后一直转圈,抓包发现客户端在重复请求同一个区间。

定位:Asc 和 Desc 的判定条件写成了一个方向(都看 maxSeq)。升序拉新时永远以为"还有更多"。

修复:改成镜像判定,并加了一个断言:如果返回条数为 0 且 isEnd=false,记为异常指标。

教训:方向类逻辑一定要拿两个方向各跑一遍。只测一个方向,另一半永远是盲区。

坑三:群聊误调 MarkMsgsAsRead,把已读广播到 500 人群(3 小时)

现象:某个版本上线后,群成员的客户端收到大量"已读"通知,网络流量和推送量翻了几倍。

定位:客户端对群会话调了 MarkMsgsAsRead(这个接口不区分单聊群聊,直接用 conversation.ConversationType),于是已读回执真的广播给了群成员。

修复:客户端群会话只调 MarkConversationAsRead(那个分支强制用 SingleChatType 只发给自己),并在服务端对群会话的 MarkMsgsAsRead 加限制。

教训:同一件事有两套接口时,必须明确"谁在什么场景下调哪个"。 不然客户端一换人写,就会用错入口。

对照:主流 IM 的"同步"都长什么样

如果你只做过这一个项目,面试时容易把 OpenIM 的做法当成唯一答案。实际上主流方案分三派,各自的取舍很清楚:

方案 代表 锚点 优点 代价
seq 水位(本项目) OpenIM / 早期微信 每会话一个单调递增整数 天然有序、可做区间、幂等、能算未读 要一个全局分配器;空洞要处理
游标(cursor) Slack / 部分开放平台 不透明字符串 + 快照 服务端可换存储不动接口 游标没法比较大小,算不了未读
时间戳 + 去重 不少自研小系统 last_ts 实现最简单 同毫秒重复、时钟漂移、分页会漏

三派的核心分歧只有一个:锚点能不能比较大小。

  • 能比较(seq)→ 可以"从 A 到 B"取区间、可以算 max - hasRead、可以做 > 锚点 的增量;
  • 不能比较(cursor)→ 只能"从这往下取 N 条",未读数必须独立维护计数器;
  • 时间戳 → 介于两者之间,能比较但不唯一。

📌 这条判断在面试里非常好用。当被问"你们为什么用 seq",别只说"有序",要说:"因为我要在同一个模型里同时支持区间拉取、未读数计算和幂等重放,这三件事都要求锚点可比较、唯一、且由服务端单点分配。cursor 方案做不到减法,就必须再维护一套未读计数,一致性成本反而更高。"

顺带说一句:本项目 maxSeq - hasReadSeq 能成立,靠的正是"写入时把发送者自己的水位推到最新"这个隐含前提(第七节)。换成 cursor 方案,这个前提根本不存在,未读数只能走单独的计数器 + 定期对账。

十一、面试会怎么问

追问 怎么答(一句话版)
同步为什么用 seq 不用时间戳? 同毫秒重复 + 客户端时钟不可信 + 服务端时钟漂移
拉取要夹几层边界? 下界取 max(会话 min、用户 min)、上界取 min(会话 max、用户 max)、num 截断、越界返回空
截断为什么保留靠近 end 的? 用户要看最新,不是最早
userMaxSeq / userMinSeq 干什么? 可见性窗口:清空记录、踢出群、新入群不看历史、付费解锁
isEnd 两个方向为什么反过来? 升序看下界还是上界?------升序看上界 maxSeq<=end,降序看下界 begin<=minSeq
未读数为什么能直接相减? 写入时把发送者自己的水位推到最新,减法路径不需要知道发送者
群聊为什么不存消息级已读? 500 人 × 100 条写放大,bool 存不下"谁读了",只留水位
已读水位丢了会怎样? Redis 30 天过期回源 Mongo 拿旧值,未读数突然变大;readSeqWriteRatio 声明了没实现

十二、小结:四个数、四道边界、一个方向问题

  • 四个数 :会话 minSeq、会话 maxSeq、用户 userMinSeq、用户 userMaxSeq。
  • 四道边界:下界取 max、上界取 min、num 截断(保留靠近 end)、越界返回空。
  • 一个方向问题 :isEnd 在两个方向上是镜像判定,写反就是无限翻页。

下面这张表建议收藏,下次你做"增量同步"(消息、通知、日志、动态流都适用),照着过一遍:

检查项 该问什么 本项目的答案
锚点 用什么做"上次拉到哪"? 每会话一个 seq 水位
上界 是否有"用户级上限"? userMaxSeq,取 min
下界 是否有"用户级下限"? userMinSeq,取 max
截断 超量怎么截? 保留最靠近 end 的 num 条
越界 请求整段不可见怎么办? 直接返回空,不报错
边界回传 要不要告诉客户端真实边界? 返回 minSeq/maxSeq/endSeq
翻页 isEnd 判定方向对吗? 升序看上界,降序看下界
未读 减法还是独立计数? 减法,靠写入时推自己水位
已读存储 存哪、丢了怎么办? Redis 为主(30 天),Mongo 为备(批量落库未实现)
群已读 存消息级还是只存水位? 只存水位

留个问题,评论区一句话能答: 如果同时把会话级 minSeq 和用户级 userMinSeq 都设成 100,客户端请求 begin=1, end=50,服务端应该返回什么?为什么不能报错?

如果这篇帮你把"同步"这块捋顺了,点个赞、收个藏------这个系列后面还有 9 篇。下一篇讲推送与在线状态:Kafka 三个 topic 怎么分工,为什么推送要单独一条流水线。

下一篇:《Go IM 源码实战(8):Kafka 四个 topic 一条流水线,消息为什么先进 Redis 再落库》------从 toRedis / toMongo / toPush 的分工讲到消费位点与重试。

作者:利威尔xu | CSDN 专栏《Go IM 源码实战》

相关推荐
我的xiaodoujiao1 小时前
Django 权限系统项目实战详细图文教程 4--系统登录功能实现 2
后端·python·django
月夏1 小时前
前端第一次接自己的后端:mock 切真接口踩了 2 个坑,组件只改了一行 import
前端·后端·node.js
IT_陈寒1 小时前
React实战技巧总结:如何优雅处理复杂列表的渲染卡顿问题?
前端·人工智能·后端
Sailing2 小时前
🚀🚀【封闭结束5】Agent 中的 SSE 流式推送与 HITL 暂停恢复
前端·面试·agent
pt10432 小时前
Cisco设备网络安全系列-16:微服务、Kubernetes与无服务器架构安全
网络·安全·微服务·架构
Lzh编程小栈2 小时前
STM32TIM基础定时器深度解析:时基原理、核心寄存器、影子寄存器、基础定时器和中断与DMA联动、代码实战及面试考点全解
stm32·单片机·面试
打工仔折腾 AI2 小时前
后端部署避坑:吃透IPv4地址、网络接口与CIDR前缀的实际应用
arm开发·人工智能·后端·python·性能优化
独泪了无痕10 小时前
Spring Data Redis 快速上手
spring boot·redis·后端
行思理11 小时前
go语言学习难度及适合学习的人群
开发语言·学习·golang