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 ConcurrentHashMap<String, 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排序后,统一从大到小或从小到大拿锁:List<String> 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字段,每次状态改动就自增。客户端连上时,服务端把这个版本号返回给客户端,后续如果版本号变化较大,客户端就知道本地状态可能落后,会主动发起一次重新同步。这个字段让高并发下的状态同步不光是“锁写对了”,还做到了客户端与服务端最终一致,成本极小,价值却很大。我踩过几次坑之后,如果说只能留一句话,那就是:不要急着上花哨的锁,先把锁内代码压缩成纯内存更新,再用合理分片去控制碰撞概率。单节点内部状态同步做到这一步,高并发场景基本就稳了。
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 ConcurrentHashMap<String, 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排序后,统一从大到小或从小到大拿锁:List<String> 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字段,每次状态改动就自增。客户端连上时,服务端把这个版本号返回给客户端,后续如果版本号变化较大,客户端就知道本地状态可能落后,会主动发起一次重新同步。这个字段让高并发下的状态同步不光是“锁写对了”,还做到了客户端与服务端最终一致,成本极小,价值却很大。我踩过几次坑之后,如果说只能留一句话,那就是:不要急着上花哨的锁,先把锁内代码压缩成纯内存更新,再用合理分片去控制碰撞概率。单节点内部状态同步做到这一步,高并发场景基本就稳了。