做移动端IM接入的兄弟应该都有印象——消息明明发出去了、也收到了,但会话的未读数、最后一条消息时间戳、置顶状态经常对不上。客户端反复下拉刷新,后端日志里全是超时重试。我接手这套基于个微iPad协议场景的会话状态同步模块时,面临的正是这种局面:内存里用ConcurrentHashMap存会话状态,读写看起来“线程安全”,但只要并发一上来,状态就乱、CPU就飙、偶发死锁。折腾了一轮之后,问题集中在Java的ConcurrentHashMap分片锁设计上——把细粒度锁、热点key管理和锁时序做对了,吞吐才真正稳下来。
这篇文章不聊客户端协议本身的实现细节,只讲后端的会话状态管理。核心场景是:一个高并发的消息收发模块,每秒钟有成百上千条消息进出,需要维护大量会话(session)的未读数、游标、置顶、免打扰等字段。对于正在设计类似IM会话模块、或者被线上并发更新状态问题折磨的Java后端同学,这篇内容应该能直接帮上忙。
1. 别低估“会话状态”这四个字:它到底在同步什么
很多人以为会话状态同步就是把“未读数加一”这种小事做好。实际上,一个会话状态对象在内存里要维护的字段比想象中多,而且每个字段的更新频率、更新模式完全不同。
1.1 一次经典的状态错乱现场
先说一个我实际遇到的案例。某天线上报警,用户反馈会话列表的未读数量对不上,有的会话未读显示99+,点进去却是空;另一个会话明明有新消息,列表排序却把几天前的会话顶在了上面。拉日志一看,状态写入的交叉执行顺序乱套了。
复盘的时候定位到问题根源:
- 消息推送线程A收到新消息,执行
unread = unread + 1,同时更新lastMsgSeq。 - 客户端同步线程B在用户点开会话时,执行
unread = 0,重置游标。 - 两个线程并发读写同一个
SessionState对象,而unread和lastMsgSeq不是原子更新的。
更隐蔽的是,即使我用了volatile int unread,unread = unread + 1这种read-modify-write操作在并发下依然会丢更新。A线程读到的旧值被B线程的置零覆盖,未读数从1直接变成0,或者从1变成2后又跳回1,最终用户看到的状态永远是错的。
1.2 线上数据模型与更新模式
我把这套模块的数据模型简化后画成下面这张表,每个字段的写特性不一样,这直接决定了锁怎么加:
| 字段 | 类型 | 典型更新方式 | 写频率 |
|---|---|---|---|
| sessionKey | String | 不变 | 无 |
| lastMsgSeq | long | 单调递增赋值 | 极高(收消息) |
| unreadCount | int | 小步加减 / 置零 | 极高(收消息、已读) |
| top | boolean | 整体替换 | 低(用户操作) |
| muted | boolean | 整体替换 | 低(用户操作) |
| lastActiveTime | long | 整体替换 | 中(心跳、点击) |
| draft | String | 整体替换 | 极低(草稿保存) |
| syncVersion | long | 单调递增 | 中(多端同步用) |
更新模式可以归成三类:
- 高频小步更新:
lastMsgSeq、unreadCount这种,几乎每条消息都会触发,要求延迟极低。 - 低频整体快照替换:
top、muted这种,用户操作时会直接覆盖整个字段。 - 跨会话批量更新:例如“一键已读”会把一批会话的
unreadCount同时置零,或者把某个群聊的状态批量迁移到新会话。
这类状态不仅在内存里保存,还要异步落库、推送给其他在线端。读多写少、单点热点高、跨会话操作存在事务性需求——正是这三个特性叠加,才让同步模块成为整个消息收发链路里最容易出性能问题的环节。
提示:如果只是单个会话的单个字段更新,随便加把锁就行。真正麻烦的是批量操作和状态一致性同时出现。这也是后面要引入分片锁的核心原因。
2. ConcurrentHashMap在会话场景的三种失效姿势
ConcurrentHashMap确实是Java并发容器里的优等生,但用它直接存会话状态,我只能说“能用,但不等于够用”。在真实的高并发消息收发场景下,它有三个很明显的坑。
2.1 复合操作不是原子操作:read-modify-write的竞态现场
ConcurrentHashMap保证的是单个方法(get、put、compute等)的原子性,但业务上的“先读后写”它管不了。
写一段典型的错误代码:
// 错误示例:并发下会丢更新 SessionState state = sessionMap.get(key); if (state == null) { state = new SessionState(key); sessionMap.put(key, state); } state.setUnread(state.getUnread() + 1); // 竞态窗口两个线程同时 get 到同一个 state,同时执行getUnread() + 1,再同时写回,结果就是只加了1而不是加2。更隐晦的是get和put分开写时,两个线程可能各自 new 出一个新对象,后 put 的覆盖前 put 的,造成状态“凭空丢失”。
2.2 热点会话把并行打回原形
消息流量天然符合二八法则:少数几个大群、客服会话、热门账号会贡献绝大部分消息量。对ConcurrentHashMap来说,同一个key的所有写操作天然是串行的,这没问题,毕竟状态一致性要求如此。问题在于热点key所在的哈希桶会连带影响其他key的并发度。
JDK 8的ConcurrentHashMap对单个桶(bin)加锁,哈希到同一个桶的key共享同一把监视器锁。热点key如果和一个冷key落在了同一个桶里,冷key的更新也会被热点key长时间压制。我做了一个统计,集群里top 10个热点会话占了总写量的45%,这些key散落到64个桶里之后,仍然有5-6个桶长期处于高竞争状态。等于是少数热点把整体的并行度拖成了串行。
更难受的是,长期高竞争的桶还会触发扩容、红黑树转换等额外机制,线程CAS失败重试的次数直线上升,CPU空转严重。而且这种情况用ConcurrentHashMap自带的机制很难绕开——它不知道哪个key是热的,也不可能为某个key单独开一条“快车道”。
2.3 compute与全局锁的局限:为什么需要可控的分片临界区
有人会说:“那用compute不就行了吗?它能把复合操作放到bin锁里原子执行。”
sessionMap.compute(key, (k, state) -> { state.setUnread(state.getUnread() + 1); ... return state; });对,单key的复合操作,compute确实好用。但问题是:
- compute的临界区是“开发者不可控的”,你没法在临界区里同时操作两个key;比如跨会话批量已读,compute做不到一个原子操作内更新多个会话。
- compute如果在临界区内做了耗时操作(比如填充一个很重的快照对象),会把整个bin锁住,殃及同一个桶的其他key。
- 拿全局锁(synchronized整个方法)虽然简单,但代价是所有会话的写操作全部退化成单线程——这在消息量稍大的场景立刻崩。
分片锁(striped lock)的思路则是:预创建固定数量的锁,按key的哈希值把不同的key映射到不同锁;同时又保留热点key升级机制,让真正的热点key拿到独立锁。这样锁的粒度由我控制,批量操作也能编排,而不是被ConcurrentHashMap的桶结构锁死。
3. 分片锁落地:锁数组、热点识别与锁升级策略
方案设计的核心就一句话:把竞争范围控制在“该竞争的地方”。让冷key之间尽量互不干扰,让热key单独隔离,让批量操作有明确的锁获取顺序。
3.1 锁数组分片与哈希散列
先实现一个基础的StripedLock。它的作用不是锁某个具体key,而是把一批key映射到有限的锁上:
public class StripedLock { private final ReentrantLock[] locks; private final int mask; public StripedLock(int stripes) { // 向上取整到2的幂,方便用位运算替换取模 int size = 1; while (size < stripes) size <<= 1; this.locks = new ReentrantLock[size]; this.mask = size - 1; for (int i = 0; i < size; i++) { locks[i] = new ReentrantLock(); } } public Lock getLock(String key) { // 二次散列,避免直接使用String.hashCode的分布缺陷 int h = key.hashCode(); h ^= (h >>> 16); return locks[h & mask]; } }分片数选多少比较合适?我选了64。理由有三:
- 锁对象本身有内存开销,64把锁微不足道,即使到128、256也问题不大。
- 分片数太少,碰撞率高;分片数太多,单锁保护的key太少,锁竞争下降,但批量操作时要拿的锁也变多,协调成本上升。
- 在64个分片下,任意两个随机key落在同一个锁上的概率大约是
1/64,也就是1.56%左右。线上qps上万时,这个碰撞率完全可接受。
哈希散列这里有个小细节:直接拿key.hashCode()取模不均匀,因为不同会话的sessionKey往往有相同前缀,哈希值的低位分布差。所以要做一次h ^= (h >>> 16)的扰动,把高位信息混入低位。
3.2 热点key识别与独立锁升级
基础分片锁的问题是:热点key仍会和一批冷key共享锁。一个热门大群每秒更新上千次,它所在分片的那把锁就会被长时间占用,同分片的冷key全部被拖累。
所以我在分片锁之上加了热点锁层:
public class SessionLockManager { private final StripedLock stripedLock = new StripedLock(64); private final ConcurrentHashMap<String, ReentrantLock> hotLocks = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, LongAdder> accessCount = new ConcurrentHashMap<>(); private final int HOT_THRESHOLD = 500; // 每秒访问量阈值 private final int HOT_MAX_SIZE = 64; // 热点锁上限 public Lock getLock(String key) { ReentrantLock hotLock = hotLocks.get(key); if (hotLock != null) { return hotLock; } return stripedLock.getLock(key); } public void recordAccess(String key) { accessCount.computeIfAbsent(key, k -> new LongAdder()).increment(); } // 定时扫描,由后台单线程调度 public void promoteHotKeys() { for (Map.Entry<String, LongAdder> e : accessCount.entrySet()) { if (e.getValue().sum() >= HOT_THRESHOLD && !hotLocks.containsKey(e.getKey())) { if (hotLocks.size() < HOT_MAX_SIZE) { hotLocks.putIfAbsent(e.getKey(), new ReentrantLock()); } } e.getValue().reset(); } } }识别热点用的是LongAdder计数器加定时扫描,每秒跑一次。计数达到阈值(比如每秒500次访问)就把这个key升级成独立锁;同时设置热点锁数量上限,防止内部map无限膨胀。
升级的根本逻辑是:热点key一旦升级,后续它所有的锁竞争都在自己的独立锁上,和其余key彻底隔离。哪怕某个分片锁再拥挤,只要热点key不在里面,冷key的延迟就能恢复到正常水平。我上线后观察,top热点会话的P99延迟从原来的120ms降到了30ms以下,同一个分片里冷key的超时告警也消失了。
3.3 锁的获取、释放与伪共享处理
锁获取和释放的代码不复杂,但有几个容易犯的错:
- 锁内不能做耗时操作。凡是数据库写入、RPC调用、消息推送,一律挪到锁外。锁内的活着时间应该只有几百微秒,只是“改几个字段+构建快照”。
- 获取多个锁时一定要保证全局顺序一致,否则必然产生死锁(这个坑我在第6章会展开讲)。
- 锁数组的伪共享问题。64个ReentrantLock对象是连续分配的,相邻对象很可能落在同一个CPU缓存行。某个锁频繁更新状态时,会导致相邻锁所在的缓存行被标记失效,其他线程获取相邻锁时不得不重新从主内存加载。一个简单规避办法是给每个锁对象填充padding,让锁对象均匀跨越缓存行。虽然Java对象内存布局有压缩指针的影响,但padding方向是对的,实际测试有5%-10%的收益。
在锁管理器外层,我加了锁等待耗时的埋点统计,便于观察竞争程度。另外把分片数、热点阈值、热点上限都做成配置项,防止上线后定死不好调。
4. 消息收发链路中的加锁点位与时序设计
锁本身只是工具,真正的性能优化在“怎么安排锁”。消息收发的链路很长,但真正需要持锁的窗口非常短,把不必要持锁的步骤全部挪出去,比选什么锁更重要。
4.1 收消息链路:哪些更新必须持锁
收消息的关键路径大概是:消息到达 -> 找到会话 -> 更新游标和未读数 -> 更新最后消息摘要 -> 构建快照 -> 异步落库/推送。
其中“找到会话”是读操作,不需要锁;“异步落库/推送”是IO操作,必须在锁外。真正需要持锁的只有中间三个步骤,因为它们要保证“游标和未读数的一致性”。
示例代码结构:
public void onMessage(InboundMessage msg) { String key = msg.getSessionKey(); Lock lock = lockManager.getLock(key); lock.lock(); try { SessionState state = sessions.get(key); if (state == null) { state = new SessionState(key); sessions.put(key, state); } // 锁内只做内存状态更新 state.setLastMsgSeq(msg.getSeq()); state.setUnread(state.getUnread() + 1); state.setLastActiveTime(System.currentTimeMillis()); state.incrSyncVersion(); snapshot = state.toSnapshot(); // 快速生成不可变快照 } finally { lock.unlock(); } // 锁外做异步持久化、跨端推送 asyncPersist(key, snapshot); asyncNotify(snapshot); }这里有个很关键的原则:锁内不要调用任何可能阻塞的方法。toSnapshot()也要轻量化,提前把快照对象设计成预分配结构,不要在临界区里新建大对象。我见过同事在锁里做JSON序列化,一次序列化要几毫秒,持锁时间瞬间拉长,整条链路直接废了。
4.2 发消息链路:跨会话操作的临界区控制
发消息时不仅会更新当前会话,还会触发“最近会话列表”的整体排序,可能涉及多个会话。如果每次发消息都锁住整个列表,并发度就没了;如果不锁,列表排序和会话状态更新又可能错乱。
我的做法是:列表排序不做成实时计算,而是维护一个“按活跃时间排序”的索引结构,每个会话的活跃时间字段用volatile保证可见性,排序时只读取快照,不在锁内修改。真正需要跨会话持锁的,只有两类操作:
- 批量已读:用户一次已读10个会话,需要把这10个会话的状态一次性更新。
- 会话合并/迁移:例如两个会话合并时,要把A会话的状态原子性地迁移到B会话。
这两类操作频率低,但必须保证原子性。我实现的方法是:先按sessionKey排序,再按顺序依次获取对应的分片锁或热点锁,最后统一执行更新。排序保证两个线程即使以不同顺序发起批量操作,加锁顺序也是一致的,从源头上消除死锁。
public void batchMarkRead(List<String> keys) { List<String> sorted = keys.stream().sorted().collect(Collectors.toList()); List<Lock> acquired = new ArrayList<>(); try { for (String key : sorted) { Lock lock = lockManager.getLock(key); lock.lock(); acquired.add(lock); } // 获取全部锁后执行批量更新 for (String key : sorted) { SessionState state = sessions.get(key); if (state != null) { state.setUnread(0); state.incrSyncVersion(); } } } finally { // 释放顺序与获取顺序相反 for (int i = acquired.size() - 1; i >= 0; i--) { acquired.get(i).unlock(); } } }4.3 读路径快照与写路径优化的平衡
会话状态是典型的读多写少场景:用户打开会话列表,要一次性读几十个会话的状态;但每次读都要看到最新的值。我的方案是双轨并行:
- 高频读路径:单个字段(如unread、top)直接用volatile读取,不拿锁。这个字段的写入都在锁内完成,所以volatile的happens-before语义能保证读线程看到最新值。
- 整体快照路径:会话状态需要打包传给客户端时,用
toSnapshot()在锁内生成一个不可变对象。这样读线程拿到的快照永远不会在读取过程中被写线程改到一半,也不需要加读锁。
有的方案用ReentrantReadWriteLock,读锁保护整体读,写锁保护更新。但这里有个隐藏问题:写操作频繁时,写锁会饿死读线程,或者读锁一直被占导致写线程长时间等待。volatile分散读 + 锁内生成快照的组合,实测比读写锁方案在混合负载下吞吐高出近一倍。
注意:快照不能引用SessionState内部的可变对象。生成快照时如果直接返回state的引用,读线程依然可能读到写了一半的状态。一定要在锁内把字段复制到不可变对象,或者干脆用record类。
5. 压测对比与线上数据:分片锁方案的真实收益
光说方案好没有说服力,直接看压测和线上数据。我准备了三个对照组:全局锁方案、纯ConcurrentHashMap的compute方案、加上分片锁和热点升级后的方案。
5.1 压测设计:要压出竞争而非压出吞吐
压测最容易犯的错是只测“并发量”,不测“竞争结构”。我模拟的是更接近线上的负载:
- 4个线程模拟推送线程,持续写入随机会话的新消息。
- 2个线程模拟用户已读操作,随机把某个会话的未读数清零。
- 4个线程模拟客户端拉取列表,持续读取会话快照。
- 热点数据单独构造:60%的流量打在20%的会话上,剩下的40%流量分散到2000个冷会话。
压测机是8核16G,JVM堆4G,跑15分钟看稳定值。
5.2 三组方案对比与数据分析
| 方案 | 吞吐量(op/s) | P99延迟 | 状态正确性 |
|---|---|---|---|
| 全局锁(synchronized方法) | 约3400 | 780ms | 正确,但吞吐崩 |
| ConcurrentHashMap.compute | 约6800 | 320ms | 单key正确,批量操作无法原子完成 |
| 分片锁 + 热点升级 | 约15600 | 48ms | 正确,跨会话批量操作也可控 |
三个结论值得展开说:
- 全局锁不是不能用,是这种场景不能用。全链路被一把锁串行化,吞吐天花板就在几千量级,消息量再涨只能扩容,成本完全不可控。
- compute方案比全局锁强,但它解决不了跨会话事务。“一键已读”这种操作如果用compute实现,需要分多次执行,中间会有读线程看到“一部分会话已读、一部分没已读”的中间态,这对客户端来说就是数据异常。
- 分片锁的涨幅主要来自热key隔离。如果只做分片不做热点升级,压测吞吐只有约11000,P99是190ms;加上热点升级后,top热点会话之间的锁竞争完全消除,P99立刻降到50ms以内。
纯分片与分片+热key升级的差距,也验证了前面说的二八定律——优化热点,就是优化整体。
5.3 上线后的Metrics验证和热点波动监控
压测数据只是纸面收益,真正盯着它跑了一段线上流量之后,我加了三类监控:
- 每把分片锁的等待时间分布:如果发现某把锁的等待时间持续偏高,大概率说明有新的热点key漏掉了。这比看平均延迟有用得多。
- 热点key的升级/降级频率:升级次数多说明热点在漂移,要检查业务侧是不是在大规模拉群或者搞活动。
- 锁内耗时:如果单次持锁时间突然从微秒级跳到毫秒级,基本可以断定有人在锁内添加了耗时操作,必须立刻告警。
这套监控上线之后,我抓到过两次新热点key漏升级的问题,都是因为某些群聊流量瞬间涨了20倍,而热点计数器还没跑满一个周期。后面我把热点评判周期从1秒缩到500ms,情况好转了很多。
6. 上线后踩过的坑:锁顺序、锁膨胀与锁降级
再好的方案,落地过程中也会踩坑。这几个坑我基本都踩了一遍,写出来帮大家省时间。
6.1 锁顺序不一致导致的会话级死锁
第一次做批量已读时,我图省事直接按参数传来的顺序加锁。结果线上出现两个线程互相等锁:
- 线程A要更新会话K1和K2,先锁K1再锁K2。
- 线程B要更新会话K2和K1,先锁K2再锁K1。
- A拿着K1等K2,B拿着K2等K1,死锁。
排查过程还挺曲折:一开始只看到线程池里的任务堆着不动,CPU不高,但是所有任务都卡住;用jstack看线程栈,发现两个线程都在park状态,等待对方的锁。解法就是第4章说的那个点:跨会话批量操作前先对key排序,保证所有线程按同一个顺序获取锁。
这个坑说明一个道理:分片锁不像ConcurrentHashMap那样自动管理死锁风险,锁的编排一旦到了我们手里,就必须做加锁顺序的纪律约束。我把排序逻辑封装在BatchLockTemplate里,之后所有批量操作强制走模板,从制度上避免再犯。
6.2 锁对象膨胀与内存回收问题
最初一版设计,我给每个热点key都new一个独立的ReentrantLock,并且没有设置上限。某个大群连续几天活跃,hotLocks里就会一直留着一把锁;活动结束后这个key不再热,但锁对象还在map里,几十个会话就把内存撑出几个MB,看起来还好。但如果热点频繁迁移,一天内可能有几万个key短暂成为热点,hotLocks无限膨胀,JVM堆压力立刻显现。
解法是给hotLocks加上回收机制:热度降下来之后,不是立刻移除,而是保留一段时间;超过30分钟仍然没有访问,再把它淘汰掉。具体实现可以用一个简单的定时任务,扫描hotLocks里每个key的最近访问时间,超过阈值就移除。同时严格控制hotLocks上限,我线上配置的是128,防止极端情况。
6.3 热点降级误判引发的连锁阻塞
还有一个坑来自降级条件设计不当。最初判定热点只有“是否超过阈值”一个条件,没有设置“热度连续超过阈值N个周期”的确认机制。结果某次运营活动,一批冷会话被瞬间集中推送,计数器冲高,几十个key同时升级成了热点锁。但活动只持续了两三分钟,热度退散后这些key依然留在hotLocks里,导致后续访问必须经过一个多余的热点锁层级,反而增加了竞争。
后来改成两段式判定:
- 连续3个统计周期(共1.5秒)访问量都超过阈值,才升级为热点。
- 连续10个统计周期访问量低于阈值的30%,才降级为普通key。
这样既防止瞬时脉冲误升级,也保证热度走后锁能及时释放。上线后热点锁的波动明显平滑,短暂活动时不会再出现锁数量暴涨了。
6.4 一个不起眼的hashCode坑
最后分享一个看起来很“低级”但真实踩过的坑。sessionKey最初是用一个自定义对象包装的,equals和hashCode写得比较随意,导致两个业务上完全不同的会话算出来的哈希值相同,全部映射到同一把分片锁上。
症状是:压测并发很低时一切正常,并发一高,某个分片的锁等待时间特别长,其它分片却完全空闲。排查到后来才发现是hash值分布问题,而且因为是自定义对象,String哈希扰动逻辑也没生效。
修复很简单:统一使用String类型的sessionKey,并且在StripedLock里做二次散列扰动。但这提醒我,任何引入哈希映射的设计都要在压测时专门构造哈希冲突用例,不能只盯着并发数。
这套分片锁方案上线后平稳运行了相当长时间,吞吐达标,状态正确性也稳了。最后分享一个实用的小技巧:分片锁的开关和参数一定要做成可配置的,线上如果出现异常,可以一键退化为全局锁模式先止血。虽然全局锁吞吐低,但它永远正确;保住正确性,再慢慢定位问题,比让状态错乱影响用户要强得多。