高并发会话状态同步优化:ConcurrentHashMap与分片锁实践
发布时间:2026/10/20 21:14:41 锦皓数字建站

1. 会话状态同步为什么这么容易成为性能瓶颈1.1 会话状态不只是readSeqNo那么简单做消息网关后台这些年我最大的感受是网络层和消息队列往往不是最麻烦的真正让人半夜爬起来看告警的往往是内存里的会话状态同步。很多人一听到“会话状态同步”第一反应就是已读位点update一下。实际上一个IM网关里要维护的状态比这多得多。至少要包括当前会话的基础信息会话ID、用户身份、接入网关、协议类型、设备信息消息位点最新消息序号、已读位点、离线拉取游标在线能力连接状态、最后心跳时间、在线设备列表、多端同步版本号以及临时挂起的待推送消息和重试信息。这些字段基本都要存在内存里不可能每次同步都去查数据库。我见过不少从传统业务后端转来做IM的人一开始只把已读位点当状态结果线上问题接二连三。原因也很简单一条消息进入网关后至少要经历这么几步从上行通道或消息队列里拿到消息判断目标会话是否在线推进最新消息序号更新未读计数给在线端下发消息收到ack之后再改已读位点最后把离线拉取游标往前挪。这一套动作在几十毫秒内可能被多个线程反复触发如果这些状态没有一个统一的并发控制就会出现“明明已经已读客户端刷新后又变成未读”这类回退问题。所以这里说的“会话状态同步”核心并不是数据怎么存而是多个线程同时更新同一批状态时怎么保证更新的可见性和顺序性。lastSeqNo、readSeqNo、在线状态这些字段对业务来说必须像一笔账一样要么一起变要么都不变。拆散成多个独立字段去各自更新早晚会裂开。1.2 跨字段、跨Key联动才是锁竞争的真因ConcurrentHashMap本身能保证单个key的原子替换所以很多第一版实现都会直接往map.compute(sessionId, ...)上靠。但我要说一句大实话只靠compute解决不了真正的并发问题因为真正的业务逻辑几乎不会只更新一个key。最典型的就是群消息。一条群消息要推给几百上千个成员会话每个成员的lastSeqNo都要改未读状态也要维护。如果你的代码是按成员一个一个compute其他线程在中间插入一笔更新那整个批量操作就已经不是一个原子事务了状态之间互相覆盖只是时间问题。另一种典型场景是已读回执批量上报。客户端一次上报几十条已读消息服务端要同时更新readSeqNo、会话最后活跃时间、多端同步版本号还要决定是否触发一次消息撤回。这些字段凑在一起是一个完整的业务状态变更如果被拆成多个Map分别存储比如位点一个Map、同步版本一个Map、心跳一个Map那跨Map的组合更新根本无法通过compute保证原子性。我还接手过一版直接用synchronized(map)包住整个会话状态表的实现所有操作全部排他执行。在线规模小的时候确实没问题一旦连接数上了几万消息QPS到几千几乎每个消息都要抢全局锁一个慢操作或者一次GC停顿就能让所有线程堵在锁外。压测数据里锁等待时间占了总耗时的20%以上吞吐量直接碰到天花板。1.3 全局锁真正伤害的是线程池全局锁的问题不只是慢它还会伤到线程池的整体调度。Java线程池里的线程一旦阻塞在synchronized锁上并不会像IO等待那样触发线程池动态扩展因为它无法判断这个锁什么时候能释放。大量线程阻塞在锁上的后果是后面排队的任务全部积压线程池对外表现出“假死”状态看起来CPU不高但请求就是处理不完。这也是我后来坚持把锁粒度从“一把锁”改成“一组锁”的原因。全局锁把所有操作强行串行而分片锁只让落在同一个分片内的操作串行。不同分片之间完全并行这样锁竞争就被限制在很小的范围里对整体吞吐的影响几乎可以忽略。我的目标很简单核心状态更新必须原子但原子性的作用域要尽量小。全局锁是把作用域放大到整个表每key一把锁是把作用域缩小到单个会话。在这两者之间分片锁是工程上最均衡的折中。2. ConcurrentHashMap与分片锁的选型与设计2.1 CHM为你解决了哪一半问题Java 8之后的ConcurrentHashMap内部是桶数组加红黑树的结构写入时对单个桶加锁或CAS读取基本不加锁。它对高并发读取和单key原子操作非常友好get在绝大多数场景下无锁putIfAbsent、compute、merge这些都是针对单个key的原子方法。这对会话状态同步来说是一个很合适的底座。我们可以把SessionState对象全部放进一个ConcurrentHashMap状态更新走分片锁但如果只是读取当前值就直接从CHM里拿不需要经过锁。这样读操作和写操作的竞争被彻底分开不像全局锁方案里读操作也要排队。但要注意ConcurrentHashMap不负责跨多个key的原子组合也不负责多个字段之间的事务性修改。这两件事正好是分片锁要补的。所以我的方案不是“用CHM替代锁”而是“CHM管存取分片锁管联动更新”两者各干各的。2.2 分片锁到底怎么实现分片锁的设计在代码上并不复杂。核心是维护一个锁对象数组根据key的哈希值算出一个分片下标更新状态时只锁对应的那个分片。下面给出我在实际项目里用过的简化版本public final class SessionStateStore { private static final int SHARD_COUNT 1024; private final Object[] shardLocks new Object[SHARD_COUNT]; private final ConcurrentHashMapString, SessionState states new ConcurrentHashMap(4096); public SessionStateStore() { for (int i 0; i SHARD_COUNT; i) { shardLocks[i] new Object(); } } private Object shardOf(String sessionId) { int h sessionId.hashCode() ^ (sessionId.hashCode() 16); return shardLocks[h (SHARD_COUNT - 1)]; } public void updateReadSeq(String sessionId, long newSeq, long ts) { Object lock shardOf(sessionId); synchronized (lock) { SessionState st states.get(sessionId); if (st null) return; if (newSeq st.readSeqNo) return; st.readSeqNo newSeq; st.lastActiveAt ts; st.syncVersion; // 这里还能继续更新未读消息数、撤回标记等字段 } } }这里有几个关键细节。第一分片锁数组用普通Object就够了不需要ReentrantLock。因为这里不需要条件等待、不需要支持中断、不需要超时控制synchronized在JVM优化下已经足够高效。引入更重量级的锁反而增加开销。第二下标直接用hash (SHARD_COUNT - 1)计算所以SHARD_COUNT必须是2的幂。这样下标计算极快没有取模除法。在key计算里我也先做了一次hashCode() ^ (hashCode() 16)的扰动把哈希高位的影响扩散到低位避免低几位相同的key全部撞进同一个分片。第三所有对SessionState的联动修改都放在同一把分片锁里。这样其他线程进入同一个分片时能看到完整的状态变更不会出现readSeqNo已经更新而lastActiveAt还是旧值的撕裂状态。提示分片锁锁的是shard不是单个key。两个不相关的会话如果落在同一个shard里仍然会短暂串行。这是折中不是缺陷。2.3 分片数量怎么定基于活跃会话和热点分布分片数选多少是个讲究事。我最早图省事选了64压测时依然能看到竞争因为热点时段的在线用户分布并不均匀某些热门房间的所有成员都集中在少数分片上。后来改成1024内存开销几乎没有变化竞争明显下来。分片数可以通过几张表来权衡分片数锁对象内存开销单分片承载压力适用场景64极小偏高容易热点聚集早期小规模、接入方单一256很小可控常规在线5万以内的网关1024可以忽略较低20万在线、单节点并发较高8192几十KB锁对象更低但管理复杂多热点key需要单独拆锁时做个简单估算。假设活跃会话10万个分片数1024平均每个分片里大约98个key。即使同一分片内的操作偶尔碰撞一个分片内的操作在任意时刻也只涉及几十个key完全不会像全局锁那样把所有线程赶到同一个门口。锁对象本身非常轻1024个Object只有几十KB对一个8C16G的网关节点来说完全不是负担。真正要注意的是不要为了追求“极致并发”把分片数设成上百万。分片太多时哈希扩散要求更高缓存行上的伪共享也可能更明显收益反而不大。2.4 为什么我坚持用synchronized而不是Lock很多人看到要细化锁粒度第一反应是上ReentrantLock或者StampedLock。我在这个场景里试过ReentrantLock后来又换回synchronized原因有几点。ReentrantLock能做的那些事比如公平锁、可中断、超时获取在会话状态同步里几乎用不到。这里需要的是最普通的互斥synchronized语义已经覆盖。JVM对synchronized做了大量优化包括偏向锁、轻量级锁、锁膨胀大多数情况下锁持有时间在微秒级ReentrantLock不会带来明显优势。如果未来需要在线诊断锁竞争可以用ReentrantLock的tryLock加统计来做细粒度观测但生产环境的主力还是synchronized更稳。锁是手段不是目标。让锁内代码足够短才是性能的关键。3. 高并发消息收发链路的改造与压测3.1 重构消息处理链读不锁、写分片、推送异步再讲整个消息收发链路怎么改造。一开始的流程是客户端上行消息或ack请求线程直接去操作共享内存状态然后在全局锁里更新位点和在线状态最后丢给推送线程。这个流程里锁等待时间被算进请求链路等到高峰期一个线程等锁等久了还会拖累线程池里其他任务。改造后我把链路拆成两段。请求线程只负责解析和路由然后按sessionId进入分片锁更新状态状态更新完生成一个“状态变更事件”放进队列由异步线程处理推送和持久化。同步操作仍在当前线程完成但推送不阻塞在锁内。另一个关键优化是读取。对“读已读位点”、“判断会话是否在线”这类只读操作直接用states.get(sessionId)完全不拿分片锁。因为CHM的get在大多数场景无锁SessionState内部的关键字段用volatile声明读端能看到一个稳定的发布版本。这样读流量几乎不参与锁竞争只有真正改状态时才进入分片锁。这段读和写分离的调整效果非常直接。3.2 用数据看收益用公式看竞争我用一个8C16G的节点模拟20万在线连接、消息峰值约每秒23000条的负载对比改造前后的指标指标改造前全局锁改造后分片锁P99消息处理延迟约48ms约9ms平均锁等待时间占比21%2%以下节点支撑连接数约12万20万以上GC暂停次数分钟内超过200ms多次明显减少不同机器、不同业务模型下数字肯定不一样趋势是确定的分片锁不会把单条消息处理时间降到微秒级但它能把锁等待的时间从请求路径里大幅挤出去。为什么有效可以从竞争公式上看。假设平均每次锁持有时间是2微秒消息进入率是每秒10000次。如果分片数是1024且Key分散均匀单个分片平均每秒处理大约10次进入那理论上单个分片的锁占用率只有十万分之二几乎不构成竞争。如果把分片数降到64单个分片每秒进入约156次占用率就变成万分之三仍然不高但如果某个热点分片集中了每秒2000次进入占用率就变成千分之四碰撞概率明显上升。这就是为什么热点Key要单独处理的原因——平均数掩盖了长尾热点分片的竞争会被放大。3.3 热点会话必须单独处理分片锁不是万能的。如果某个超大群或者某个热点账号的消息特别密集和它落在同一个分片里的其他会话就会被殃及。我遇到过几万人的大群每次群发消息时整个分片锁几乎全被群成员的状态更新占着其他普通会话的延迟涨得很快。处理方式是给热点会话单独开一把锁。SessionState里加一个hotLock字段当判断某会话热度超过阈值后续更新不再走分片锁而是走hotLock。public void updateSeq(String sessionId, long newSeq) { SessionState st states.get(sessionId); if (st ! null st.isHot()) { synchronized (st.hotLock) { // 热点会话专用锁 } } else { synchronized (shardOf(sessionId)) { // 普通分片 } } }热点状态是动态的不能只进不出。我用一个时间窗口统计热度连续一段时间热度下降就取消hotLock标记让它回到分片锁控制。如果所有曾经热门的会话都一直保留独立锁内存和管理负担又会冒出来。3.4 别忘了进程边界会话粘滞与跨节点同步分片锁解决的是单节点内部的并发问题。网关本身就是多节点部署的如果同一个会话被两个节点同时读写再精细的分片锁也保护不了因为锁不会跨进程生效。我在这类系统里一直强调会话粘滞同一个会话的所有消息尽量路由到同一个节点上处理。要做粘滞最简单的方式是会话ID节点ID的一致性哈希取模这样每个会话在某一时段只属于一个进程。只要做到这一点进程内的分片锁就能完整保护该会话的状态同步。有些业务无法做严格粘滞比如用户从两台设备同时登录离线状态要跨节点同步。这种情况我建议把跨节点的状态同步收敛成一个异步事件本机先更新本地状态再将变更事件发到其他节点其他节点通过版本号合并。不要在跨节点路径上尝试用分布式锁延迟和故障面都太大。4. 实战问题与排查实录4.1 已读回退volatile与快照读取上线分片锁后有段时间线上偶发已读回退客户端明明上报了已读很高的消息序号后台状态却短暂变成旧值。排查下来发现问题不在分片锁而在从CHM获取SessionState引用后继续拿这个对象做后续处理没有读取最新版本。另一条线程刚好改了readSeqNo当前线程读到的还是旧字段。解决方法是把SessionState里的readSeqNo、lastSeqNo、syncVersion都声明为volatile所有更新在分片锁内按顺序写完。如果是无锁读取关键信息不要跨状态字段一次性读很多项而是先拿state引用再把需要的字段复制到局部变量这样能避免看到一个字段新、一个字段旧的撕裂视图。更讲究的做法是给每次状态变更生成一个不可变的快照对象读取时直接把整个SessionState替换成新对象。这在内存连续变更时会多产生一些垃圾对象但读一致性最干净。我后来在关键路径上用了这个方案已读回退就再没出现过。4.2 僵尸会话过期清理的执行顺序会话状态一直放在CHM里如果没有过期机制离线用户的状态永远占着内存。最离谱的一次有效在线只有十几万Map里却攒了几百万“僵尸会话”内存和GC压力全上来了。给CHM加过期策略第一步是在SessionState里记录lastActiveAt周期性扫描。但扫描本身也要走分片锁否则一边清理一边更新会出问题。正确流程是遍历所有key按key取分片锁如果超过心跳超时阈值就删除否则跳过。同时任何写入状态的操作都会刷新lastActiveAt避免误删活跃会话。清理周期我建议设成心跳超时时间的一半比如心跳10秒就每5秒扫一次。如果Map特别大扫描本身有CPU开销可以维护一个按时间排序的延迟队列先只处理一段时间内没有活动的会话。小规模节点直接全量扫描就够用不用一上来就上轮子。4.3 批量操作死锁锁排序与任务离线化第三个问题是批量更新。比如批量已读回执一次要更新几十个会话如果在代码里不控制拿锁顺序A线程持有1号分片锁要等2号分片锁B线程持有2号分片锁要等1号分片锁这个瞬间就死锁了。死锁的解决核心是固定锁顺序。最稳的做法是把所有要操作的sessionId排序后统一从大到小或从小到大拿锁ListString ids new ArrayList(sessionIds); ids.sort(Comparator.naturalOrder()); for (String id : ids) { Object shardLock shardOf(id); synchronized (shardLock) { // 更新该会话状态 } }排序后所有线程都按同一个全局顺序获取锁循环等待条件被打破死锁不会再发生。但要注意一个批次的会话可能落在不同分片拿完第一个锁再去拿后面的锁时如果别的线程恰好持有后面的锁等待是不可避免的。为了避免长时间占用前面的锁我会把“必须同时持有多把锁”的批量操作离线化接到请求后先落到后台任务队列由单线程串行更新前端只要持久化请求不需要同步等待完成。批量回执这种场景的并发量远低于单消息写路径单线程串行完全够用。4.4 如何快速定位锁热点遇到性能问题我习惯先用工具看锁不能靠猜。最常用的是jstack线上抓一份线程dump搜状态库类的关键字很快能看到线程到底卡在哪把锁上。如果发现大量线程都停在某个shardLocks[i]的monitor上说明这个分片存在热点操作或锁内耗时偏高。这时再仔细看是哪条线程持有锁执行上下文里是不是混了RPC、SQL或者大字符串操作。很多时候锁热点是锁内耗时太长造成的不是锁竞争本身。另外一个习惯是给锁等待时间打点。改造后我加了一个计数器按分片维度统计锁等待总时长。线上只要看到某个分片的等待时间异常升高就能提前发现热点会话或者批量任务没走离线队列不用等故障爆发才回头看。5. 经验小结和最后建议这套分片锁方案并不是什么新发明认真说起来它就是数据库锁粒度设计思想在内存里的一次落地。但放到消息网关上有几个经验很值得分享。第一先想清楚数据怎么存再想锁怎么分。如果状态字段一开始就散落在好几个Map里什么锁方案都别扭优先应该合并数据结构。第二分片锁真正的价值是把锁碰撞概率压低到“噪音”级别。主流操作的锁持有时间如果能控制在几十微秒内即使偶尔碰撞对整体吞吐影响也非常有限。第三别只盯平均延迟线上体验往往被长尾拖垮P99和P999才是关键指标。最后再分享一个小技巧。我给每个SessionState挂了一个syncVersion字段每次状态改动就自增。客户端连上时服务端把这个版本号返回给客户端后续如果版本号变化较大客户端就知道本地状态可能落后会主动发起一次重新同步。这个字段让高并发下的状态同步不光是“锁写对了”还做到了客户端与服务端最终一致成本极小价值却很大。我踩过几次坑之后如果说只能留一句话那就是不要急着上花哨的锁先把锁内代码压缩成纯内存更新再用合理分片去控制碰撞概率。单节点内部状态同步做到这一步高并发场景基本就稳了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。