尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Golang WebSocket 房间分组管理:从单机到多实例的完整方案
做即时通讯、游戏对战、在线协作白板这类产品时只要涉及 Golang WebSocket就绕不开一个场景同一套服务上挂着几千上万个连接必须按某个业务维度把它们圈起来。比如用户进了 3 号竞速房他就只能收到 3 号房里其他玩家发的坐标、操作和聊天没进房的人一条都收不到。这个需求听上去就是按房间分组管理连接群组但真正动手时会发现并发模型、连接生命周期、异常断开、多实例扩展每一环都可能把你坑到怀疑人生。这篇文章把我自己在项目里用过的房间分组方案拆开讲清楚。先聊设计模型再给出可以直接跑的 Go 代码最后补充压测和线上出现过的隐藏问题。适合已经会写 WebSocket echo server、但还没认真设计过连接管理的 Go 开发者。看完你会明白Golang 做房间分组管理难点从来不是 Map 本身而是什么时候加、什么时候删、怎么安全地广播。1. 先想清楚你说的房间到底是个什么模型1.1 从聊天室到协作白板一组连接就是一张业务表很多教程喜欢直接用聊天室举例但实际上房间在不同业务里的语义差别很大。游戏对战房除了消息转发还要同步玩家状态、座位、准备状态甚至房间生命周期由战斗逻辑驱动。在线白板每个房间对应一块共享画布客户端发的是绘制指令服务端要按房间做有序广播。直播弹幕房间通常是直播间 ID观众只读、主播可写权限模型不一样。客服系统房间可能是一次会话连接的角色分用户和客服消息还要路由给特定账号。就算底层都是 WebSocket 连接分组上层业务逻辑完全不同。所以我建议你动手前先把以下三个问题答出来房间 ID 由谁生成URL 路径、首条消息、还是服务端下发同一个用户能否同时加入多个房间房间为空时要不要立刻销毁房间销毁后里面的连接怎么处理这些答案直接决定你的数据结构是map[string]map[*Client]struct{}还是更复杂的Room对象。用一张表来对比常见场景的需求差异场景成员关系消息范围服务端额外状态聊天室多对多房间内所有人无游戏房多对多有座位房间内所有人座位、准备状态、房间阶段在线白板多对多房间内所有人画布增量、历史记录直播弹幕一对多房间内所有人无客服会话用户与客服限定成员会话状态、转接记录把业务模型先定下来代码只是翻译的过程。反过来代码先写顺手了再套业务往往要重写。1.2 单机房间还是分布式房间决定你用哪种分组法这是第二个必须早做的决定。如果你只有一台后端实例所有连接都落在这台机器上房间 Map 直接放内存就行问题集中在并发控制和连接生命周期。如果你有 N 台实例用户 A 连在实例 1用户 B 连在实例 2两人在同一个房间里必须在进程之间做消息转发。这时候房间表就不能只住在单机内存里需要 Redis Pub/Sub、NATS、MQ 这类外部组件。很多人一开始不理解Golang 的 Map 不挺好吗确实好但跨机器不共享内存这是硬边界。所以架构上通常分两阶段单机阶段先用一个 Hub 管理所有连接和房间把业务跑通。多实例阶段Hub 变成本机代理同时往消息总线发布订阅。不要一上来就上 Redis除非你明确知道这是长期多节点产品。单体 Hub 调通以后再抽接口做扩展成本低很多。后面第 5 部分我会专门讲多实例方案这里先记住结论单机和分布式的核心差别在房间表和广播路径两个点上。2. 先别写代码三种分组实现选好再动手2.1 全局 Map 读写锁最直观但广播时容易把自己锁死最朴素的做法是一个全局管理器里面放map[roomID]map[*Client]struct{}所有房间共用一把读写锁。代码很好写type RoomManager struct { mu sync.RWMutex rooms map[string]map[*Client]struct{} } func (m *RoomManager) Join(roomID string, c *Client) { m.mu.Lock() defer m.mu.Unlock() if m.rooms nil { m.rooms make(map[string]map[*Client]struct{}) } if _, ok : m.rooms[roomID]; !ok { m.rooms[roomID] make(map[*Client]struct{}) } m.rooms[roomID][c] struct{}{} } func (m *RoomManager) Leave(roomID string, c *Client) { m.mu.Lock() defer m.mu.Unlock() if clients, ok : m.rooms[roomID]; ok { delete(clients, c) if len(clients) 0 { delete(m.rooms, roomID) } } }但广播就不是随便写写了。如果这样写func (m *RoomManager) Broadcast(roomID string, msg []byte) { m.mu.RLock() defer m.mu.RUnlock() for c : range m.rooms[roomID] { c.send - msg // 危险可能会阻塞 } }一旦某个客户端send通道满了整把 RLock 会被一直持有其他 Join、Leave、Broadcast 全卡住。正确做法是先把要广播的客户端切片拷出来释放锁再逐个发送func (m *RoomManager) Broadcast(roomID string, msg []byte) { m.mu.RLock() clients : make([]*Client, 0, len(m.rooms[roomID])) for c : range m.rooms[roomID] { clients append(clients, c) } m.mu.RUnlock() for _, c : range clients { select { case c.send - msg: default: // 缓冲满说明这个客户端消费能力不足 } } }用select default可以在慢客户端场景下不阻塞广播。这里有个 trade-off如果 default 分支什么都不干消息就丢了如果直接关闭连接慢客户端的体验是被踹下线但其他人不受影响。没有银弹业务允许丢消息就选前者重要消息就选后者。全局锁方案的最大问题不是性能而是锁粒度太粗。一个房间广播慢理论上是这个房间内部的事但全局锁会导致其他房间也被拖累。房间少、消息量小可以接受做大之后不合适。2.2 房间对象独立管理把并发控制圈在房间内部我更推荐的做法是把一件事归一个对象管。每个房间一个对象房间内部一把锁全局只需要一个轻量 Map 把roomID映射到*Room。type Room struct { ID string mu sync.RWMutex clients map[*Client]struct{} } type Hub struct { mu sync.RWMutex rooms map[string]*Room } func (h *Hub) getOrCreateRoom(roomID string) *Room { h.mu.RLock() room, ok : h.rooms[roomID] h.mu.RUnlock() if ok { return room } h.mu.Lock() defer h.mu.Unlock() if room, ok h.rooms[roomID]; ok { return room } room Room{ ID: roomID, clients: make(map[*Client]struct{}), } h.rooms[roomID] room return room }getOrCreateRoom里二次检查h.rooms[roomID]是防止并发重复创建房间。这种模式叫 double-check在 Go 里配合 Mutex 很常用。接下来房间的加入、离开、广播都在Room上操作锁范围小了很多func (r *Room) AddClient(c *Client) { r.mu.Lock() defer r.mu.Unlock() r.clients[c] struct{}{} } func (r *Room) RemoveClient(c *Client) { r.mu.Lock() defer r.mu.Unlock() delete(r.clients, c) if len(r.clients) 0 { // 通知 Hub 删除空房间 } }这里的经验是尽量让锁只在某个房间内生效不要让跨房间的业务共用同一个大锁。房间数量少时性能差异不明显但到几千个房间、每房几十人时就清楚了。2.3 基于消息路由的订阅式分组适合业务复杂的时候还有一种思路不直接维护连接集合而是让每个连接订阅一个或多个主题。服务端收到消息时通过路由规则决定发给谁。类似的消息中间件有 Redis Pub/Sub、NATS、MQTT也有 Golang 生态里的 event bus、actor 框架。这种方案适合连接可以不只属于一个房间的场景比如用户同时在多个工作区里或者需要按用户 ID、群组 ID、会话 ID 多个维度路由。缺点是抽象层级高排查问题时房间不再是显式概念而是变成路由规则调试成本上去了。我的看法是普通项目优先用显式 Room 对象别为了抽象的优雅而牺牲可调试性。消息路由方案等业务真的需要多维分组时再引入否则就是过度设计。3. 一个能跑的房间服务从握手到消息转发的完整代码3.1 选型gorilla/websocket 还是 nhooyr.io/websocketGolang 生态里 WebSocket 库最常用的是github.com/gorilla/websocket。它足够成熟文档多网上踩坑案例也多。另一个现代库是nhooyr.io/websocket现在已经迁移到github.com/coder/websocket支持 contextAPI 更现代化但对老项目兼容性稍差。我下面的代码用 gorilla/websocket因为大多数团队的现有代码都长这样哪怕你换成 coder/websocket房间管理的核心思路完全一样变的只是一两个方法名。选型理解到这层就够了没必要每天换库。3.2 核心结构Client 与 Hub一个 Client 代表一条 WebSocket 连接。它至少要有id连接唯一标识可以用 uuid也可以用用户 ID。room当前所在房间 ID。conn底层 WebSocket 连接。send服务端写数据的缓冲通道。所有要发给这个客户端的消息都走这个通道。done通知连接已关闭的管道用来防止重复清理。type Client struct { id string room string conn *websocket.Conn send chan []byte done chan struct{} }Hub 负责管所有房间type Hub struct { mu sync.RWMutex rooms map[string]*Room }这里我把 Room 作为分组单位而不是直接map[string]map[*Client]struct{}原因刚才说过把房间的生命周期和锁圈在独立对象里。3.3 消息协议用 JSON 定义加入、发送、离开客户端连上来以后通过消息来切换房间。简单消息协议就够{type: join, room: 10086} {type: msg, room: 10086, data: hello} {type: leave, room: 10086}对应 Go 结构体type Message struct { Type string json:type Room string json:room Data string json:data }关于房间 ID 放 URL 还是放消息体我的建议是如果房间是连接建立时唯一确定的比如/ws?room10086放 URL 更干净如果连接建立后可能要切房比如进入匹配大厅后又被拉进具体对战房放首条消息更灵活。两种我都会写供你参考。3.4 读循环与写循环两条 goroutine 是安全底线WebSocket 连接标准模型是读、写分开两个 goroutine。读循环负责接收消息并处理业务写循环负责把send通道里的消息写出去。读循环核心代码func (c *Client) readPump() { defer func() { close(c.done) c.conn.Close() }() c.conn.SetReadLimit(4 * 1024) // 限制单条消息大小 for { _, raw, err : c.conn.ReadMessage() if err ! nil { return } var msg Message if err : json.Unmarshal(raw, msg); err ! nil { continue } switch msg.Type { case join: if msg.Room ! msg.Room ! c.room { roomMgr.Leave(c.room, c) c.room msg.Room roomMgr.Join(c.room, c) } case msg: roomMgr.Broadcast(c.room, raw) case leave: roomMgr.Leave(c.room, c) c.conn.WriteMessage( websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, leave), ) return } } }写循环核心代码func (c *Client) writePump() { ticker : time.NewTicker(pingPeriod) defer func() { ticker.Stop() c.conn.Close() }() for { select { case msg, ok : -c.send: _ c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if !ok { return } if err : c.conn.WriteMessage(websocket.TextMessage, msg); err ! nil { return } case -ticker.C: _ c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err : c.conn.WriteMessage(websocket.PingMessage, nil); err ! nil { return } } } }注意写循环里-c.send的ok只有 send 通道被 close 的时候才返回 false所以如果业务没有其他地方 close 这个通道这一支基本不会触发靠的是 WriteMessage 报错退出。这种设计避免了广播路径上 close 通道带来的竞态问题。3.5 广播加进房间真正完整的流程在一个具体的房间服务里广播不只是把消息丢给每个 client 的 send channel还要处理连接已经死掉的情况。广播代码我习惯写成这样func (r *Room) Broadcast(msg []byte, except *Client) { r.mu.RLock() clients : make([]*Client, 0, len(r.clients)) for c : range r.clients { if c except { continue } clients append(clients, c) } r.mu.RUnlock() for _, c : range clients { select { case c.send - msg: default: // 发不出去说明这个客户端慢了 // 业务上常见做法是直接断开避免拖垮其他人 r.RemoveClient(c) go c.conn.Close() } } }except参数是可选的常见于给房间里其他人宣告某人加入时的通知消息。有些消息需要回执给发送者本人有些不需要按需处理即可。HTTP 升级入口var upgrader websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } func wsHandler(w http.ResponseWriter, r *http.Request) { roomID : r.URL.Query().Get(room) conn, err : upgrader.Upgrade(w, r, nil) if err ! nil { return } c : Client{ id: uuid.NewString(), room: roomID, conn: conn, send: make(chan []byte, 32), done: make(chan struct{}), } roomMgr.Join(roomID, c) go c.writePump() go c.readPump() }这里roomMgr是全局 Hub 实例。到这里一个最小可运行的房间服务已经成型连接进来进房、消息广播给同屋、连接断开自动移除。4. 实测中绕不开的四个坑并发写、慢客户端、断线清理、心跳4.1 WebSocket 并发写为什么会 panicgorilla/websocket 的Conn不保证多个 goroutine 同时调用写方法。如果你在广播路径里直接给每个 client 写// 错误示例 for _, c : range room.clients { go c.conn.WriteMessage(websocket.TextMessage, msg) }同一个连接被多个 goroutine 写会触发 panic错误信息类似concurrent write to websocket connection。我一个项目里就是在上线后第一次高峰流量时撞上的服务直接崩掉排查时日志全是这个。这就是为什么所有写操作必须收敛到一个 goroutine 里也就是上面writePump的模式。房间管理再怎么设计这条底线不能碰。4.2 慢客户端拖垮整个房间房间广播的性能模型是这样你要给房间里 N 个连接各发一份数据整体发送速率取决于最慢的那个连接。如果不做任何缓冲策略一个客户端的 TCP 缓冲满了WriteMessage会阻塞。阻塞时间一长整个广播循环卡住同房间其他人也都收不到消息。我遇到过最典型的情况是测试环境里有人用 3G 网络连进房间房间内其他人在高峰期集体卡顿单独看服务端 CPU 又不高。解决办法就是给send通道设一个合理缓冲配合select default发不进去就认了。具体选丢消息还是断连取决于业务弹幕、坐标丢一帧无所谓选丢消息。支付结果、游戏状态必须到选断连 客户端重连补偿。我实际项目里取的经验值是缓冲32超过就断。参数不用死记压测的时候根据最慢客户端的表现调整。4.3 1006 和半开连接为什么客户端明明关了服务端还认为他在房间WebSocket 一个很常见的坑是客户端断网、App 被杀、Wi-Fi 切换这些场景下服务端不一定立即感知到 TCP 断开。结果是客户端已经在客户端侧显示断开了服务端的conn.ReadMessage()还一直阻塞房间 Map 里还躺着这个僵尸连接。你在日志里看到客户端onclose code 1006意思是连接非正常关闭这往往不是服务端主动发的 close frame而是网络层异常。服务端如果只靠ReadMessage返回错误来清理连接可能几分钟都感知不到。解决办法是心跳。标准做法服务端定期发 Ping。客户端必须回 Pong。服务端设置ReadDeadline如果在超时时间内没收到任何数据包括 Pong就主动关闭连接。代码在writePump里已经加了PingMessage还需要在readPump里设置读超时和 Pong handlerc.conn.SetReadDeadline(time.Now().Add(pongWait)) c.conn.SetPongHandler(func(string) error { _ c.conn.SetReadDeadline(time.Now().Add(pongWait)) return nil })时间参数上pingPeriod通常设为pongWait * 9 / 10。比如pongWait 60s那pingPeriod 54s。这样每次 ReadDeadline 到期前都有新的 Pong 续命。4.4 房间为空时要不要立刻销毁很多人会忽略空房间的清理。如果每次 Join 都getOrCreateRoom但 Leave 之后不删空房间时间一长内存里堆满了只有 ID 没有人的房间。这些空房间本身占用很小但如果房间数量是用户级别增长的照样会膨胀。我推荐在RemoveClient后检查房间成员数变成 0 就删除。下面是 Room 与 Hub 协作清理的方式func (r *Room) RemoveClient(c *Client) { r.mu.Lock() delete(r.clients, c) empty : len(r.clients) 0 r.mu.Unlock() if empty { hub.DeleteRoom(r.ID) } }不过要注意如果业务里房间本身有持久状态比如游戏结果、聊天记录空房间的旧记录要归档到数据库再删内存对象。否则玩家退出后服务端就把房间历史丢干净了。5. 你要做多实例部署时房间表就不能只放在内存里了5.1 单机方案的天花板单机 Hub 能扛多少连接这没有绝对数字跟消息频率、消息大小、业务逻辑复杂度都有关。我压测过一个比较极端的配置8C16G 的容器跑一个纯转发房间服务每连接每秒发一条 1KB 消息大概能稳定支撑 3-5 万个连接。数据量一大带宽和内存才是瓶颈CPU 反而没打满。但真正的问题不是单机瓶颈而是你只要上了多实例内存里的房间表就互相不可见了。玩家 A 在实例 1 的房间里发了一条消息玩家 B 在实例 2 的同一间房他必然收不到。除非每个用户都通过网关路由到同一个实例但游戏房间场景里用户分布不均很难保证同房始终落在同一台上。5.2 用 Redis Pub/Sub 广播改动最小最常见的跨实例方案是 Redis Pub/Sub。每个实例在启动时订阅业务相关频道本机房间广播时先把消息发给本机该房间的连接。再发布到 Redis 频道。其他实例从 Redis 收到消息后查自己的房间表转发给本机对应房间的连接。伪代码type Broadcaster struct { nodeID string local *Hub redis *redis.Client } func (b *Broadcaster) Publish(room string, msg []byte) { // 本机先发 b.local.BroadcastToRoom(room, msg) // 跨实例再发 payload : NodeMessage{ NodeID: b.nodeID, Room: room, Data: msg, } data, _ : json.Marshal(payload) b.redis.Publish(context.Background(), room:room, data) }订阅端要注意一个问题Redis Pub/Sub 会把你自己发布的消息也推送回来如果你不做判断本机会收到两份。解决办法很简单在消息里带NodeID订阅端发现自己 ID 的消息就跳过。func (b *Broadcaster) handleRedisMessage(msg *redis.Message) { var nodeMsg NodeMessage if err : json.Unmarshal([]byte(msg.Payload), nodeMsg); err ! nil { return } if nodeMsg.NodeID b.nodeID { return // 自己发的不处理 } b.local.BroadcastToRoom(nodeMsg.Room, nodeMsg.Data) }这个方案改动很小适合从单体进化到多实例的第一步。5.3 Redis Pub/Sub 的几个缺点提前做好心理准备Redis Pub/Sub 不是持久化消息队列。如果某个实例断连它在断连期间错过所有消息不会重放。房间广播场景里如果业务要求消息不丢需要上 Redis Streams、NATS JetStream 或者 Kafka 这类支持持久化和消费位点的组件。另外订阅频道数量如果按房间维度动态创建比如room:10086房间多了 Redis 的 SUBSCRIBE/UNSUBSCRIBE 会变得非常频繁连接状态也可能出问题。更稳的模式是只订阅一个广播频道所有跨实例消息都走它由每条消息里的Room字段判断广播给谁。这样频道数量固定扩展性更好代价是每个实例都会收到全量消息需要自己过滤。对于真正的海量房间且需要记录房间状态的业务我会建议不要把状态放在广播通道里而是单独做一个房间状态服务。消息走消息通道状态走数据库/缓存各司其职。6. 压测与监控我怎么确认这套分组逻辑真的稳6.1 压测思路别只压连接数要压同房间广播很多人压测 WebSocket 只关心能建立多少连接这是不够的。房间分组服务的核心瓶颈是广播。一个房间 1000 人、每秒每人发一条消息和 1000 个房间每房 1 人性能表现完全不同。我自己压测时至少跑两种场景大房间场景1 个房间几千个连接服务端每秒钟给全员广播观察广播 P99 延迟和内存。多房间场景几千个房间每房 5-10 人随机发送观察 Map 创建销毁、锁竞争、GC 压力。工具可以用 Go 写个简单的压测客户端也可以用 k6 这类支持 WebSocket 的压测工具。重要的是记录结果别只看连接成功了。6.2 线上需要盯住的四个指标房间服务上线后我通常会建议监控以下指标指标怎么统计说明活跃连接数每个房间成员数汇总看总量规模和分布send 通道溢出次数广播 default 分支计数反映慢客户端比例房间广播耗时 P99广播函数前后打点反映整体转发压力异常断开数1006/1008 等 close code 计数网络异常和协议错误的指示器生产环境里最容易被忽略的是send 通道溢出次数。这个数字突然上涨往往不是服务端问题而是某个客户端网络变差了。看到这个指标可以主动排查是不是有弱网用户拖住了房间。6.3 压测里一个反直觉的现象广播放大效应压测时我经常发现服务端 CPU 不高但网络带宽先打满了。假设一个房间 5000 人一条消息 1KB全员广播就是 5MB 流量。如果每秒广播 10 条消息就是 50MB/s。这几乎是固定的数学题Go 优化帮不了你。所以做房间服务之前一定要算一下流量模型。如果业务确实需要全员高频广播就得降低消息频率、合并数据包或者用边缘节点做分发。房间分组管理解决的是发给谁的问题但发多少数据是整个系统设计决定的两者要一起考虑。最后分享一个我踩过几次坑之后沉淀下来的习惯如果你现在正要从零写 WebSocket 房间服务我建议先把连接池、房间、广播这三个概念用最小的代码跑通别急着加鉴权、重连、离线消息。我在实际项目里反复改过几次后最有价值的体会是房间管理的代码要尽量让房间 Map 的操作路径集中在一处不要散落在各种 handler 里。所有连接生命周期状态变化都走 Hub 的 Register/Unregister/GetRoom 接口这样排查问题时只需要盯着一两个文件。另外客户端协议字段的命名从第一天就要规范room、type、data这些词到处都会用。参数多了以后最好加一个version字段方便后端做协议兼容切换。这个习惯帮我省了非常多的联调时间。
RELATED

相关推荐

从线上故障到生产实践:分布式事务与最终一致性落地全程复盘

从线上故障到生产实践:分布式事务与最终一致性落地全程复盘

一次线上故障,把“分布式事务”四个字从PPT里拽到了我面前。当时订单服务已经扣款成功,库存服务却回滚失败,用户看到的提示是“支付成功”,仓库里却没有货可发。客服工单一下子涌进来,技术群里全是“库存到底扣没扣”的…

📅 2026/9/12 2:22:04
数据传输仿真核心:封装、时延与丢包重传建模详解

数据传输仿真核心:封装、时延与丢包重传建模详解

1. 为什么我从“传输”而不是“网络”开始讲仿真做信息系统仿真这几年,我最大的体会是:很多人一上手就直奔网络仿真工具,急着搭拓扑、配协议、看吞吐量,结果连最底层的数据是怎么从A点挪到B点的都没搞清楚。仿真报错的时候&#x…

📅 2026/9/12 2:22:04
高并发场景下隧道代理稳定性实测:三家服务压测对比与调优经验

高并发场景下隧道代理稳定性实测:三家服务压测对比与调优经验

做数据采集的人,大概都经历过这种绝望:白天压测一切正常,晚上一上量,IP批量掉线、请求超时、限流报错刷屏。我去年在给一个电商比价项目搭建数据采集平台时,就把市面上主流的隧道代理(动态IP轮换服务&#…

📅 2026/9/12 2:22:04
MORE NEWS

更多资讯

📰

校园奶茶店微信小程序毕业设计:从登录支付到订单管理的全流程实践

校园奶茶店这种选题,在计算机毕业设计里属于典型的“小切口、全流程”项目。小程序端要处理点单、购物车、订单状态,管理端要维护商品库存、统计销量,中间还夹着微信登录、支付回调、消息通知这类绕不开的第三方对接。很多同学做完这个项目&a…

📰

系统运维核心指南:从基础设施到自动化工具生态

1. 系统运维到底是什么:先把这个概念掰开揉碎 很多人第一次听到"系统运维"四个字,脑子里浮现的画面往往是:一个程序员坐在电脑前,屏幕上一堆命令行在跑,偶尔敲两下回车,然后对着监控面板发呆。这…

📰

等保三级数据库合规选型:ECS自建 vs 瑶池RDS核心差异

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

模型部署实战:从PyTorch到ONNX的推理服务化全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

三电平有源电力滤波器仿真设计与工程实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

手写LRU缓存:从LinkedHashMap到高并发缓存设计

1. 面试官问出这道题时,到底在考察什么大概没有一个Java岗位的面试能绕开“手写LRU缓存”这道题。我在哈罗出行的面试中就碰到了,而且面试官不是简单丢一句“你实现一个LRU”,而是先抛了一个场景:假设线上有个热点商品列表&#x…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬