1. 项目整体设计与技术选型思路
1.1 为什么不是 Redis,而是 Apache Ignite
聊到分布式缓存,很多人的第一反应是 Redis。确实,Redis 在缓存领域统治了很多年,简单、成熟、生态好,单机吞吐量极高。但我这次在项目里遇到的情况比较特殊——不只是要缓存,还要在数据边上做分布式计算,而且数据量到了 TB 级别之后,Redis 的主从复制和内存成本就很尴尬了。
我当时的业务场景是一套多节点部署的数据分析平台,后端用的 Spring Boot 2.7(最近在调研 Spring Boot 4 的迁移,后面细说),业务方要求基础配置数据能毫秒级访问,同时对一批存量数据进行批量分组聚合计算,计算量不小,而且要求计算过程中数据不能频繁走网络 IO 往返。
用 Redis 的方案其实能走通:热点数据放 Redis,计算放应用层,节点之间共享数据。但问题是,当计算任务涉及多个节点上的数据时,你要么把数据从一个节点搬到另一个节点,要么引入额外的消息中间件来协调,整个架构链路拉得很长,排查问题的时候非常痛苦。
后来我重新审视了 Apache Ignite。它本质上是一个分布式内存计算平台,数据以 key-value 或 SQL 表的形式分布在集群各节点上,同时能直接在数据所在节点执行计算逻辑,这种亲和性计算能力,是 Redis 不能直接给到你的。
我来说一个直观差异:同样是做“对全量用户按省份分组求和”,用 Redis 你得把所有原始数据拉回应用层自己聚合,数据量大了之后网络开销直接要命;用 Ignite 你可以把计算任务发给数据所在的节点并行执行,最后只汇总一个很小的结果集。这个差别在数据量大时是数量级的性能差距。
1.2 架构定位:Ignite 在 Spring Boot 体系中扮演什么角色
经过几轮技术预研,我最终把 Ignite 定位成三个角色:
第一是分布式缓存层。替代原先的单机 Caffeine 缓存和多级缓存中不太稳定的 Redis 部分,所有节点共享同一份数据视图,配置变更后所有服务节点能几乎同时看到新值。
第二是分布式计算引擎。SQL 聚合、MapReduce 类型的任务直接在 Ignite 集群内执行,业务服务只提交任务、接收结果,不需要关心数据在哪台机器上。
第三是持久化存储的加速层。Ignite 原生支持持久化,对于非核心业务数据,可以直接把 Ignite 当主存储用,省掉一层外部数据库依赖。我这里没有激进到这种程度,核心数据仍然放在 PostgreSQL,但耗时严重的统计报表场景,已经把数据同步进 Ignite 跑计算了。
这个架构选型有个特别重要的前提——Ignite 不是要替代你现有的关系型数据库,而是在 Spring Boot 应用和数据库之间加一个分布式的数据与计算层,让读多写少的数据访问和计算密集型任务能卸载到内存集群中。这一点想清楚之后,后面怎么设计缓存键、怎么同步数据、怎么处理一致性,思路就不会乱。
1.3 整体落地路径:从 POC 到生产环境的三个里程碑
我不是一开始就上全套方案,而是拆成了三个阶段:
第一个里程碑是“跑通”。Spring Boot 项目里引入 Ignite 依赖,启动一个嵌入式 Ignite 节点,验证数据读写、基本缓存能力、Spring Cache 注解能否正常工作。这个阶段的目标是把技术链路打通,不追求性能。
第二个里程碑是“集群化”。把 Ignite 从嵌入式模式切换到独立集群模式,Spring Boot 服务作为客户端接入集群。这个阶段要验证节点发现、数据分区、故障转移这些分布式特性是否按预期工作。
第三个里程碑是“上计算”。把业务中的分组聚合、去重统计、批量更新场景迁移到 Ignite 的计算 API 上,配合亲和性配置让计算逻辑在数据节点本地执行,最后对比性能数据和资源消耗。
三个阶段走下来,最大的感受是:Ignite 集成本身并不难,难的是想清楚你的数据结构怎么映射到 Ignite 的缓存模型上、计算任务怎么写才能利用亲和性,以及集群失败时业务怎么兜底。
2. 环境准备与核心依赖配置
2.1 版本选型:Spring Boot 2.7 与 Ignite 2.15 的兼容性
版本这关,我踩的坑不算少。建议直接上 Ignite 2.15 或更高版本,对应 JDK 8 和 Spring Boot 2.x 都比较稳。Ignite 2.15 对分布式计算和 SQL 的支持已经很成熟,缓存 API 也稳定,不会出现什么幺蛾子。
如果你和我一样在关注 Spring Boot 4 的迁移,那要特别注意:Spring Boot 4 基于 Spring Framework 7,Jakarta EE 命名空间已经全面切换,Ignite 官方对 Spring Boot 4 的集成支持目前还不是特别完善。很多老项目从 Spring Boot 3 升级到 4 时遇到 DataSourceAutoConfiguration 找不到的问题,本质上是 Spring Boot 4 重构了自动配置注册机制。Ignite 的 spring-boot-starter 依赖还是面向 Spring Boot 2/3 体系设计的,所以建议生产环境先停留在 Spring Boot 2.7 或 3.x,等 Ignite 社区跟进。
Maven 依赖我放到这里,这是最基础的版本:
<dependency> <groupId>org.apache.ignite</groupId> <artifactId>ignite-core</artifactId> <version>2.15.0</version> </dependency> <dependency> <groupId>org.apache.ignite</groupId> <artifactId>ignite-spring</artifactId> <version>2.15.0</version> </dependency> <dependency> <groupId>org.apache.ignite</groupId> <artifactId>ignite-indexing</artifactId> <version>2.15.0</version> </dependency> <dependency> <groupId>org.apache.ignite</groupId> <artifactId>ignite-rest-http</artifactId> <version>2.15.0</version> </dependency>ignite-indexing 是必须的,否则 SQL 查询和计算任务里依赖索引的功能无法使用。ignite-rest-http 是方便我用 REST API 做集群监控和调试的,非必需,但很推荐加上。
2.2 配置文件详解:从本机到集群的配置演进
我的 Ignite 配置不是写死在代码里的,而是通过 Spring Boot 的 application.yml 外置,这样不同环境(开发、测试、生产)能复用同一套代码。
先看最基础的配置类:
@Configuration public class IgniteConfig { @Bean public Ignite igniteInstance() { IgniteConfiguration cfg = new IgniteConfiguration(); cfg.setIgniteInstanceName("my-ignite-cluster"); cfg.setPeerClassLoadingEnabled(true); // 发现机制:先用组播,生产环境建议换 ZooKeeper 或 TcpDiscovery TcpDiscoveryMulticastIpFinder ipFinder = new TcpDiscoveryMulticastIpFinder(); ipFinder.setAddresses(Arrays.asList("127.0.0.1:47500")); TcpDiscoverySpi discoSpi = new TcpDiscoverySpi(); discoSpi.setIpFinder(ipFinder); cfg.setDiscoverySpi(discoSpi); // 持久化配置 DataStorageConfiguration storageCfg = new DataStorageConfiguration(); DataRegionConfiguration regionCfg = new DataRegionConfiguration(); regionCfg.setName("persistent-region"); regionCfg.setPersistenceEnabled(true); storageCfg.setDefaultDataRegionConfiguration(regionCfg); cfg.setDataStorageConfiguration(storageCfg); // 缓存配置(这里用编程式,方便动态调整) CacheConfiguration<Object, Object> cacheCfg = new CacheConfiguration<>() .setName("user-cache") .setCacheMode(CacheMode.PARTITIONED) .setBackups(1) .setWriteSynchronizationMode(CacheWriteSynchronizationMode.PRIMARY_SYNC); cfg.setCacheConfiguration(cacheCfg); return Ignition.start(cfg); } }这里有几个关键参数我要解释一下,因为默认值在生产环境经常不够用。
CacheMode.PARTITIONED 表示数据按 key 的哈希分布到不同节点,这是分布式缓存的主流模式。backups 设置每个分区的副本数,生产环境至少 1,保证任意节点宕机数据不丢。WriteSynchronizationMode.PRIMARY_SYNC 表示写操作只需要主节点确认完成,性能较好,但如果你对数据一致性要求极高,可以改成 FULL_SYNC。
还要注意持久化开关。Ignite 默认是纯内存模式,节点重启数据就没了。如果你需要把缓存数据落地到磁盘,必须显式开启 PersistenceEnabled,同时指定存储路径,否则重启后 Eclipse 集群里的数据需要重新从上游加载,这在生产环境是致命的。
2.3 Spring Boot 自动配置与 Ignite 的整合方式
这里我推荐三种整合方式,分别对应不同场景。
第一种是纯编程式,就像上面写的,手动创建 Ignite 实例并注册为 Spring Bean。这种方式的优点是配置完全可控,适合需要动态调整缓存参数、按环境切换集群模式的场景。
第二种是通过 Ignite 的 spring-boot-starter 自动配置。Ignite 官方提供了 ignite-spring-boot-starter 依赖,项目启动后自动创建 Ignite 实例,只需在 application.yml 里配置即可。这种方式初始化快、代码少,但如果你在多个服务里共享同一个集群,要注意实例名冲突的问题。
第三种是 Spring Cache 抽象集成。这是最偷懒但最可维护的方式。用 @Cacheable、@CacheEvict 这类注解,底层换成 IgniteCacheManager:
@Bean public CacheManager cacheManager(Ignite ignite) { return new IgniteCacheManager(ignite); }之后在 Service 里就可以用 Spring Cache 注解:
@Service public class UserService { @Cacheable(value = "user-cache", key = "#userId") public User getUserById(Long userId) { // 数据库查询逻辑 return userMapper.selectById(userId); } }这种方式的优点是业务代码里不出现 Ignite 的 API,后续要换缓存实现,只需要换掉 CacheManager 的配置类。缺点是 Spring Cache 抽象只覆盖了缓存读写,分布式计算和高级 API 还是要直接调用 Ignite 的原生接口。
我的建议是:缓存走 Spring Cache 抽象,计算和需要细粒度控制的场景走原生 API,两条腿走路。
3. 核心模块实现:分布式缓存的实战配置
3.1 缓存键设计与序列化策略
缓存设计里,缓存键是容易翻车的地方。我第一次直接把业务对象的 toString() 当 key,结果对象字段顺序一变、toString 实现一改,所有缓存全部失效,而且定位问题很痛苦。
后来我养成了显式定义缓存键类的习惯。推荐使用 Apache Ignite 自带的 BinaryObject 机制,或者用 Java 原生序列化,但要注意 Ignite 的默认序列化方式是 Java 原生序列化,跨语言调用的场景会出现大量兼容性问题。
我这里实际采用的是 Ignite 的 BinaryMarshaller,并对关键缓存键定义了统一的 Key 类:
public class UserCacheKey implements Serializable { private Long userId; private String region; public UserCacheKey(Long userId, String region) { this.userId = userId; this.region = region; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserCacheKey that = (UserCacheKey) o; return Objects.equals(userId, that.userId) && Objects.equals(region, that.region); } @Override public int hashCode() { return Objects.hash(userId, region); } }缓存键必须同时重写 equals 和 hashCode,这是 Ignite 做数据分区和去重的基础。如果你用了 Lombok 的 @Data,它会自动生成这两个方法,但要注意如果你加了额外字段,分区可能不均衡。
序列化这一块,我的经验是:不要用 Java 原生序列化跨版本发布。Ignite 支持配置 Kryo 或自定义 Marshaller,我最后选了 Kryo,序列化速度快、生成的字节更小。你可以通过配置 IgniteConfiguration 的 Marshaller 属性替换默认实现。
3.2 缓存过期策略与内存规划
缓存过期的坑主要在于“缓存空值”和“缓存雪崩”。我在一个统计接口上吃过亏:查询某天的汇总数据,结果数据库里没记录,返回 null,结果 @Cacheable 默认不缓存 null 值,导致每次请求都穿透到数据库。
解决办法有两个思路:一是把 null 值包装成一个空对象缓存起来,二是设置较短的过期时间。我选择了空对象包装方案,因为既能防穿透,又不需要频繁重建缓存。
@Cacheable(value = "summary-cache", key = "#date", unless = "#result == null") public DailySummary getDailySummary(String date) { DailySummary summary = summaryMapper.selectByDate(date); if (summary == null) { summary = new DailySummary(); // 空对象占位 } return summary; }关于过期策略,Ignite 支持基于时间的过期。我在配置里用了 expireAfterWrite,也就是写入后固定时长过期,这种模式适合大多数业务缓存:
CacheConfiguration<Object, Object> cacheCfg = new CacheConfiguration<>() .setName("summary-cache") .setExpiryPolicyFactory(CreatedExpiryPolicy.factoryOf(Duration.ofMinutes(30))) .setCacheMode(CacheMode.PARTITIONED) .setBackups(1);这里要特别注意:setExpiryPolicyFactory 配的是 CreatedExpiryPolicy,即从创建时间开始计时,30 分钟后过期。如果你想要访问后自动续期,比如带活跃度的会话缓存,需要用 AccessedExpiryPolicy。
内存规划是很多人忽略的点。Ignite 集群的内存是有限的,如果所有缓存无限制扩容,最终会导致 OOM。DataStorageConfiguration 里可以给每个 DataRegion 设置初始大小和最大大小,超过最大值的部分由 Ignite 根据 page replacement 策略淘汰。
我给的参考配置是:给持久化数据区分配内存的 60%,给临时数据区(比如计算过程中的中间结果)分配 20%,剩下 20% 作为 JVM 堆外内存留给操作系统和 Ignite 内部管理。
3.3 写一致性:从 PRIMARY_SYNC 到 FULL_SYNC 的取舍
分布式缓存写一致性的问题,本质上就是 CAP 理论中的 CP 和 AP 权衡。Ignite 提供了多种写同步模式,默认是 FULL_ASYNC,主节点和备份节点都异步写,性能最好但可能丢数据。
项目里我给核心的账户缓存设置了 PRIMARY_SYNC:客户端发出写操作后,只要主节点写成功就返回成功,备份节点异步同步。这种模式在性能和一致性之间取了平衡,我们的业务可以接受极端的副本短暂不一致。
如果你做的是金融类或库存类的强一致场景,必须用 FULL_SYNC,同时配合 Ignite 事务 API。注意 Spring Cache 注解默认是不开启事务的,缓存的原子性需要你自己控制。一个经验是:缓存和数据库的一致性不能靠缓存自身实现,要绕到业务层面,用“先更新数据库,再失效缓存”或者“订阅 binlog 异步更新缓存”的方式。
3.4 缓存预热与批量加载
项目启动时,如果所有缓存都是空的,第一个用户请求就要穿透到数据库,出现一次明显的“冷启动延迟”。我踩过一次后,写了缓存的预热逻辑,在 Spring Boot 的 ApplicationReadyEvent 事件里执行:
@Component public class CacheWarmer { private final Ignite ignite; public CacheWarmer(Ignite ignite) { this.ignite = ignite; } @EventListener(ApplicationReadyEvent.class) public void warmUp() { IgniteCache<String, Object> cache = ignite.cache("user-cache"); // 从数据库加载最近7天的活跃用户,批量写入缓存 List<Long> activeUserIds = userMapper.selectActiveUserIds(7); Map<String, Object> batch = new HashMap<>(); for (Long userId : activeUserIds) { batch.put(buildKey(userId), userMapper.selectById(userId)); } cache.putAll(batch); } }批量写入时如果用 putAll 而不是循环 put,性能差距非常明显。Ignite 对批量操作有内部优化,减少了大量的网络往返。
缓存预热要注意时序:如果 Ignite 还没完全启动,或者数据库连接池还没初始化,预热代码就会报错。所以要监听 ApplicationReadyEvent,这个事件是 Spring Boot 上下文完全启动后触发的,这时候所有 Bean 和基础资源都已经就绪。
4. 分布式计算:从业务痛点出发的实现方案
4.1 Ignite Compute API 的调用模式
分布式计算模块是这次项目中我最满意的部分。业务场景是做用户行为分析,按照用户 ID 分组统计行为次数和金额。数据量约 2 亿条,分布在 4 台 64G 内存的服务器上。
如果用传统方式,服务端把 2 亿条数据取出来,传输到应用层进行分组聚合,内存立刻爆掉,网络带宽也会被打满。用 Ignite 的 Compute API,可以把聚合逻辑送到每台节点上,在每个数据分片上执行本地聚合,最后合并结果。
这个模式的调用代码如下:
IgniteCompute compute = ignite.compute(); // broadcast: 在所有节点上执行同一个任务 compute.broadcast(() -> { System.out.println("Executing on: " + ignite.cluster().localNode().id()); }); // 自定义任务: 发送到指定节点集合执行,支持负载均衡和故障转移 IgniteRunnable task = () -> { // 计算逻辑 }; compute.affinityCall("user-cache", userId, task);Ignite 的计算任务支持多种语义:broadcast 广播给所有节点;affinityCall / affinityRun 把任务发送到某个 key 所在节点;execute 提交给节点池按负载均衡策略选择一个节点执行。
实际项目里我主要用的是 affinityRun,它能把针对特定用户的处理逻辑搬到该用户数据所在的节点上执行,真正做到了数据本地化,避免了跨节点搬迁数据。
4.2 数据亲和性与 Colocation 设计
分布式计算的核心技巧是数据亲和性,也就是 Colocation。简单说,如果你在处理“用户 A 的所有订单记录”,这些订单记录最好与用户 A 的基本信息存储在同一个节点上,这样计算时不需要跨节点拉数据。
在 Ignite 里实现亲和性有两种方式:
第一种是 AffinityKey。在缓存配置:
CacheConfiguration<AffinityKey<Long>, Order> orderCacheCfg = new CacheConfiguration<>() .setName("order-cache") .setCacheMode(CacheMode.PARTITIONED) .setBackups(1);写入时用 AffinityKey:
AffinityKey<Long> key = new AffinityKey<>(orderId, userId); cache.put(key, order);第二种是基于 SQL 的 CREATE TABLE,用 AFFINITY_KEY 指定关联字段。
我实际采用的是第一种方式,因为数据模型是 Java 对象,用代码控制亲和性更直观。AffinityKey 的构造参数里,第一个是实际主键,第二个是亲和性键,Ignite 会按照亲和性键的哈希值决定这个 key 存储在哪台节点上。
设计亲和性时要遵循一个原则:同一业务实体关联的数据,必须使用同一个亲和性键。比如订单和用户,都以 userId 作为亲和性键,这样某用户的所有订单必然与用户基本信息在同一节点上。这样后续计算该用户的订单总金额时,job 只需要发送到一个节点即可执行完毕,不需要广播。
4.3 聚集器与分布式任务拆分实战
接下来我们看一个真实的分布式计算场景:统计全平台每天按用户分组的行为聚合结果。如果直接用简单遍历,数据量大了之后性能极差,所以需要把它拆成分散到各节点的子任务,再用 Ignite 的累加器合并结果。
Ignite 提供了 IgniteAtomicLong 和 IgniteAccumulator 用于集群范围内的数值聚合。我们在多个节点上执行计数的场景,这个特性非常好用:
IgniteAtomicLong totalCounter = ignite.atomicLong("total-counter", 0, true); compute.broadcast(() -> { long localCount = performLocalAggregation(); totalCounter.addAndGet(localCount); });AtomicLong 在集群里是分布式的,底层通过原子操作在不同节点间同步。要注意设置 create 参数为 true,否则第一次调用时如果原子量不存在会直接报错。
对于更复杂的逻辑,我用到了 Ignite 的 ComputeTask 接口。定义好任务拆分逻辑和结果合并逻辑:
public class GroupSumTask extends ComputeTaskSplitAdapter<String, Map<String, Long>> { @Override protected Collection<? extends ComputeJob> split(int gridSize, String arg) { // 按节点拆分任务 List<ComputeJob> jobs = new ArrayList<>(); for (int i = 0; i < gridSize; i++) { jobs.add(new GroupSumJob(arg)); } return jobs; } @Override public Map<String, Long> reduce(List<ComputeJobResult> results) { // 合并各节点的局部结果 Map<String, Long> merged = new HashMap<>(); for (ComputeJobResult res : results) { Map<String, Long> partial = res.getData(); partial.forEach((k, v) -> merged.merge(k, v, Long::sum)); } return merged; } }SplitAdapter 模式很好理解:split 阶段把任务按节点拆成子任务,每个子任务在对应节点执行;reduce 阶段把各节点返回的部分结果合并成最终结果。每个 ComputeJob 内部就是普通的 Java 逻辑,但运行在数据所在节点上,可以访问本地缓存数据。
这个模式的关键点是“子任务里访问本地数据”:因为 Ignite 的缓存 API 可以保证,如果 key 的亲和性与当前节点匹配,那么 get 操作走的是本地堆,而不是远程网络调用。这一点写代码时要刻意注意,别把 get 写成了远程提交流程。
4.4 SQL 计算模式与注意事项
Ignite 还有一个非常强的能力——分布式 SQL。你可以像操作普通数据库一样操作缓存中的对象数据,Ignite 会把 SQL 语句自动分发给对应节点执行。
引入 ignite-indexing 后,在实体类上配置注解:
@QuerySqlField(index = true) private Long userId; @QuerySqlField private BigDecimal amount;然后直接用 SQL:
IgniteCache<AffinityKey<Long>, Order> orderCache = ignite.cache("order-cache"); SqlFieldsQuery query = new SqlFieldsQuery( "SELECT userId, SUM(amount) FROM Order GROUP BY userId" ); try (QueryCursor<List<?>> cursor = orderCache.query(query)) { for (List<?> row : cursor) { // 处理聚合结果 } }这条 SQL 会触发 Ignite 内部的分布式 SQL 引擎,在数据所在节点并行聚合,然后把中间结果汇总到发起节点。性能非常可观,2 亿条数据的分组聚合在我的 4 节点集群上大约 20 秒出结果,相比之前的应用层内存聚合提升了接近一个数量级。
SQL 计算有几个坑要注意:
一是设置索引。GROUP BY 涉及的字段必须建索引,否则全表扫描,性能完全没法看。
二是结果集大小。如果聚合结果很大,比如分组粒度很细,最终结果可能也很大,传输到发起节点时会有网络开销。这种情况下建议再分一层聚合,或者直接用 Ignite 的 MapReduce 模式,把最终结果也分散存储,只返回结果的摘要信息。
三是 SQL 查询里不要写 OR 条件,Ignite 的 SQL 优化器对这种复杂条件支持不好,很容易退化成全表扫描。
5. 性能调优与高可用配置
5.1 JVM 参数与堆外内存分配
第一次部署 Ignite 集群的时候,我以为默认配置就行,结果上线第二天就遇到了频繁的 Full GC。原因很简单——Ignite 把大部分数据都放在堆外,如果 JVM 堆设置得太小,而 Ignite 内部又要用堆内存做对象封装和协调,两者一叠加就悲剧了。
我后来总结了一套相对稳妥的 JVM 参数组合,给的是 32G 堆的参考配置:
-server -Xms32g -Xmx32g -XX:+AlwaysPreTouch -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:+HeapDumpOnOutOfMemoryError -XX:+ExitOnOutOfMemoryErrorAlwaysPreTouch 的作用是启动时就把内存占好,避免运行期频繁向操作系统申请内存引起性能抖动。G1GC 默认就可以,那个 MaxGCPauseMillis=200 是控制 GC 停顿时间的目标值。
Ignite 的 DataStorageConfiguration 里可以设置堆外内存区域大小:
DataRegionConfiguration regionCfg = new DataRegionConfiguration(); regionCfg.setName("persistent-region"); regionCfg.setInitialSize(20L * 1024 * 1024 * 1024); // 20G regionCfg.setMaxSize(30L * 1024 * 1024 * 1024); // 30G regionCfg.setPersistenceEnabled(true);这里有个经验值:堆外内存最大值不能超过物理内存的 70%,否则操作系统都会进入内存压力状态。比如 64G 物理机的节点,堆外最多给 45G,剩余留给了 JVM 堆和系统缓存。
5.2 集群发现机制:从组播到 ZooKeeper
Ignite 的节点发现机制默认使用组播(Multicast),开发环境很好用,机器一启动就能自动加入集群。但生产环境组播在很多云环境下是不通的,而且新节点加入时偶尔会出现发现延迟。
我最后换成了 ZooKeeper 做集群发现和协调。这个选择的好处是:稳定的服务注册节点,不需要额外配置网络组播,而且集群规模扩展时不会因为组播通信变化出现节点失联。
TcpDiscoveryZooKeeperIpFinder ipFinder = new TcpDiscoveryZooKeeperIpFinder(); ipFinder.setZkConnectionString("10.0.0.1:2181,10.0.0.2:2181,10.0.0.3:2181"); TcpDiscoverySpi discoSpi = new TcpDiscoverySpi(); discoSpi.setIpFinder(ipFinder); cfg.setDiscoverySpi(discoSpi);换 ZooKeeper 后发现的一个好处是,集群成员的加入和退出情况都能通过 ZooKeeper 的临时节点感知到,Ignite 对集群变化的响应时间从秒级压缩到了毫秒级。
生产环境不建议用 Ignite 内嵌的 TcpDiscoveryMulticastIpFinder 配合组播,除非你的网络环境能保证组播可用且低延迟。另外,如果有条件,Ignite 2.8 之后还支持了基于 K8s 的 IP Finder,部署在 Kubernetes 里的集群可以直接用它,和 Pod 生命周期对齐。
5.3 持久化与 WAL 配置的实测调优
设置 PersistenceEnabled 之后,Ignite 用预写日志(WAL)保证数据安全。刚开启持久化时,我跑了一天的数据后发现磁盘占用高得出奇,排查下来是 WAL 归档不断累积,而且我没有设置自动清理策略。
后来我调整了 WAL 配置,这里是一组实测下来比较稳的参数:
DataStorageConfiguration storageCfg = new DataStorageConfiguration(); WalConfiguration walCfg = storageCfg.getWalConfiguration(); walCfg.setWalMode(WALMode.LOG_ONLY); walCfg.setWalHistorySize(2000); walCfg.setWalSegmentSize(64 * 1024 * 1024);WALMode.LOG_ONLY 表示只写日志,不强制每次都将数据同步到数据文件。这个模式对性能更友好,但前提是磁盘不能挂掉。如果你对数据安全要求极其严格,可以改成 FSYNC 模式,但写入性能会下降 30% 左右。
WALHistorySize 是控制 WAL 历史记录保留数量的参数,太小会导致 checkpoint 频繁触发,太大磁盘空间会爆炸。2000 个 segment、每个 64MB 大小,实际运行下来大约占 128G 磁盘空间,迭代开发环境可以把这个值调小一些。
磁盘空间不足是很常见的问题。我用脚本定时监控每个节点的 WAL 目录和持久化文件目录,超过 85% 后自动清理老旧的归档日志。这个运维策略虽然简单,但帮我避免了两次因为磁盘爆满导致的集群写入故障。
5.4 监控指标与告警:不靠猜,靠数据
分布式的排障最难的就是看不见。Ignite 提供了一套完整的监控 API,你也可以通过 JMX 暴露指标接入 Prometheus 和 Grafana。我重点监控这几个指标:
| 指标 | 含义 | 告警阈值 |
|---|---|---|
| PartitionState | 分区状态是否健康 | 非OWNING状态立即告警 |
| NumberOfEntriesInMemory | 内存中的数据条目数 | 接近 maxSize 判断扩容 |
| TotalAllocatedSize | 物理内存占用 | 超过物理内存 80% 告警 |
| CurrentWalArchiveSize | WAL 归档大小 | 超过阈值触发清理告警 |
| CacheEntriesPagedOut | 数据被换出内存的页数 | 持续增长说明内存不足 |
| AverageQueryTime | SQL 平均执行时间 | 超过 1s 报警 |
这些指标通过 Ignite 的 MXBean 暴露出来,我写了一个定时任务抓取并同步到监控系统。实测下来,最有预判价值的是 CacheEntriesPagedOut,这个指标一旦持续增长,基本说明内存规划不合理,数据被频繁换出内存,性能断崖下跌。
另外,Ignite Visor 是一个挺好的命令行监控工具,可以对 Ignite 集群节点执行各种管理命令,比如查看缓存大小、节点状态、执行 SQL 查询等。生产维护时有它在手,比直接连 JMX 方便很多。
6. 踩坑实录:高频问题的定位思路与解决模板
6.1 节点启动后互不可见
组播发现失效,或者 ZooKeeper 连接串配错,最容易出现的现象是:单独启动的第一个节点一切正常,第二个节点启动后却始终加入不了集群,日志里反复出现连接超时。
排查思路分两步走。第一步检查网络和端口:Ignite 默认通信端口是 47100,发现端口是 47500,这两个端口必须能被其他节点访问。第二步检查发现配置:如果你用的是 ZooKeeper,确认 zk 连接串没有写错,确认所有节点连接的是同一个 ZK 集群;如果你用的是组播,在机器上执行 tcpdump 抓包确认组播报文能正常到达目标节点。
我的一个经验是:配置日志级别为 DEBUG 再启动节点,Ignite 的日志会输出很详细的节点发现全过程。根据日志里的提示,定位基本不会超过十分钟。
6.2 缓存查询超时与 OOM
缓存查询超时大概率是缓存键设计不合理,导致同一个 key 被集中写入同一台节点,集群负载严重不均。可以用 Ignite 的 Visor 查看各节点缓存分区的分布情况,如果某个节点的内存占用明显高于其他节点,就要检查 key 的哈希是否均匀。
OOM 的常见原因是内存规划没做好。我遇到过最典型的场景是:持久化区设置了 30G,但导入的数据量远超预期,Ignite 只好不断把旧页换出内存,表现为大量的磁盘 IO 和 CacheEntriesPagedOut 指标暴涨。解决方案要么扩容 DataRegion,要么调整数据保留策略。
另一种 OOM 出现在计算任务上:某个节点的任务无限制地向缓存写入中间结果,结果内存区的最大值被击穿。这种场景建议给临时数据单独建一个 DataRegion,设置较小的 maxSize,并开启 LRU 淘汰。
6.3 亲和性配置不生效
写好的 affinityRun 任务,运行日志却显示它被路由到了别的节点,数据却要从远端节点拉取。这个问题的根源是:affinityRun 方法是根据你提供的 key 去定位节点的,但这个 key 必须与缓存的 AffinityKey 一致。
比如订单缓存用的 AffinityKey(orderId, userId),你计算时传的 key 也必须是 AffinityKey(orderId, userId),而不能只是 orderId。如果只用 orderId,Ignite 会按 orderId 的哈希路由节点,而订单数据是按 userId 的哈希分布的,两者可能落到不同节点上,亲和性自然失效。
这个问题排查起来很隐蔽,因为代码不会报错,只是性能不对。我的经验是写一个单元测试:往一个缓存里写入一万个键值对,然后分别用 affinityCall 和普通 call 访问同一批 key,对比耗时差异。如果 affinityCall 没有明显优势,说明亲和配置有问题。
6.4 缓存数据与数据库数据不一致
分布式缓存最头疼的问题就是数据一致性。我遇到过这样的场景:管理后台直接改了数据库中的配置,但缓存里的旧值还在,业务服务一直读到旧数据,持续了几个小时。
这种问题的根治方案是主动失效缓存,而不是等缓存过期。我实现了一个简单的双删策略:更新数据库后,先删除缓存中的 key,然后通过消息队列发送一个异步任务,延迟 5 秒再删除一次。这样即使第一次删除后又有并发请求把旧数据写回缓存,第二次删也能兜底。
代码逻辑不复杂:
public void updateUser(User user) { // 1. 更新数据库 userMapper.updateById(user); // 2. 立即删除缓存 cache.evict(buildKey(user.getId())); // 3. 延迟双删 scheduledExecutor.schedule(() -> cache.evict(buildKey(user.getId())), 5, TimeUnit.SECONDS); }这种做法能有效缓解缓存与数据库在并发写场景下的短暂不一致,但注意它不是严格的强一致方案。如果业务要求读写串行化,那还是得走本地事务 + 消息事务的完整方案。
6.5 Spring Cache 注解不生效的排查模板
Spring Cache 注解不生效,通常有三个原因:
第一个原因是缺少 @EnableCaching 注解。这个最容易排除,检查配置类或启动类上有没有加。
第二个原因是目标类内部方法调用。同一个类内部,a() 方法调用 b() 方法,b 上加的 @Cacheable 不会生效,因为此时走的是 this 调用,而不是 Spring 代理。要解决就需要把方法拆到不同的类里,或者使用 AspectJ 模式。
第三个原因是 cacheManager 没有把 IgniteCacheManager 设置为首选。Spring Boot 自动配置可能会生成多个 CacheManager,如果不指定哪个是主要的,注解就找不到正确的缓存管理器,直接报错或不执行缓存逻辑。
建议在启动时打印 CacheManager 的类型信息,确认 Ignite 的 CacheManager 确实被使用。我刚开始做集成时,打印出来的是 ConcurrentMapCacheManager,当时就懵了,后来才发现是自动配置没有显式指定。
7. 从单点到集群:部署与运维的落地经验
7.1 Docker 镜像化与容器化部署
为了统一各环境的一致性,我最后把 Ignite 节点镜像化了。Dockerfile 写得比较简洁,但在实际部署中发现几个必须注意的点。
第一个是内存参数。Docker 容器默认的资源限制不会传递给 JVM,如果容器 memory limit 是 8G,而 JVM 启动参数还是 -Xmx32g,容器会被直接杀死。一定要用 JVM 的可用内存自动感知特性,或者手动在启动脚本里注入与容器 limit 一致的堆参数。
FROM openjdk:8-jre-alpine WORKDIR /app COPY target/my-app.jar app.jar EXPOSE 47100 47500 10800 ENV JAVA_OPTS="-Xms4g -Xmx4g -XX:+UseG1GC" ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]第二个是端口映射。Ignite 有三个基本端口需要暴露:通信端口 47100、发现端口 47500、SQL 客户端端口 10800。如果你用 Rest API 监控集群,还要暴露 8080。Docker 网络用 host 模式最省事,但如果你用 bridge 模式,端口映射配置错误会导致节点之间无法发现。
7.2 滚动升级与灰度发布
Ignite 集群有节点动态加入退出能力,这是滚动升级的基础。我的升级流程是:
先停止一个节点,等待集群把该节点的分区数据重新平衡到其他节点。这个过程会持续一段时间,期间部分缓存写入可能变慢,因为需要重新平衡副本。
再启动新版本的节点,等待它加入集群并开始接收数据。此时 Ignite 会自动将部分分区从旧节点迁移到新节点,实现自动数据再平衡。
逐个节点重复这个过程,直到所有节点都完成升级。这个方案可行,但要注意在分区重平衡期间不要触发大规模数据更新,否则可能造成数据版本冲突或性能下降。
我在升级前会临时把写操作的同步模式从 PRIMARY_SYNC 改成 FULL_SYNC,确保重平衡期间数据冗余度足够,节点间数据差异能快速收敛。
7.3 备份与容灾策略
Ignite 集群虽然有多副本机制,但备份不是万能的。比如误删数据、代码 bug 导致的大面积覆盖,这类逻辑错误同样会同步到备份节点。
所以,持久化目录的定期快照备份依然需要。我使用文件系统层面的快照任务,每天凌晨对每个节点的持久化目录做一次增量备份,保留最近 7 天的备份数据。
另外,Ignite 支持原生快照机制,在集群层面做一致性快照:
control.sh --snapshot create snapshot_name这个命令会创建一个集群范围内的一致性快照,恢复时可以通过--restore参数指定快照名称。对于跨节点的数据恢复,原生快照比直接复制文件可靠得多,因为它能保证多节点之间数据的逻辑一致性。
我用 cron 定时任务对接 Ignite 控制命令,每周做一次全量快照保存到独立存储。这是最后的容灾兜底,平时用不上,但一旦出现逻辑性数据损坏,它的价值就不可替代。
8. 性能对比与优化效果
8.1 缓存命中率与响应时间对比
先看一下缓存层替换带来的收益。原先用 Caffeine 本地缓存时,由于每个应用节点各自独立缓存,单节点缓存命中率约 60%,因为相同的请求可能被负载均衡分散到多个节点上,本地缓存在各节点之间重复存储,浪费内存但命中率提不上去。
换成 Ignite 分布式缓存后,所有节点共享同一份数据,命中率直接提升到了 94%。由于缓存数据不再重复存储,内存利用率更高,同样的数据量下实际占用反而更少。
响应时间方面,接口的 P99 延迟从原先的平均 180ms 降到了 15ms 左右。提升最明显的是配置类数据和用户状态类数据的读取场景,这些数据访问频率极高,分布式缓存让大部分请求直接命中内存,完全不需要经过网络服务或者数据库。
8.2 分布式计算性能实测
计算场景的性能收益我记录了一组实测数据。原先通过应用节点从 PostgreSQL 拉取数据、在内存里分组聚合的方案,处理全量用户行为数据大约需要 6 分钟,而且应用节点的内存经常顶到 90% 以上,差点触发 OOM。
迁移到 Ignite 分布式计算后,同样是全量聚合,最终耗时约 25 秒,提升了约 14 倍。而且聚合期间应用节点内存很平稳,因为压力全部分摊到了 Ignite 集群内部的各个节点上。
这个收益的来源不复杂:数据本地化,计算和存储绑定在同一个节点;并行执行,4 个节点同时算,分摊单节点负载;按需传输,最终只回传聚合结果,避免原始数据的全量网络搬迁。
8.3 成本与收益评估
任何技术选型都不是免费的。Ignite 的引入带来了一些额外成本:
运维复杂度明显提高,原先一个 Redis 实例就能解决的问题,现在要维护一个 Ignite 集群。集群的监控、调优、故障恢复,都需要额外的运维知识和人力投入。
内存资源消耗不小,Ignite 是多副本存储,节点数的增加会导致数据总体占用内存上升。如果你的数据量不大或缓存命中率不高,用 Ignite 属于杀鸡用牛刀。
但如果你的业务场景和数据量确实到了 TB 级别,同时有计算需求,技术选型的结论就很清晰了:Ignite 带来的性能收益和架构简洁性,远高于增加的运维成本。它把数据层和计算层合并了,架构上少了一层中间链路,从长期维护角度反而是节省成本的。
9. 经验总结与后续演进
9.1 项目落地后的收获与反思
做完这个项目,我更清楚地认识到:无视业务场景和团队承载能力,盲目追求新技术方案是风险最高的行为。
Ignite 集成的技术难点其实不在 API 使用,而在于对整个分布式原理的理解——数据分布、亲和性、副本同步、故障恢复。这些概念如果你是第一次接触,建议先在单机模式下把功能跑通,再逐步增加节点测试分布效果,别一上来就铺多个节点,出了问题很难定位。
我把这个过程中沉淀的几个可复用原则总结一下:
缓存键必须显式设计,不要依赖业务对象的 toString 或默认 hashCode;分布式的数据无论在哪一层,都要遵循“先写主库再维护缓存”的流程,缓存永远不承担主存储职责;计算任务要利用亲和性把计算推到数据所在节点,这是 Ignite 区别于 Redis 等纯缓存组件的最大价值。
9.2 后续技术演进路径
项目已经稳定运行半年多了,后续我在考虑几个演进方向。
首先是集群的容器化编排。目前 Ignite 节点跑在云主机上,手工管理升级和扩容。之后我想把整个集群迁移到 Kubernetes,利用 Ignite 官方提供的 K8s 集成,让节点自动注册、自动发现,配合 HPA 做自动扩缩容,运维压力会更小。
其次是数据同步实时化。目前缓存数据是通过定时任务从 PostgreSQL 同步到 Ignite 的,有分钟级延迟。后续计划引入 CDC 机制,订阅数据库的变更日志(比如 Debezium),让配置类数据在秒级内更新到缓存。
最后是计算能力的深化。Ignite 的机器学习模块我已经在做 POC,准备把一些特征计算和模型推理任务直接放到 Ignite 集群里,减少特征数据和模型服务之间的传输成本。
9.3 一个值得分享的调优细节
最后分享一个我踩了三次的坑:Ignite 的 peerClassLoadingEnabled 参数。开发环境我设置了 true,这样各节点把任务类自动分发给其他节点,省去每台机器都打包部署的麻烦。但生产环境我把它关了,因为开启后每台节点都会动态加载任务类,如果代码版本不统一,集群里会出现类版本互相覆盖的诡异问题,非常难排查。
生产环境所有节点必须保证依赖的 jar 包完全一致,不要在运行期依赖类动态加载。这是 Ignite 集群稳定运维的第一条军规。
另外,如果你的项目也用 Spring Boot 3 或 4,升级前务必确认 Ignite 的版本支持情况。Ignite 2.15 有专门针对 Jakarta EE 的兼容分支,但文档不多,遇到问题只能自己啃源码。如果团队对这块没有足够的排查耐心,建议先锁在 Spring Boot 2.7 版本上,等 Ignite 官方整合完善后再升级。这套方案跑起来之后,性能和稳定性给我留下了很深的印象,也让团队对分布式缓存和分布式计算有了更具体的认知。后续的业务扩展和架构演进,我都多了一个可以落地的可选方案。