8GB。这是业务侧算出来的缓存数据量——800 万条记录,每条平均 1KB 的业务对象,一个很朴素的乘法,谁算都不会错。集群搭起来跑了一周之后,监控上三个节点的 JVM 常驻内存各自停在 11GB 上下,加起来 33GB,是原始估算的 4.1 倍。运维的第一反应是"是不是有内存泄漏",开发的第一反应是"是不是 Hazelcast 有问题",两边吵了一下午,最后发现谁都没错——错的是那个乘法本身。
Hazelcast 这类内存数据网格(IMDG)的内存账,从来不是"单条大小 × 条数 ÷ 节点数"这一条式子。一条记录从你的业务代码交到集群手里,中间要经过序列化、分区归属计算、主副本落位、备份副本复制、索引登记、块内对齐这几道手续,每道手续都会往上加一点。加完之后才是"集群实际占用",再往下还要跟 JVM 自身的堆外开销相加,才是你在监控曲线上看到的那个数字。
本文按一条记录进集群之后真正经历的顺序往下拆,不按"概念、配置、选型"那套八股来。先把几个结论摆在这里,后面每一条都会算给你看:
一、集群内存不是节点内存乘以节点数,而是逻辑数据量先乘一个 2 到 4 的放大系数,再摊到节点上。这个系数由序列化方式、副本份数、记录容器开销、碎片、索引共同决定,其中副本份数是最大的单一变量。
二、backup-count 从 1 提到 2,内存涨 50%,网络写入量也涨 50%,而延迟涨的更多。因为同步备份要等 ack,等两个和等一个不是线性的关系。
三、近缓存不是在集群里加一层,而是在你的应用进程堆里再加一份。20 个客户端各缓存 30% 的热点集,等于全量数据又多了 6 份,这些内存算不到集群头上去,只会体现在应用侧莫名其妙的 OOM 上。
四、堆内堆外不是"哪个更好"的选择题,是"GC 停顿和序列化开销你愿意付哪个"的取舍题。堆内 BINARY 省一次反序列化但 GC 会扫到它,堆外 Native Memory 摘掉了 GC 但每次读写都要过一遍序列化。
五、扩容不是加机器那么简单,加机器的那一分钟是集群最脆弱的一分钟。分区迁移会同时吃满网络和 CPU,故障恢复窗口内集群处于少副本状态。
先把那个 1KB 拆开。业务侧说的"1KB",通常指的是这个对象在逻辑上承载的数据量:十来个字段,其中一两个是长度一百字节上下的字符串,剩下的是基本类型。开发按这个估,是因为他在代码里看到的确实就是这么多东西。
但集群存的不是你 new 出来的那个对象,集群存的是这个对象经过序列化之后的字节,外面还要裹一层记录容器。这两层的开销,是绝大多数容量规划漏掉的。
如果 map 配的是 OBJECT 格式,Hazelcast 存的是反序列化后的 Java 对象图。这时候单个对象的堆内占用是这么来的:每个对象有一个对象头,开启压缩指针的情况下是 12 字节加对齐补到 16 字节;每个引用字段占 4 字节;一个 String 不是一段连续的字符,它是 String 对象自身(对象头加 hash 加 coder 加一个引用)指向一个 byte[] 或 char[],那个数组又有自己的对象头和长度字段;如果字段里还有嵌套对象、集合、Map,每一层都重复这套开销。
按行业通用的 JVM 对象布局经验估算(具体以你的 JDK 版本、是否开启压缩指针、字段实际类型为准,需实测),一个"逻辑上 1KB"的 POJO,在堆内以对象图形式常驻时,实际占用通常落在 1.3KB 到 1.6KB。字段越碎、嵌套越深、字符串越多,这个比例越高。反过来,如果对象里塞的是一个大 byte[],那对象头占比就很小,膨胀几乎可以忽略——所以同样是"1KB",不同结构的对象膨胀系数能差出两倍。
默认的 BINARY 格式不一样。它在堆里存的是序列化后的字节数组,查询时按需反序列化。好处是数据紧凑、GC 扫描的对象数量少(一个大 byte[] 比几千个小对象好扫得多),坏处是每次读取都要付一次反序列化成本,而且字节数组本身也有数组对象头、长度字段,加上 Hazelcast 在堆内按块分配,块内会有对齐浪费。
这里有个容易搞混的点:BINARY 格式下的单条体积,取决于你的序列化方式,不取决于对象在堆里的布局。换句话说,换序列化方式,BINARY 模式下单条体积能直接往下砍一半;换 OBJECT 模式,序列化方式对内存基本没影响,因为存的就是对象本身。
这是最狠的一刀。如果你的业务对象没有实现 Hazelcast 的任何序列化接口,Hazelcast 会退回 Java 原生序列化。Java 原生序列化写出来的是什么?类名、类的序列化版本号、字段数量、每个字段的名字与类型签名、父类信息,最后才是字段值。它写的是"这个对象是什么"的完整自描述,而不是"这个对象的值是多少"。
实测过的团队都知道那个数字有多难看:一个十来个字段的 POJO,逻辑数据 1KB,Java 原生序列化之后 2KB 到 2.5KB 是很常见的量级,字段多、类名长、有继承层级的时候能到 3KB。这就是开篇那个 8GB 变成 33GB 的最大单一贡献者。
换成 IdentifiedDataSerializable,写出去的东西就变了:一个 factoryId(整数)、一个 classId(整数)、然后是按你自己写的顺序排下来的字段原始字节,没有类名、没有字段名、没有类型签名。同样的对象,序列化后通常落在 1.05KB 到 1.2KB——比原生序列化省掉一半以上,而且序列化本身消耗的 CPU 也大幅下降。代价是你要手写 writeData 和 readData,字段变了要自己管版本,还要注册 factory。Portable 更灵活一点(支持按字段名部分读取、跨语言),体积比 IdentifiedDataSerializable 略大但远小于原生序列化。
除了 value,还有三笔钱必须算进去。
第一笔是记录容器开销。每条记录在分区里不是光秃秃一段字节,它外面还有记录对象本身、TTL 与失效元数据、版本号、以及 Hazelcast 内部的 Data 包装。这一层的经验值在每条 100 到 200 字节这个量级(具体数值随版本与配置变化,官方未明示精确常量,需在你的版本上实测)。听起来不多,但如果你的单条数据很小——比如存的是几字节的状态标记、计数器、短字符串——这一层反而可能比数据本身还大。
第二笔是key 也要算。很多人做容量估算只算 value,忘了 key 同样要序列化、同样要占记录容器、同样要被复制到备份副本、同样要进索引。一个 16 字节的业务主键,序列化加包装之后落到 30 到 50 字节并不稀奇,800 万条就是 240MB 到 400MB,再乘上副本份数就不是小数目了。
第三笔是索引。给 map 建了索引,每个索引字段、每条记录都要登记一个索引项,经验量级在每条每字段 40 到 80 字节。给三个字段建了索引的 map,单条记录的索引成本就在 120 到 240 字节,跟记录容器是同一量级。而且索引是每个分区独立维护的,不会因为分区多而摊薄。
把上面这些合起来,一台机器上单条记录的完整账可以写成下面这条式子:
M_单份 = V_ser + K_ser + O_rec + (I_idx × F_idx)
M_单条 = M_单份 × (1 + b_sync + b_async) × (1 + f_frag)
M_集群 = N × M_单条 + M_idx_meta + M_ptab
M_每节点 = M_集群 ÷ 节点数 + M_jvm
其中:V_ser 是 value 序列化后体积,K_ser 是 key 序列化后体积,O_rec 是记录容器开销,I_idx 是单条单字段索引开销,F_idx 是建了索引的字段数,b_sync 是 sync backup 份数,b_async 是 async backup 份数,f_frag 是块内碎片与对齐系数,N 是总条数,M_idx_meta 与 M_ptab 是索引元数据与分区表这类固定开销,M_jvm 是 JVM 自身的堆外开销(元空间、线程栈、直接内存、GC 结构、网络缓冲)。
拿开篇那个场景代入一遍。800 万条,逻辑 1KB,key 是 16 字节短主键,BINARY 格式,sync backup 1 份,建了两个字段的索引,用的是默认 Java 原生序列化:
这算出来是 16GB,比实际观测到的 11GB 高。差在哪?差在实际项目里没人会用原生序列化跑 800 万条——真跑起来第一个礼拜就会被内存逼着改成 IdentifiedDataSerializable。把 V_ser 换成 1.1KB 再算一遍:M_单份 ≈ 1150 + 40 + 150 + 120 = 1460B,×2 = 2.92KB,×1.15 ≈ 3.36KB,M_集群 ≈ 800 万 × 3.36KB ≈ 25.6GB,每节点 ≈ 8.5GB,加 JVM 的 1GB 和索引元数据、分区表那一两百 MB,再算上监控 agent 和一点水位波动,11GB 这个观测值就对得上了。
所以开篇那句"8GB 变 33GB"的完整解释是这样的:序列化把它抬到 12.8GB,记录容器与索引抬到 14.5GB,一份同步备份翻倍到 29GB,加索引元数据与分区表到 30GB,三节点摊下来每节点 10GB,再加 JVM 自身那一层,11GB。放大系数 4.1 倍,其中最大的一块是备份副本,第二块是序列化。
搞清楚单条记录怎么膨胀之后,下一个问题是副本。副本是这套账里唯一一个"乘整数"的项,也是最容易在规划阶段被忽略的项。
Hazelcast 4.x 与 5.x 的默认分区数是 271。每条记录的 key 先算哈希,再对 271 取模,落到某一个分区上;分区再按一致性哈希分布到成员节点上。分区本身不存数据,它只是一个归属单位,真正的数据在分区下的记录里。
271 这个数字为什么是质数、为什么是 271,官方文档有说明,这里不展开。对容量规划有意义的是两件事:一是分区数决定了数据分布的粒度,分区太少会导致数据倾斜(某个分区特别大,落在谁头上谁就爆),分区太多会让分区表本身和迁移时的调度开销变大;二是分区是迁移的最小单位,扩容时挪的是分区,不是记录。
按 271 个分区、3 个节点算,每个节点大致分管 90 个分区的主副本,加上别人备份过来的副本,总分区槽位在 180 上下。这个"大致"很关键——一致性哈希不保证绝对均匀,实测下来各节点分区数差个百分之几很正常,做容量规划时要把这个不均匀度算进余量里。
backup-count 默认值是 1,意思是每条数据除了主副本,还有一份同步备份,放在不同的节点上。async-backup-count 默认值是 0,是异步备份,后台线程慢慢追,不阻塞写入。
先说从 1 提到 2 会发生什么。数据份数从 2 份变 3 份,集群总内存占用上涨 50%——注意不是翻倍,是从"两倍数据量"变成"三倍数据量"。这个 50% 是纯内存成本,没有任何折扣。
网络这边更难受。每一次 put,同步备份要把这条数据发到每个备份节点并等 ack。份数从 2 到 3,意味着每次写入的网络出站量从 1 份变成 2 份,涨 100% 而不是 50%,因为主节点自己那份不算网络。也就是说,写流量 ×2。如果集群本来写入就是瓶颈,加一份备份可能直接把写入吞吐打下来一截。
延迟那边更不线性。等 1 个 ack 和等 2 个 ack,在网络有抖动的时候差别会被放大——两个备份里任何一个慢,这次写入就慢。P99 延迟的劣化通常比 P50 明显得多。
那什么时候值得加?我给的判断标准是:只有当你的集群规模到了 5 个节点以上、且能接受同时坏两个节点也不丢数据这个业务要求时,才考虑 backup-count=2。3 个节点的集群配 backup-count=2,意味着每条数据的 3 份分别落在全部 3 个节点上——这时候坏任何一个节点都不丢数据,但同时坏两个就全丢,而你为此多付了 50% 的内存和 100% 的写网络。更划算的做法是把节点加到 5 个,备份保持 1 份:内存省了,容错能力反而更好(5 节点 1 备份,任何单节点故障都能扛,且恢复窗口内还有 4 个节点分摊)。
Near Cache 默认关闭,开启之后会在读取方本地再存一份数据副本。它有两种形态:客户端侧(Java 客户端、各类语言客户端在本地进程里缓存)和成员侧(member 本地缓存别的成员上的分区数据)。两者本质一样——都是在"真正存数据的地方之外"再放一份。
这件事的性质必须讲清楚:近缓存占的内存,不在集群的账上,在你应用进程的堆上。业务方算容量时算的是"Hazelcast 集群要多大",开了近缓存之后,你的总内存占用变成了"集群 + N 个应用进程各自的一份"。这 N 份加起来,往往比集群本身还大。
这个问题在真实项目里出现频率极高,原因通常不是一个,是好几个叠在一起。
第一个原因:内存换了个地方涨,你没在监控里看那个地方。团队给 Hazelcast 集群配了足量内存,压测通过,上线之后开始报 OOM——报的是应用容器的 OOMKilled,不是 Hazelcast 的。因为近缓存把内存搬到了应用进程里,而应用进程的内存是按"无状态服务"配的。
第二个原因:份数被客户端数量放大。假设热点数据占全量的 30%,看起来每个客户端只多 30%。但如果你有 20 个客户端实例,那就是 20 × 30% = 6 份全量数据散落在应用侧。而且这些副本之间没有共享,同一个 key 在 20 个客户端里各存一份。
第三个原因:max-size 按条数算,不按字节算。近缓存的容量上限配置通常是条目数。你按"缓存 10 万条"配了 max-size,结果这些条目平均 5KB 而不是你以为的 500 字节,实际占用就是 500MB 而不是 50MB,十倍差距。配近缓存必须自己换算成字节:max-size × 平均条目体积 × 客户端实例数。
第四个原因:失效事件流本身占内存。近缓存要靠失效通知保持一致,源端数据一变,就要给订阅了的客户端发失效事件。高频写的 map 会产生持续的事件流,客户端侧的事件队列和处理线程如果跟不上,队列会堆积,堆积的那部分也占堆。写越频繁,这个问题越明显——讽刺的是,写频繁的 map 本来就不该开近缓存。
第五个原因:本地淘汰策略与集群不一致。近缓存有自己的淘汰策略(LRU、LFU、随机、基于 TTL),跟集群侧的淘汰是两套。如果配了 TTL,条目会在本地过期后重新拉取,某些访问模式下会造成反复拉取,内存占用曲线呈现锯齿状抖动,峰值比稳态高不少。
所以近缓存的正确用法很窄:读远多于写、热点高度集中、单条体积不大且可预期、客户端实例数量可控——配置字典、权限映射、商品类目、地区编码表这类数据。反过来,写频繁、热点分散、条目体积不可控、客户端实例几十上百个的场景,开近缓存就是在给自己埋雷。
把上面这几层叠在一起,就是下面这张拆解表。表里每一行的"放大倍数"指的是这一项对总内存的贡献方式,不是连乘关系——固定开销那几项是加数,副本那几项才是乘数,用的时候别搞混。
| 内存占用来源 | 放大倍数 | 发生在哪一层 | 能不能关掉 | 什么情况下必须留 |
|---|---|---|---|---|
| 同步备份副本 backup-count=1(默认) | 数据部分 ×2 | 集群层:主副本与备份副本落在不同节点的分区上 | 能关(设为 0) | 生产环境只要数据不能从后端回源,就必须留至少 1 份;缓存类数据也建议留,否则坏一个节点就是一次缓存击穿 |
| 同步备份提到 backup-count=2 | 在 ×2 之上再 ×1.5(总份数 2 变 3) | 集群层:每次写入要走两份网络、等两个 ack | 能关(默认就是 1) | 只有业务明确要求"同时坏两个节点也不丢"且集群已有 5 个以上节点时才加;3 节点集群加它不如加节点 |
| 异步备份 async-backup-count | 再 ×1(在 sync 之外另叠一份) | 集群层:后台线程异步追赶,不阻塞写入 | 能关(默认 0) | 跨机房兜底、可接受丢失最后几秒写入的场景才值得留;它一样占内存,不要当成"省内存的同步备份"来用 |
| 客户端侧近缓存 Near Cache | 按命中集另计 1 份 × 客户端实例数,实测常落在全量的 10%–40% 每份 | 应用进程层:占的是业务服务的堆,不是集群的堆 | 能关(默认关闭) | 读多写少、热点集中、单条体积可预期、客户端实例数可控时才开;先算"命中集体积 × 实例数"再决定 |
| 默认 Java 原生序列化 | 单条 ×1.8–2.5(换 IdentifiedDataSerializable 可压到 ×1.05–1.2) | 序列化层:写入前转字节,类名与字段签名全写进去 | 不能关但能换掉 | 不属于"必须留",属于"必须换"——这是全表性价比最高的一项改造,几乎不花钱却砍掉一半内存 |
| 记录容器与块内碎片(BINARY 堆内) | ×1.1–1.2,另加每条 100–200 字节固定容器开销 | 存储层:堆内按块分配,字节数组对齐与碎片 | 基本不能关 | 必须留,这是格式本身的成本;单条数据越小,这层的相对占比越高,小对象场景要单独测 |
| 索引 Index | 每条每字段 40–80 字节 × 索引字段数 | 存储层:每个分区独立维护自己的索引 | 能关(不建索引就不占) | 有谓词查询的 map 必须留;纯 key 查询的 map 一个索引都不用建,能省下跟记录容器同量级的内存 |
| key 的序列化副本 | 每条 30–50 字节(16 字节短主键的实测量级) | 存储层:key 同样序列化、同样复制、同样进索引 | 不能关 | 必须留;做容量估算时最容易漏的一项,800 万条再乘副本份数就是几百 MB |
| 堆外 Native Memory(HD)的内存管理器 | 数据量之外再留 20%–30% | 存储层:堆外内存的元数据与碎片 | 能关(改回堆内 BINARY) | 单机要放 40GB 以上数据、且要求 GC 停顿稳定可控时必须留;数据量小的时候这 20% 是纯浪费 |
| JVM 自身开销(元空间、线程栈、直接内存、GC 结构) | 每节点固定 0.5–1.5GB,与数据量无关 | 进程层:不进堆,但计入常驻内存 | 不能关,可裁剪线程数 | 必须留;容器环境下这一层算在容器内存里,配 limit 时忘了它就等着被 OOMKilled |
存储位置这一层的选择,决定了前面算出来的那些字节到底落在哪里、由谁来回收、以及 GC 要不要为它们买单。
BINARY 是默认值。数据在 JVM 堆里,以字节数组形式存在,时间一长全部晋升到老年代。好处前面说过——紧凑、反序列化按需、对象数量少。坏处是几十 GB 的老年代一旦触发 Full GC,标记阶段要扫描整个存活对象图,停顿时间以分钟计,这对一个有响应要求的缓存服务是不能接受的。
行业里比较一致的经验是:堆内 BINARY 模式下,单个节点的堆控制在 8GB 到 16GB 以内比较稳妥,超过这个量级就该考虑分区更细、节点更多,或者换堆外。这个数字不是官方硬限制,是 GC 停顿与运维可接受度之间权衡出来的经验值,具体要按你的 GC 收集器(G1、ZGC、Shenandoah 表现差别很大)实测。
如果非要用大堆,G1 要调 MaxGCPauseMillis 和 Region 大小,ZGC 在超大堆上停顿表现好得多但吞吐会有损失,而且 ZGC 需要预留更多的堆外内存给它的转发结构与染色指针相关机制——这部分开销也得算进 M_jvm 里。
OBJECT 格式存的是对象本身。查出来直接用,省一次反序列化,对于"查完立刻做复杂对象遍历"的场景确实快。但代价是双份的:对象图的实际占用比字节数组大(前面算过 1.3 到 1.6 倍),而且堆内对象数量暴涨——800 万条记录如果是 800 万个对象图,每个图里又有一堆小对象,那是几千万个对象,GC 要维护的引用关系复杂度完全不是一个量级。
我的立场比较明确:除非你的 map 明确是"读一次算很久、计算复杂度远高于反序列化成本"且数据量在百万级以内,否则不要用 OBJECT。绝大多数缓存场景是"取出来序列化完直接返回给调用方",BINARY 更合适。而且 OBJECT 格式下你换序列化方式对内存毫无帮助,等于放弃了前面那把最锋利的刀。
堆外模式(Hazelcast 企业版的 High-Density Memory Store,配 NATIVE 格式)把数据放到 JVM 堆外,由 Hazelcast 自己的内存管理器分配和回收。GC 完全看不到这些数据,几十上百 GB 的单机容量也不会给 GC 添负担。
但这本账要加三项:第一,内存管理器本身要占元数据空间,且堆外也有碎片,经验上要在数据量之外再留 20% 到 30% 的余量;第二,堆外只存字节,每次读写都必须过一遍序列化与反序列化,这是纯 CPU 成本,热数据高频访问时这笔开销很显眼;第三,堆外内存不受 JVM 的 -Xmx 约束,它是进程的常驻内存,容器环境下如果不限制 RSS,很容易触发宿主机层面的 OOM killer,而那一刻你连一个 heap dump 都拿不到。
还有一条务实的提醒:堆外模式属于企业版特性,涉及授权,成本要单算。租用物理机自己部署的话,这笔授权费要摊进每台机器的月度成本里,别只比硬件价格。
开了 Persistence(数据持久化到磁盘,重启后从磁盘恢复)之后,集群对磁盘的要求从"能装系统"变成"要扛得住持续的写入 IOPS"。持久化写入的是分区级别的数据文件,加上 WAL 式的日志,写入模式是持续的小块随机写叠加周期性的大块合并。
规划时的经验口径:数据盘至少要是企业级 SSD,且要按"峰值写入吞吐 × 安全余量"来选 IOPS,而不是按容量选。一块 2TB 的机械盘容量完全够装 30GB 数据,但它的随机写 IOPS 撑不住一个写入频繁的集群,表现就是持久化线程队列堆积、写入延迟飙高、甚至触发背压把业务写入也拖慢。NVMe SSD 在这类负载上和 SATA SSD 的差距是数量级的。
CP 子系统(基于 Raft 的一致性子系统,用于分布式锁、计数器这类需要强一致的场景)的默认配置下成员数建议是 3 或 5——3 个 CP 成员容忍 1 个故障,5 个容忍 2 个。CP 成员挂掉之后,剩余成员要选举、要补日志,这些操作都要落盘(CP 子系统的状态是要持久化的),磁盘慢会直接拖慢故障切换时间。而且 CP 子系统默认是关闭的,要用必须先启用并规划好哪几个节点当 CP 成员——随手把全部节点都设成 CP 成员是大忌,CP 成员越多,Raft 的提交延迟越高。
这一节讲的是"平时没问题、一动手就出事"的部分。集群的内存账算得再准,扩容和故障这两件事没规划好,一样会在线上翻车。
往 N 个节点的集群里加 1 个节点,稳态下新节点要接管大约 1/(N+1) 的分区。3 节点加到 4 个,新节点接管大约四分之一的分区主副本,同时这些分区的备份副本也要重新分布——实际迁移的数据量是"主副本数据量 × 份数"的那四分之一。
按前面那个例子,25.6GB 的集群数据量,3 节点扩到 4 节点,理论迁移量在 6.4GB 上下(含备份副本的重新分布,实际会略高)。
迁移时间是这么算的:T ≈ 迁移数据量 ÷ (内网有效吞吐 × 迁移并发度)+ 固定调度开销。千兆内网扣除协议开销后有效吞吐按 100MB/s 量级估,6.4GB 理论约 64 秒;但 Hazelcast 对分区迁移有并发度限制与迁移间隔控制,不会让你一口气把网络打满(这是有意的保护,避免迁移把业务流量挤死),所以实际时间通常在几分钟到十几分钟这个量级,具体取决于配置与硬件,需实测。
换成万兆内网,有效吞吐上一个数量级,同样的迁移量能压到几十秒到几分钟。这就是内存与网络配比的核心依据——不是"平时用多少带宽",而是"再平衡时你能给多少带宽"。
迁移不只是网络搬运。源节点要把记录读出来、序列化(或反序列化)、打包、发走;目标节点要收、反序列化、写进自己的存储、登记索引、建备份。这是一条完整的 CPU 流水线。
实测过的场景里,一次大规模再平衡能让节点的 CPU 使用率从常态的 20% 冲到 70% 以上,8 核机器尤其明显。这时候业务请求和迁移任务抢 CPU,表现是接口 P99 延迟整体抬高,而不是某个接口变慢。所以扩容要放在低峰,这不是一句保守建议,是因为高峰扩容等于主动发起一次针对自己的资源争抢。
还有一点:迁移期间集群处于"副本正在补齐"的中间状态。这时候如果再坏一个节点,某些分区可能同时丢失主副本和备份副本,那就是真丢数据。这就是为什么扩容要一次只加一个、等再平衡完全结束再加下一个——两个动作叠加会把这个窗口拉长一倍。
节点失联之后,集群要按顺序做四件事:判定成员离开(心跳超时)、重算分区表、把该节点的备份副本提升为主副本、在剩余节点上补齐缺失的备份副本。前三步很快,第四步是耗时的那一步——它本质上是一次全量再平衡,只不过迁移量是"这个节点原本持有的数据量"。
3 节点集群每个节点持有大约三分之一的数据,坏一个就要把三分之一的分区重新分布到剩下两个节点上。按 25.6GB 算,约 8.5GB 要在两个节点之间搬运和重建。而且这时候集群的总容量只剩三分之二,如果原本水位就在 70%,坏一个节点之后剩余节点的水位会冲到 100% 以上——直接 OOM。这就是容量规划要给故障留余量的原因:N 个节点的集群,常态水位不能按 N 个节点的容量算,要按 N-1 个节点算。
网络分区(脑裂)发生时,集群可能裂成两半,两边各自认为自己是合法的、各自接受写入,网络恢复后数据就冲突了。Hazelcast 的 split-brain protection 靠 minimum-cluster-size 这个阈值来防:成员数达不到阈值的那一半,拒绝执行受保护的操作。
这里有个必须点破的算术:2 节点集群配 minimum-cluster-size=2,在网络分区时两个节点各自看到 1 个成员,两边都达不到 2,两边都拒绝服务——集群全挂。配成 1 又等于没保护,两边都能写。所以 2 节点集群在脑裂保护这件事上是无解的,最小规模就是 3。
3 节点配 minimum-cluster-size=3 也不行——坏一个就剩 2,达不到 3,整个集群停止服务。正确的配法是 3 节点配 2:正常态 3 个成员满足,坏一个剩 2 个仍满足,服务继续;脑裂成 1+2 时,2 个那一半满足、1 个那一半不满足,多数派继续服务。5 节点一般配 3,能同时容忍坏 2 个。
如果要用 CP 子系统做分布式锁,前面说过 CP 成员建议 3 或 5 个。这意味着一个同时要跑 IMDG 缓存、又要跑 CP 分布式锁、又要有脑裂保护的最小生产集群,节点数是 5 而不是 3——3 个节点全设为 CP 成员的话,任何一个节点故障都会让 CP 子系统的多数派只剩 2/3(勉强可用),再来一次抖动就没了。这个数量直接决定你的租机成本。
到了该决定"买几台、每台多大内存"的时候。这个问题没有标准答案,但有三条可以量化的边界,把三条都算出来,答案就自己浮出来了。这里要提醒一句:这类常驻型内存服务的机器,选的时候别只比内存单价——一万网络深耕 IDC 19 年(成立于 2007 年),在这类长期满载跑的物理机和裸金属上可以提供 7×24 中文工单与工程师一对一的部署协助,把内存档位、内网带宽、CPU 核数这几项一起对着你的再平衡窗口去比,比单独看"每 G 内存多少钱"有意义得多。
节点越少、单机内存越大,意味着单个节点持有的数据量越大,故障时要在剩余节点之间搬运的数据量也就越大。3 节点 × 每节点 32GB 内存 vs 6 节点 × 每节点 16GB 内存,前者坏一个节点要搬 32GB 量级的数据,后者只搬 16GB 量级。在同样的内网带宽下,前者的恢复窗口是后者的两倍。
这条边界指向多节点小内存。代价是节点多了,网络广播、心跳、分区表同步的开销也上去了,而且机器数量直接线性增加成本。
堆内模式下,单节点堆越大,GC 停顿越长。这个是反向的:单机内存越大,GC 越难看。堆超过 16GB 之后,Full GC 的停顿很可能已经超出你的 SLA,除非你换 ZGC 这种低停顿收集器,或者干脆走堆外。
这条边界指向多节点小内存(堆内模式),或者"大内存 + 堆外"(堆外模式)。
前面说过:N 节点集群的常态水位要按 N-1 个节点算。3 节点集群,每节点只能用到总容量的 2/3;5 节点集群,能用到 4/5;10 节点集群,能用到 9/10。节点越多,可利用率越高——这是多节点的第三个好处,但每多一个节点的边际收益在递减:从 3 到 5 提升了 13 个百分点的可用率,从 5 到 10 只提升 11 个百分点,从 10 到 20 只有 5 个百分点。
我的建议落点:
一个反直觉但很实用的结论:在总内存相同的前提下,节点多一点、单机小一点,几乎总是更安全的选择。唯一不选它的理由是成本和网络——机器台数上去了,内网交换机和带宽也要跟着上。
踩坑现场:开发按 POJO 字段估算 8GB,运维按 8GB 除以 3 台机器配了每台 4GB 堆,上线第三天开始频繁 Full GC,第七天 OOM。两边互相甩锅,最后才发现账本来就不该这么算。
怎么躲:用前面那条公式重算一遍,四个变量逐个取实测值而不是猜——V_ser 拿你的真实对象跑一次序列化量一下,O_rec 和 I_idx 在你的版本上用一条记录测,f_frag 按 10% 到 20% 留,b_sync 按实际配置。算完之后再乘 1.3 的安全余量,这才是给运维的数字。
踩坑现场:为了"更安全",备份从 1 提到 2。内存涨 50% 是预期内的,写入 QPS 掉了 40% 是没想到的——因为每次写入要走两份网络、等两个 ack,而当时的内网是千兆且已经有水位了。
怎么躲:先问业务"能接受同时坏两个节点吗"。多数缓存场景答案是能,因为缓存丢了可以从数据库回源。缓存类数据保持 backup-count=1 就够,把省下来的内存换成更多节点,容错能力反而更好。真要做到双故障不丢,加节点而不是加备份份数。
踩坑现场:选机器时把几家的大内存机型拉了个表,按每 G 内存多少钱排序,挑了最便宜的——双路老平台、64GB 内存、千兆内网、机械盘做数据盘。跑起来发现:再平衡要四十多分钟且期间业务延迟翻倍,开了持久化之后机械盘的 IOPS 直接成为瓶颈,CPU 在反序列化上被打满。
怎么躲:比选的时候把三项一起列——内存档位、内网带宽(1G/10G/25G)、CPU 核数与主频,再单列一项"是否支持 NVMe 数据盘"。像一万网络这类裸金属与大内存物理机(例如裸金属 E5-2698v4×2 档位 ¥3999 起,以官网实时价为准;具体内存档位与加内存的升级价需实时询价)在选型时,建议直接按"再平衡窗口"这个指标去比:同样的内存总量,10G 内网和 1G 内网的恢复速度差一个数量级,这个差距在故障当晚值回全部差价。CPU 也不能太省,序列化是纯 CPU 活。
踩坑现场:为了降低缓存读取延迟,客户端开了 Near Cache,max-size 配了 10 万条。压测时只有 2 个客户端实例,一切正常;上线扩到 24 个实例,每个实例多占 400MB,24 个就是 9.6GB,应用侧开始被 OOMKilled。
怎么躲:开之前先把三件事算清楚——平均条目体积是多少字节、客户端实例数上限是多少、命中集占全量多少比例。三者相乘才是近缓存的总内存成本,这个成本要加在应用进程的内存配额上,不是加在集群上。max-size 按字节换算后再配,别直接抄条数。写完频繁、热点分散的 map 一律不开。
踩坑现场:为了赶活动,一次性申请了 3 台机器加入 3 节点集群。第一轮的分区迁移还没结束,后两台已经加入,触发新一轮再平衡,两次迁移叠加,网络跑满、CPU 打满,业务接口超时率上升,活动期间出了故障。
怎么躲:一次只加一个,等监控确认再平衡完全结束(分区迁移队列清零、各节点数据量重新均衡)再加下一个。扩容动作放在业务低峰。扩容前先确认剩余水位——如果当前水位已经超过按 N-1 算出的上限,先别扩,先清理数据,因为扩容期间的中间状态本身就是风险。
这要看你怎么定义"放"。如果是堆内 BINARY 模式,JVM 堆通常设成物理内存的 60% 到 70%(剩下的给元空间、线程栈、直接内存、GC 结构),16GB 物理内存对应大约 10GB 到 11GB 的堆。再按 N-1 的故障余量打个折,3 节点集群里每个节点的安全堆水位在 7GB 上下。然后用前面的公式反推条数:假设单条含副本含碎片后是 3.4KB,7GB 大约能放 210 万条;如果单条只有 500 字节,能放 1400 万条。所以"16GB 能放多少"这个问题的答案不是 GB,是"你的单条实际占用是多少"——先量单条,再除,别直接拿 16GB 去除 1KB。还有一点要注意,这些数字是经验估算口径,真实值必须在你的版本和你的对象结构上实测。
看你对"丢一点"的容忍度。sync backup 是写入时同步复制并等 ack,节点挂掉之后备份副本上的数据是完整的,提升为主副本不丢数据,代价是写入延迟和写网络翻倍。async backup 是后台线程异步追,主节点写入就返回,速度快、网络压力小,但主节点挂掉时异步备份可能还差几毫秒到几秒的数据,那部分就丢了。我的配法是:缓存类数据(丢了能回源)备份 1 份 sync 就够,不要 async;会话与幂等标记这类不允许丢的,1 份 sync 保底,如果业务能接受极少量丢失再加 1 份 async 做异地兜底;不要把 async 当成"省内存的同步备份"来用——它一样占内存,只是不保证一致。
默认不开,只在三个条件同时满足时才开:读远多于写(读写比在 100:1 以上比较稳妥)、热点集小且集中、单条体积可预期且不大。配置字典、权限映射、地区编码表这类是典型适用对象。不开的理由比开的理由更硬:它占的是应用进程的堆而不是集群的堆,份数会被客户端实例数放大,max-size 按条数配容易导致实际占用超预期十倍,高频写的 map 还会产生堆积的失效事件。判断方法很土:先算出"命中集体积 × 客户端实例数"这个数字,如果这个数字比集群总内存还大,就别开。真需要降低读取延迟,优先考虑把客户端和集群放在同一个机房的同一个内网里,或者用成员侧近缓存(份数可控)而不是客户端侧。
先别急着怀疑泄漏,按可能性从高到低排查四件事。第一,备份副本状态——刚发生过节点上下线的话,集群可能正在补备份副本,或者补完之后旧的副本没释放,这时候内存在涨是正常的,等再平衡结束看会不会回落。第二,记录容器与索引的增长——如果你给 map 新加了索引字段,每条记录都要多占 40 到 80 字节,800 万条加两个字段就是一两百 MB。第三,碎片——删改频繁会导致块内碎片累积,表现出来就是"条数没变但占用涨了",堆外模式下尤其明显。第四,近缓存的本地淘汰与重拉——条目在本地过期后被重新拉取,会造成锯齿状上涨。真正的泄漏排查放在最后:拿堆直方图对比两个时间点,看哪个类的实例数在单调增长。经验上,前三项能解释九成的"内存莫名上涨"。
按顺序试三件事,先试成本低的。第一步,砍单条体积——把默认 Java 序列化换成 IdentifiedDataSerializable,单条体积直接砍一半,堆占用跟着砍一半,GC 压力自然下降。这一步的收益通常最大而成本最低。第二步,降单节点堆——把堆从 24GB 降到 12GB,节点数翻倍。总内存没变,但每个 JVM 的 GC 停顿会明显改善,而且故障恢复窗口也变短了。第三步,换收集器或换存储层——G1 调到合理 Region 大小和目标停顿,或者换 ZGC(停顿极低,但要预留堆外内存、吞吐有损失);如果数据量确实大且要求停顿稳定在毫秒级,上堆外 Native Memory,让 GC 彻底看不到这些数据。不要一上来就加内存——堆越大 GC 越难看,加内存在这件事上往往是反效果的。
单个 Hazelcast 集群不要跨机房部署,这是比较明确的结论。原因是同步备份要求写入时等 ack,跨机房的往返延迟(同城几毫秒、跨城几十毫秒)会直接叠加到每一次写入上,写入吞吐会被延迟吃掉;更要命的是网络分区的概率大幅上升,跨机房链路抖动一次就可能触发脑裂。正确的形态是:单机房内部署完整集群(同一个机房、最好同一个交换机下,内网延迟在亚毫秒到 1 毫秒量级),跨机房用 WAN Replication 做异步复制——WAN 复制是异步的、容忍延迟、不要求强一致,适合做异地容灾。如果你要的是"两个机房都能读",那就在两个机房各部署一套集群,各自独立承接本地读,通过 WAN 复制保持最终一致,不要试图用一个跨机房集群同时满足两地读写。
看你要的是什么一致性。如果你只把 Hazelcast 当缓存用(数据丢了能回源),不需要 CP 子系统,默认的 AP 行为(可用性优先)在这个场景下更合适,且省掉了 CP 成员的规划与开销。如果你要用它做分布式锁、分布式计数器、领导选举这类"不能出错"的原语,就必须用 CP 子系统——AP 模式下的锁在网络分区时可能两边同时拿到锁,这是会直接造成资损的。启用之后按 3 或 5 个 CP 成员规划(3 容忍 1 个故障,5 容忍 2 个),并且不要把集群里所有节点都设成 CP 成员——CP 成员越多,Raft 的提交延迟越高。同时要确认 CP 子系统的持久化路径落在 SSD 上,成员故障切换时日志同步和选举都要走磁盘。
取决于两件事:有没有开持久化,以及磁盘有多快。没开持久化的话,"恢复"不是从磁盘加载,而是从其他节点的备份副本重新填充——本质上是一次全量再平衡,时间是"这个节点原本持有的数据量 ÷ 内网有效吞吐 × 迁移并发度",按 8GB 数据、千兆内网算在几分钟到十几分钟量级,万兆内能压到一两分钟。这段时间集群处于少副本状态,要避免同时重启第二个节点。开了持久化的话,节点启动时从本地磁盘加载分区数据文件,时间 ≈ 数据量 ÷ 磁盘顺序读吞吐,加上日志回放的时间。NVMe SSD 上 30GB 数据的加载通常在几分钟内;SATA SSD 会慢一些;机械盘在这类随机读加顺序读混合的负载上很吃力,可能要十几分钟以上。关键提醒:持久化恢复期间该节点还不算集群成员,不要在这个窗口内重启其他节点。所有时间数字都是量级估算,具体必须在你的硬件和数据量上实测。
答案藏在再平衡的调度方式里。加节点会触发分区迁移,而分区迁移不是一个可以无限加速的管道——它对并发度和迁移间隔都有控制,目的是不让迁移把业务流量挤死。你一次加三个节点,集群不会"一次性把三个节点的分区都排好",而是进入一个持续的再平衡状态:第一批分区还在搬,成员列表又变了,分区表又要重算,刚搬过去的数据可能又要再搬一次。结果就是重复搬运——同一批数据在集群里被搬了两三遍,网络带宽和 CPU 被这些无效搬运吃掉,而真正需要的那次分布迟迟没有完成。
第二个原因是风险窗口被拉长。再平衡期间集群处于"部分分区副本未补齐"的中间态,这个状态下再坏一个节点就有丢数据的可能。一次加三个节点,这个中间态的持续时间是加一个节点的三倍以上,等于把你自己暴露在风险里的时间也拉长了三倍。故障是不会挑时机的。
第三个原因是容量水位的判断会失真。加节点的过程中,各节点的数据量一直在变,你在迁移途中看到的监控数字既不代表老稳态也不代表新稳态,很容易做出错误的下一步决策——比如看到某个节点还很高就再加点什么,结果等迁移结束后发现那个节点反而最空闲。
正确的做法是:一次加一个,等再平衡彻底结束再加下一个。判断"彻底结束"要看三个信号同时成立——分区迁移队列清零、各节点的条目数重新均衡、集群的分区归属分布稳定不再变动。整个过程的每加一个节点之间留出足够的观察时间,确认业务延迟、CPU、网络水位都回到常态。慢是慢一点,但这是唯一一种"扩着扩着不会把自己扩挂"的方式。如果确实要快速扩容(比如活动前),正确的时间点是活动前几天的低峰期提前做完,而不是活动当天临时加机器。
这个问题大多数人答错,因为他们是按"平时用多少带宽"去配的。缓存集群平时的网络流量其实很小——心跳、分区表同步、少量的读写请求,千兆内网跑一天也用不掉多少。真正要按峰值配的是两个时刻:扩容与故障时的分区再平衡、以及持久化开启后的持续写入。
配比可以按这条式子反推:你希望一次再平衡在多长时间内完成?设定期望窗口 T(比如 5 分钟),单次再平衡要搬的数据量是 D(等于单节点数据量 × 副本份数),那么需要的有效吞吐就是 D ÷ T。按前面那个 25.6GB 的集群、3 节点扩 4 节点算,D 大约是 6.4GB,要在 5 分钟内搬完需要 21MB/s 以上的有效吞吐,千兆够用;但如果数据量涨到 100GB、单节点 33GB,D 就是 33GB 量级,5 分钟窗口需要 110MB/s 以上,千兆内网扣掉开销后基本跑满,业务流量就没地方走了——这时候万兆是刚需,不是升级选项。
给一条可以直接拿去用的配比口径:堆内模式下,每 1TB 物理内存配 10Gbps 内网起步;20GB 到 100GB 量级的集群,千兆是下限、万兆是推荐;100GB 以上或者要求恢复窗口在 5 分钟以内,万兆起步、25G 更从容。CPU 那边按"每 1Gbps 有效迁移吞吐配 1 到 2 个物理核"估,因为序列化是纯 CPU 活,别把内存配满了却给两颗低频 CPU。
立场说在最后:2026 年自建 Hazelcast 集群,最不该省的两样东西是内网带宽和序列化改造。序列化改造(换成 IdentifiedDataSerializable)几乎不花钱,但能把内存需求和 CPU 开销同时砍掉一半以上,性价比远超加内存;内网带宽从千兆升万兆的差价,跟故障当晚因为恢复太慢导致业务长时间降级相比,根本不值一提。反过来,最容易被过度投入的是单节点内存——盲目上大内存机器,换来的是更长的 GC 停顿、更大的故障恢复窗口、以及一次更惨烈的再平衡。把钱花在节点数、内网和 CPU 上,比花在单机内存上划算。
本文口径、参数与数据来源:
Hazelcast 默认分区数 271、backup-count 默认 1 与 async-backup-count 默认 0、in-memory-format 的 BINARY/OBJECT/NATIVE 三种取值、Near Cache 的默认关闭状态与 max-size 等配置项说明,来自 Hazelcast 5.x 官方参考手册的配置与数据结构相关章节,2026 年可查,具体行为以你实际部署的版本为准。
CP 子系统的 3 成员与 5 成员容错关系、CP 子系统默认关闭需显式启用、split-brain protection 的 minimum-cluster-size 工作原理,来自 Hazelcast 官方参考手册 CP Subsystem 与 Network Partitioning 相关章节,2026 年可查。
Persistence 与 High-Density Memory Store 的能力边界(堆外存储、企业版特性、持久化落盘行为)来自 Hazelcast 官方参考手册与企业版特性说明,2026 年可查。
本文中的对象头与字段对齐开销、记录容器开销(每条 100 到 200 字节)、单条单字段索引开销(40 到 80 字节)、块内碎片系数(10% 到 20%)、JVM 自身堆外开销(每节点 0.5 到 1.5GB)、堆内单节点堆的经验上限(8 到 16GB)、堆外内存管理器的额外余量(20% 到 30%),均为行业通用的 JVM 与内存数据网格工程经验估算值,非官方明示常量,会因 JDK 版本、GC 收集器、Hazelcast 版本、字段结构与访问模式而变化,必须在你自己的环境上实测确认后再用于容量决策。
文中提到的机器档位与价格(如裸金属 E5-2698v4×2 ¥3999 起)来自一万网络官网(idc10000.net)服务器租用相关页面的公示起步价,2026 年可查,以官网实时价为准;内存档位升级、CPU 升级、内网带宽升级、数据盘介质与 IOPS 规格等未在本文中列出确定价格的项,一律需实时询价,价格主要受 CPU、内存、存储、带宽、IP 和线路影响。具体以签约时最新报价与合同为准。
Copyright © 2013-2020 idc10000.net. All Rights Reserved. 一万网络 科技有限公司 版权所有 深圳市科技有限公司 粤ICP备07026347号
本网站的域名注册业务代理北京新网数码信息技术有限公司的产品