ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot集成Apache Ignite:构建分布式缓存与计算平台实战

Spring Boot集成Apache Ignite:构建分布式缓存与计算平台实战 按理说微服务跑得好好的谁想动缓存方案我最初接手那个统计平台时也想省事四台实例各存一份Caffeine本地缓存定时任务往数据库里写聚合结果。结果上线没两天客服就接到用户投诉——同一份数据在A节点和B节点查出来不一样刷新一下数据还会“回退”。问题就出在缓存一致性上本地缓存各管各的没有任何失效广播机制。后面我把Apache Ignite引进来把这个统计系统重构成了“分布式缓存 分布式计算”一套平台才算是真正解决了集群环境下的数据一致和计算任务分发问题。这个方案的核心就两件事用Ignite做集群内共享的数据网格再用它的计算网格能力把原本跑在单机上的批量任务分散到各节点执行。对正在做微服务化改造、又不想引入一堆重组件比如Redis集群加一批调度框架的Java后端团队来说这个组合挺值得参考的。写这篇博文不是为了介绍官方文档而是把我实际搭建Spring Boot与Apache Ignite集成后踩过的坑、调过的参数、重构时的取舍都捋一遍。你直接照着一步步做也能跑起来。1. 项目概述与核心思路1.1 为什么最终选了Apache Ignite而不是Redis当时的备选方案其实不少。身边同事第一反应是“加个Redis不就完了”但问题没那么简单。我们不只是需要缓存还需要在多个节点上执行聚合计算比如按用户维度统计订单分布、计算渠道转化率。这些计算如果单拎出来用XXL-Job做逻辑要写两套一套算数据、一套管任务。而且统计数据量较大全量丢到Redis再拉回本地计算网络开销立刻就把收益吃掉了。Ignite的定位是内存计算平台它自带计算网格能力调度任务时会把任务分发到数据所在的节点上去执行也就是“数据亲和性并置”。这一点是Redis不具备的。我用一张表做个直观对比能力维度RedisHazelcastApache Ignite缓存结构丰富KV结构分布式Map分布式Map 分布式SQL分布式计算不支持有部分支持计算网格支持并置计算数据持久化需额外配置/RDB/AOF可选原生持久化直接落盘SQL查询弱靠Lua或外部处理弱原生分布式SQL事务支持有限有限跨分区事务与Spring Cache集成需另外引入starter有适配原生Spring Cache支持过去很多人把Ignite理解为“加强版Redis”其实这低估了它。它更像是一个内存数据网格顺带把分布式计算做得很顺手。在同一个平台里既能存热点数据又能跑计算任务对于我这种讨厌“为了一个功能引入一套组件”的人来说简直正中下怀。1.2 项目的整体架构设计改造后的架构大概长这样Spring Boot服务扮演两种角色一种是需要读写缓存、执行查询的普通业务节点另一种是启动时以服务端模式加入Ignite集群的数据节点。计算任务统一走Ignite计算网格不再在业务代码里各自开线程池。核心分层大致如下接入层Spring Boot应用内的Service、Controller通过Ignite的Cache API或Spring Cache注解访问缓存数据网格层Ignite集群负责缓存数据分布、备份、失效广播、持久化计算网格层Ignite Compute负责把计算任务派发到数据所在节点并聚合结果存储层Ignite原生持久化 原MySQL仅存无法进内存的全量冷数据数据网格和计算网格共享同一份内存区域算数据时不需要千里迢迢把数据拉到调用方。整个改造中代码层面最舒服的一点是分布式任务调度的复杂度被Ignite封装了业务侧只需要定义任务逻辑不用关心哪台节点执行、结果怎么汇总。2. 工程搭建与基础配置2.1 Maven依赖引入和版本匹配建议引入有两个注意点一个是版本匹配另一个是模块裁剪。我用的是Spring Boot 2.7.x Ignite 2.15.0这个组合在生产环境跑了半年没出过兼容问题。如果你是Spring Boot 3.x重点检查javax和jakarta命名空间的问题Ignite自身的依赖里有相当多javax.servlet、javax.cache相关的包这块需要额外排除或替换依赖。POM里基础依赖如下dependency groupIdorg.apache.ignite/groupId artifactIdignite-core/artifactId version2.15.0/version /dependency dependency groupIdorg.apache.ignite/groupId artifactIdignite-spring/artifactId version2.15.0/version /dependency dependency groupIdorg.apache.ignite/groupId artifactIdignite-slf4j/artifactId version2.15.0/version /dependency如果用到分布式SQL还要加ignite-indexing模块这个后面单讲。另外强烈建议排除Ignite里自带的log4j依赖统一走Spring Boot的日志体系否则日志配置会打架排查线上问题的时候简直折磨。2.2 创建Ignite实例并注册进Spring容器Ignite实例在JVM里是重量级选手一个进程只应该有一个Ignite实例不要每次注入都new。用Spring的生命周期管理最合适一个Bean负责创建一个Bean负责关闭钩子。配置类里的画风是这样的Configuration public class IgniteConfig { Bean(destroyMethod close) public Ignite igniteInstance() { IgniteConfiguration cfg new IgniteConfiguration(); cfg.setIgniteInstanceName(my-ignite-cluster); cfg.setClientMode(false); // 发现机制用静态IP列表不依赖组播 TcpDiscoverySpi discoverySpi new TcpDiscoverySpi(); TcpDiscoveryVmIpFinder ipFinder new TcpDiscoveryVmIpFinder(); ipFinder.setAddresses(Arrays.asList(192.168.1.10:47500, 192.168.1.11:47500, 192.168.1.12:47500)); discoverySpi.setIpFinder(ipFinder); cfg.setDiscoverySpi(discoverySpi); // 通信SPI端口默认47100 TcpCommunicationSpi communicationSpi new TcpCommunicationSpi(); communicationSpi.setLocalPort(47100); cfg.setCommunicationSpi(communicationSpi); // 生产环境关闭对端类加载避免ClassNotFound的诡异问题 cfg.setPeerClassLoadingEnabled(false); return Ignite.start(cfg); } }这里有几个坑要说清楚。静态IP列表意味着新节点加入集群时必须能连通列表里任意一个节点的47500端口。如果节点不能连通就会一直刷连接失败的日志。组播模式在小规模内网很好用不用写IP但放到云服务器或者多机房后就非常不稳很多云厂商默认禁组播所以我直接用了VmIpFinder静态IP。端口方面47500是发现端口47100是通信端口压测前务必确保防火墙放行这两类端口否则就会出现“应用起来了一切正常但集群就是多了一个孤独节点”的诡异现象。3. 分布式缓存落地从本地缓存到数据网格3.1 CacheConfiguration关键参数模式、备份数与原子性缓存配置是整个项目中决定上限的部分。配置错了后面再调也要付出很大代价。我习惯在每个缓存创建前先画清楚这张表的三个要素缓存模式、备份数量、原子性。参数选项我的选择理由setCacheModePARTITIONED / REPLICATEDPARTITIONED数据量大REPLICATED所有节点全量存一份浪费内存且扩展性差setBackups0 / 1 / 21容忍单节点宕机不丢数据又不会备份开销翻倍setAtomicityModeATOMIC / TRANSACTIONALATOMIC缓存场景不需要强事务ATOMIC性能更好一个用户维度的统计缓存配置大致如下Bean public CacheConfigurationString, UserStats userStatsCacheConfiguration() { CacheConfigurationString, UserStats cacheCfg new CacheConfiguration(); cacheCfg.setName(userStatsCache); cacheCfg.setCacheMode(CacheMode.PARTITIONED); cacheCfg.setBackups(1); cacheCfg.setAtomicityMode(CacheAtomicityMode.ATOMIC); cacheCfg.setExpiryPolicyFactory( CreatedExpiryPolicy.factoryOf(Duration.ofMinutes(30)) ); return cacheCfg; }备份数不能盲目调高。我见过有团队把所有缓存都设成2备份小数据量没事数据量一旦上来集群内存翻倍涨最后频繁Full GC。分布式缓存不是“越多越保险”每个备份都需要实实在在的内存和网络来维护。3.2 与Spring Cache整合CacheManager与注解的使用Spring Cache抽象的好处是业务代码无侵入。我这边直接实现了一个IgniteCacheManager内部持有Ignite实例的引用Component public class IgniteCacheManager implements CacheManager { private final Ignite ignite; // 通过构造器注入 public IgniteCacheManager(Ignite ignite) { this.ignite ignite; } Override public Cache getCache(String name) { return new IgniteSpringCache(ignite.cache(name)); } Override public CollectionString getCacheNames() { return ignite.cacheNames(); } }业务代码里这么用Cacheable(cacheNames userStatsCache, key #userId) public UserStats getUserStats(Long userId) { // 从MySQL或者其他数据源加载加入缓存 }这里有个关键点Spring Cache的key默认用的是SimpleKey也就是把所有参数拼成一个复合对象。如果后续版本里接口参数顺序调整了缓存key的生成逻辑会跟着变历史缓存全部失效。建议自定义KeyGenerator用稳定不变的业务标识生成key比如“userStats:{userId}”这种字符串。这个问题在实际重构中坑过我一次生产环境上线后发现命中率为零查了半天才发现是key生成器变了。3.3 缓存序列化和对象存储的坑Ignite默认的Marshaller是BinaryMarshaller它会用自己的一套二进制序列化规则。你缓存的对象不需要实现Serializable但这里有个隐坑节点重启后如果类路径一致反序列化没问题如果某个节点的依赖版本和其他节点不一样反序列化时可能出现类字段不匹配的异常。我的经验是缓存对象优先用简单POJO字段能用基本类型绝不用复杂嵌套对象。如果对象内部嵌套了第三方库的类建议单独建一个DTO来承接而不是直接存领域实体类。领域实体往往带有各种关联对象序列化到Ignite里不仅多占空间还会带来类加载层面的脆弱性。关于BinaryObject如果各个服务端拿到的对象类型完全一致其实不太需要。但如果你有多个语言客户端或者希望延迟反序列化那么配置binaryBasicTypeSerializer会更有弹性。我这边团队全Java就先用简单POJO兜着了。4. 分布式计算平台让计算跑在数据那边4.1 IgniteCompute基础用法apply、broadcast与call缓存搞定后再来看计算部分。Ignite计算网格的API设计得很顺手最常用的几个就是broadcast、apply和call。先说broadcast它解决“每个节点上定时做本地清理”的场景。我们当时有个业务每天凌晨需要清理每个节点上的一些本地临时文件一开始靠脚本挨个节点登录执行后来直接用ignite.compute().broadcast(() - { // 塞入在这台节点上要执行的本地清理逻辑 System.out.println(Execute on node: ignite.cluster().localNode().id()); });一行代码就完成了对集群内所有节点的广播调用比写脚本、搞自动化顺太多了。再比如apply它适合在节点上执行并且返回结果IgniteCompute compute ignite.compute(); CollectionInteger counts compute.apply( (Cache.EntryString, Order entry) - { return entry.getValue().getAmount() 100 ? 1 : 0; }, orderCache );这个模式会把集合中所有条目分布到对应节点上去执行结果自动汇总回来省掉了“遍历数据全拉本地再计算”的笨方法。4.2 亲和性并置把计算任务发给数据所在的节点如果缓存数据量很大把数据全部拉到调用方节点做计算那网络IO和内存开销都很离谱。Ignite提供了亲和性并置机制可以把任务派发到数据主副本所在的那个节点上执行。业务上典型的例子是按用户维度统计订单。设置亲和键时我用AffinityKeypublic class Order { private AffinityKeyLong userId; private Long orderId; private BigDecimal amount; }然后配置CacheKeyConfigurationCacheConfigurationAffinityKeyLong, Order orderCacheCfg new CacheConfiguration(); orderCacheCfg.setName(orderCache); orderCacheCfg.setCacheMode(CacheMode.PARTITIONED); orderCacheCfg.setAffinity(new RendezvousAffinityFunction(false, 1024));在查询时如果你想在这台节点本地直接拿到某userId的所有订单可以采用affinityCallignite.compute().affinityCall(orderCache, userId, () - { // 在这个节点上基于userOrders只访问本地数据 return localOrderStatistics(userId); });这样计算直接就发生在数据主副本所在节点上返回的结果是已收敛后的汇总而不是把海量订单记录传到调用方。整个系统的网络消耗一下子降了一个量级这是分布式计算平台最出效果的地方。4.3 分布式SQL查询的使用与细节Ignite的SQL能力对传统关系型数据库用惯了的人来说比较友好。你可以把缓存想象成一张内存表。创建方式有两种一种是用CREATE TABLE语句另一种是编程式配置QueryEntity。我这边用SQL比较多因为团队里有人不熟Ignite API。在启动时执行DDLCREATE TABLE IF NOT EXISTS order_stats ( user_id BIGINT, stat_date VARCHAR, order_count BIGINT, total_amount DECIMAL, PRIMARY KEY (user_id, stat_date) ) WITH templatepartitioned, backups1, cache_nameorderStatsCache;注意WITH里的template和cache_name必须写对否则Ignite可能创建一个默认的REPLICATED缓存数据量大的时候内存爆炸。代码里查询SqlFieldsQuery query new SqlFieldsQuery( SELECT user_id, SUM(order_count), SUM(total_amount) FROM order_stats WHERE user_id ? GROUP BY user_id ); query.setArgs(userId); FieldsQueryCursorList? cursor ignite.cache(orderStatsCache).query(query);如果你用Spring Data的Repository习惯也可以引入ignite-spring-data模块把接口方法命名翻译成SQL。但我的体验是复杂的聚合查询还是老老实实用SqlFieldsQuery可读性和灵活度都更好。SQL查询这块还有一个性能提醒默认情况下Ignite的SQL索引是通过内存索引实现的查询条件字段上最好建索引否则就是全表扫描。哪怕内存扫描再快数据量到千万级也会拖垮节点CPU。5. 性能调优与运维要点5.1 内存模型DataRegion配置与常见OOMIgnite把缓存数据放在DataRegion里。默认的Default Region内存池是20%的物理内存但通常我们需要显式规划。DataStorageConfiguration storageCfg new DataStorageConfiguration(); DataRegionConfiguration regionCfg new DataRegionConfiguration(); regionCfg.setName(user-stats-region); regionCfg.setInitialSize(512L * 1024 * 1024); regionCfg.setMaxSize(2L * 1024 * 1024 * 1024); storageCfg.setDefaultDataRegionConfiguration(regionCfg); cfg.setDataStorageConfiguration(storageCfg);这里的核心是初始大小设小一点不要一上来就占满最大大小则要根据节点物理内存来倒推。比如机器32GB内存给JVM堆4GBIgnite最多分配20GB留下8GB给系统和其他进程。如果你不管DataRegion默认20%内存只是一条参考线。生产环境我见过一个节点堆外内存直接干到物理内存的90%操作系统开始频繁swap整个进程卡成PPT。所以内存上限务必显式设置别依赖默认值。5.2 持久化Native Persistence与缓存数据落盘Ignite的持久化和Redis的RDB不太一样它是页式存储直接操作二进制页。开启方式也不复杂DataStorageConfiguration storageCfg new DataStorageConfiguration(); storageCfg.getDefaultDataRegionConfiguration().setPersistenceEnabled(true); cfg.setDataStorageConfiguration(storageCfg);注意开启持久化后首次启动需要执行ignite.active(true)或者调用ignite.cluster().state(ClusterState.ACTIVE)激活集群。如果不激活缓存只能读写内存重启数据不保证落盘。我的建议是核心订单统计数据开启持久化中间过程临时数据不要开持久化。比如一个实时排行榜缓存数据本身就是实时计算出来的丢了也无所谓开持久化纯粹浪费磁盘IO。5.3 集群发现与网络调优的实践静态IP发现虽然稳定但运维上要维护IP列表。节点扩缩容时修改发现列表需要重启整个集群这个不算方便。如果团队已有一套ZooKeeper或者etcd可以考虑TcpDiscoveryZookeeperIpFinder动态管理节点注册和发现扩展性更好。网络调优方面LocalPort不要随便乱改默认选择通常是经过充分测试的。假如端口冲突优先改localPortRange让Ignite在范围里自动分配。另外TcpCommunicationSpi的messageQueueLimit对高峰期消息积压有缓解作用我调过几次一般设成1024就比较稳设置太高反而可能导致内存飙升。5.4 计算超时和任务线程池集群计算任务默认会在所有节点上并行执行。但如果某个节点的计算任务卡住整个compute()调用会一直等。Ignite提供了超时控制ComputeTaskFuture? future ignite.compute().broadcastAsync(() - { // long task }); future.get(30, TimeUnit.SECONDS);超时后任务并不会自动取消但至少调用方可以提前感知并做兜底。这里也推荐把compute涉及的线程池大小显式配置一下。Ignite默认的public线程池核数是max(8, CPU核数*2)但如果业务里大量使用compute建议单独扩展cfg.setPublicThreadPoolSize(16); cfg.setSystemThreadPoolSize(16);最后加一句计算任务内部不要直接睡死或者依赖Thread.sleep这种粗暴方式等到超时才发现问题排查起来很痛苦。6. 常见问题与排查实录6.1 新节点一直处于“孤独”状态发现失败怎么办这类问题最早遇到也最经典。表现在启动日志里没有异常但打开控制台或者用Ignite Visor查看发现集群里只有自己一个节点节点拓扑改变不明显。我的排查顺序是确认47500端口互通用telnet各节点47500不通就检查防火墙和云安全组确认IP发现列表里至少有一个当前已存活节点查看TcpDiscoverySpi日志看是否报“failed to connect”尝试把组播开启试试排除静态配置问题实测下来云环境最常见的还是安全组只放行了业务端口忘了放行47100和47500这两个端口。6.2 缓存反序列化报ClassNotFoundException这个问题多数发生在节点间依赖版本不一致时。比如A节点依赖了订单模块1.0.0B节点升级到了1.1.0并新增了字段A节点反序列化时就会遇到类字段不一致的异常。解决办法缓存对象独立建DTO别直接存领域对象所有节点保持依赖版本一致用统一BOM或者parent管理必要时引入BinaryObject让节点之间不共享Java类按String/Object读取字段从工程化角度第一点和第二点最好同时落地双保险。6.3 Spring Cache注解不生效这个遇到过一次后来发现是缓存管理器没被Spring容器扫描到。检查点EnableCaching有没有加到启动类上CacheManager类型是否与Spring Cache判断一致目标方法是不是被同一个类内部调用导致代理不生效如果是内部方法自调用可以用AopContext.currentProxy()绕一圈或者把缓存操作抽到另一个Service里面。这个坑不仅Ignite有Spring Cache本身就会这样搜一下解决方案到处都是但刚开始整合时很容易忽略。6.4 缓存数据过期时间和一致性之间如何平衡我遇到过一种情况缓存过期策略设置的太短数据库压力反而大了设置太长又出现数据不新鲜。最终方案是给缓存设置30分钟过期同时业务侧依赖MySQL binlog监听来主动失效关键数据两者配合起来比较理想。真实经验是不要求所有数据都强一致先把业务对数据新鲜度的要求梳理清楚。实时性要求高的走cache-aside主动失效允许分钟级延迟的走到期策略。分布式缓存不是万能药一致性要求极高的事务操作该走数据库锁还是走数据库锁。6.5 缓存热点导致单节点CPU飙升热点key问题跟Redis一样存在。某个爆款活动的userId被疯狂查询所有请求都集中在这个userId所在的分片上节点CPU飙升。处理方式本地加一层Caffeine一级缓存和Ignite做成两级缓存热点本地消化热点key增加随机后缀分散到不同分区查询时多读几次做合并对热点key做限流保护超过阈值直接回源数据库二级缓存的组合我在生产环境实测过命中率能提高很多但要注意本地一级缓存的失效和远端数据一致性问题。我这个方案里一级缓存只缓存秒级内可容忍不一致的统计数据否则就不开了。6.6 Spring Boot 4.0以来配置类位置变化带来的启发写这篇博文时正好看到一些框架升级的讨论比如Spring Boot 4.0里DataSourceAutoConfiguration的包位置发生了调整。这类变化提醒我们任何第三方组件的配置类都可能在框架升级后找不到整合时尽量少依赖自动配置多用显式Bean定义。我和Ignite集成时也吃过这个亏。早期图省事完全依赖Spring Boot自动配置来装配Ignite相关Bean结果项目升级Spring Boot小版本后某个starter的自动配置失效启动时报找不到Ignite实例。后面改成显式定义Bean后就再也没有被这种魔幻问题困扰。关于这套组合的一点个人体会如果只总结一条经验那就是分布式缓存不是“把本地缓存换成远程缓存”而是要重新审视数据一致性和计算分布的问题。Ignite把缓存和计算放在一个平台里能省去很多组件间的配合成本但也要求你从设计上就想清楚数据分布、备份策略和节点发现方案而不是把它当Redis替身用。调试阶段有个小技巧把setPeerClassLoadingEnabled(true)临时打开能加速本地调试生产环境关掉。另外Ignite自带Visor命令行工具很实用ignitevisor.sh里能直接看每个缓存的分区分布和节点内存占用排查问题比翻日志快得多。希望这套组合能在你的项目里少踩坑。
返回列表