☰
Spring Boot集成NoSQL实战:Redis缓存、MongoDB文档与ES搜索全解析
2026/10/13 10:56:32 网站建设 项目流程

1. 为什么Spring Boot项目里会需要非关系型数据库

做Java后端的朋友对关系型数据库都很熟,MySQL、PostgreSQL、Oracle这一套,二十多年了,行行有范式、表表有主键,ACID在这套体系里活得明明白白的。但是真到了业务这边,情况往往没这么简单——典型的场景比如:一个电商类项目,用户下单之后要看商品的实时库存,几十万人同时刷,如果每次都去MySQL里SELECT一下库存,数据库基本上撑不过十分钟;再比如某个内容社区项目的搜索功能,用户输入关键词,要在几秒钟之内把匹配的文章全部捞出来,关系型数据库的LIKE '%关键词%'到了几百万条数据的时候,慢得跟蜗牛爬一样;还有一种更常见的,日志记录、用户行为埋点,一天就能攒下几GB的数据,放MySQL里纯粹是受罪。

这些场景有个共同点:数据量巨大、读写频率高、但数据结构又不像订单和账户那样死板。传统的SQL处理这类问题要么性能跟不上,要么开发起来别扭,于是非关系型数据库就补上了这个空档。NoSQL这股风从早年火到现在,已经在互联网应用里扎得非常深了,不是某个技术流派标新立异的噱头,而是实实在在解决了集中式存储解决不了的高并发和大数据问题。在这个背景下,Spring Boot作为目前Java后端事实上的标准脚手架,自然要跟Redis、MongoDB、Elasticsearch这些NoSQL产品深度打通。毕竟框架只是骨架,数据库才是血肉,一个生产级项目不可能从头到尾只用一张MySQL表扛下全部业务逻辑。

我个人比较推荐的入门路径是:先搞清楚你自己的项目到底在什么场景下遇到了关系型数据库的瓶颈,再去学习对应的NoSQL组件。不是为了用而用,而是为了让系统性能上一个量级,让之前复杂的查询逻辑变得简单甚至消失,这才是Spring Boot集成的真正价值所在。本篇文章会围绕Spring Boot与主流非关系型数据库的交互展开,从Spring Data统一抽象讲起,然后分别落到Redis、MongoDB、Elasticsearch的配置、编码和坑位,最后做一组务实的数据选型对比。

2. Spring Data这套顶层抽象,解决了"换数据库等于重构"的老大难

讲具体组件之前,必须先讲一下Spring Data。我自己见过不少新人一上来就直接抄RedisTemplate和MongoTemplate的代码,抄完了能用,但是一旦让他多接一个数据库,代码就开始变得七零八落。其实Spring Boot之所以能让你只用几个注解就操作不同NoSQL,底层靠的全是Spring Data这一套统一的编程模型。

2.1 Repository接口:数据库差异藏在背后,业务代码不做选择

Spring Data的核心思想就是:把数据访问操作抽象成接口,然后让不同的Store模块去实现这套接口。你定义一个UserRepository接口,继承自CrudRepository、MongoRepository或者RedisRepository,然后像List findByAgeBetween(int min, int max)这样的方法,Spring Data会在启动时帮你自动生成实现,你根本不需要写一行查询语句。关键是对于不同的数据库,你的业务Service层调用的逻辑几乎是一样的,因为接口不变嘛。这带来的最直接价值就是:如果哪天某公司决定把MongoDB换成另一种文档型数据库,理论上你的Service层代码一个字都不用改。

我第一次把一套原本基于JPA写的用户模块迁移到MongoDB时,还担心业务代码会大改。结果实际操作下来,Controller层和Service层几乎是原封不动的,只有仓库接口的继承对象从JpaRepository换成了MongoRepository,查询方法名全部复用,因为Spring Data的命名规则是统一的——findBy + 条件字段 + 关键字。这个体验是很爽的,比过去写MyBatis的时候手动切换到另一个整套XML映射要好受太多了。

2.2 起步依赖与自动配置:零XML配置背后的装配逻辑

Spring Boot对NoSQL的支持,核心就是一群以spring-boot-starter-data-开头的起步依赖。当你引入spring-boot-starter-data-redis,启动时Spring Boot的自动配置机制会扫描到这个依赖,然后自动为你创建RedisConnectionFactory、RedisTemplate、StringRedisTemplate这些Bean。同理,引入spring-boot-starter-data-mongodb之后,MongoClient、MongoTemplate会自动装配好;引入spring-boot-starter-data-elasticsearch,ElasticsearchRestTemplate也会自动出现在容器中。

这个自动配置背后藏着一个非常关键的原理,就是条件装配。Spring Boot内部用了大量的@ConditionalOnClass、@ConditionalOnMissingBean注解。比如容器里只有在classpath中检测到了Redis的客户端类,才会实例化RedisTemplate;如果你自己已经手动定义了一个RedisTemplate,那么自动配置的版本就会让位。这个设计很优雅,既保证了零配置的默认体验,又给老手留了自定义的后门。你如果连这层原理都不知道,出了问题会非常难排查,最典型的就是:为什么我连上了Redis,存进去的key却带着一堆乱码前缀?十有八九就是你对默认的RedisTemplate序列化行为一无所知,这个坑后面详聊。

3. Redis实战:缓存、分布式锁与那些坑过无数人的序列化问题

Redis是众多NoSQL里用得最广的一个。它本质上是内存键值存储,速度是你机器上最快的SSD都追不上的那种,读写要做到微秒级。Spring Boot接入Redis基本是标配技能,但恰恰是这种"人人都会"的东西,一旦用错,后果也特别隐蔽。

3.1 依赖与基础配置

先用Maven引入依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

然后到application.yml里把连接参数配上,通常本地开发是这么写的:

spring: data: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0

这里注意一个细节:Spring Boot 2.x以后,默认的Redis客户端从Jedis换成了Lettuce。Lettuce基于Netty,是异步非阻塞的,多线程环境下并发性能比Jedis好很多,这也是官方换掉Jedis的核心原因。如果项目里还有旧代码用Jedis,要么做依赖排除,要么就干脆统一都用Lettuce,两种客户端混用属于没事找事。

3.2 RedisTemplate与StringRedisTemplate:使命不同的两兄弟

Spring Data Redis提供了两个主要的操作模板,很多人搞不清区别就乱用。StringRedisTemplate,它只处理字符串类型,存进去是String读出来也是String,序列化方式固定是String序列化器,key和value都按字符串处理。RedisTemplate则是泛型类,可以处理任意类型的对象,但默认的序列化器是JdkSerializationRedisSerializer。

我的建议是:如果只是做缓存,存JSON字符串,直接无脑用StringRedisTemplate,自己在代码里把对象转成JSON字符串再存,取出来再反序列化。这能绕开JDK序列化的各种坑。如果你要存对象类型,用RedisTemplate,那你必须显式地配置序列化器,否则就会出现下面这种灵异事件:

redisTemplate.opsForValue().set("user:1001", user);

然后你在命令行里用redis-cli查一下:

127.0.0.1:6379> keys * 1) "\xac\xed\x00\x05t\x00\x08user:1001"

看到一个带\xac\xed十六进制开头的key,那就是JDK序列化的二进制头。你业务代码里查"user:1001"没问题是因为RedisTemplate内部序列化之后也是同样的字节序列,但问题在于:运维同学肉眼排查数据时完全看不懂,第三方系统也没法直接读这个key;其次,对象一改动,老数据反序列化直接抛异常;再者,存进去的体积比JSON大一大截,内存贵得很啊。

所以正确的姿势是自定义一个RedisTemplate配置类:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // 使用Jackson做value的序列化器 Jackson2JsonRedisSerializer<Object> jsonSerializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper om = new ObjectMapper(); om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL); jsonSerializer.setObjectMapper(om); StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }

这里有个细节,就是key和hashKey一定要用StringRedisSerializer,保证key是清清爽爽的可读字符串,value才用JSON序列化。很多人只设置了value的序列化器而忘了key,结果还是会出现乱码前缀,这类问题90%发生在第一次配置的开发者身上。

3.3 缓存注解的注意事项

Spring提供了@Cacheable、@CachePut、@CacheEvict这套注解,用起来确实省事:

@Cacheable(cacheNames = "product", key = "#id") public Product getProductById(Long id) { return productMapper.selectById(id); }

第一次调用会走方法查询数据库,之后直接从Redis缓存里拿,不再进入方法体。但有两个坑必须提一下。第一个坑是缓存穿透,如果查询的id根本不存在,方法每次都查数据库,Redis里永远存不上这条"不存在"的记录,高并发下DB照样被打穿。解决办法是缓存空值或者用布隆过滤器。第二个坑是缓存雪崩,如果所有缓存的过期时间都相同,比如都是30分钟,那到点之后大量请求同时跑去数据库查,瞬间压力拉满。解决办法很简单,给过期时间加一个随机抖动值,比如30分钟±5分钟的随机范围。

3.4 分布式锁:别在单机思维里写烂锁

分布式环境下,Java的synchronized和ReentrantLock都锁不住多实例的并发。Redis实现分布式锁传统方案是SETNX + EXPIRE两步走,但两步之间如果服务挂了,锁就永不释放。正确做法是直接用一条原子命令:

Boolean locked = redisTemplate.opsForValue() .setIfAbsent("lock:order:1001", "instance-A", Duration.ofSeconds(30));

setIfAbsent配合过期时间在Redis里是一条原子操作,不存在"刚SETNX成功还没设过期时间进程就挂了"的窗口期。释放锁的时候还得多留个心眼,要用Lua脚本保证"先比对值再删除"这个过程的原子性,否则可能出现删掉了别人后来获取的锁这种严重事故。Spring Data Redis的DefaultRedisScript可以比较方便地处理这个:

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

3.5 Lettuce连接池的大坑

曾经有个项目上线后,每隔一段时间就会出现Connection reset by peer的报错。查了一圈发现是Lettuce的底层连接池配置不对——高并发时默认连接数不够,服务端把空闲连接断开,客户端又没有自动重连到位,业务就报错了。后面调大了max-active和max-idle,并且加了下面的配置让连接保持活跃:

spring: data: redis: lettuce: pool: max-active: 32 max-idle: 16 min-idle: 8 shutdown-timeout: 100ms

同时对于Lettuce,有个点必须知道:Lettuce连接是可共享的,它基于Netty的EventLoop管理多个并发操作,所以默认它其实不需要在很多场景下创建大量物理连接。真正要注意的是空闲连接校验和超时回收策略,不同版本的Spring Boot对Lettuce的默认配置差异比较大,生产环境建议在压测环境里先用JMC或者Arthas观察连接占用情况,再决定要不要调整连接池参数,切忌照搬网上的模板。

4. MongoDB实战:文档模型设计、聚合查询与事务边界

MongoDB是另一类非常常见的NoSQL,它的数据模型是文档型的,存储结构类似JSON,字段可以动态变化。Spring Boot接入MongoDB后,你会发现写数据访问代码的手感非常接近Spring Data JPA,但思想上要摆脱"表+行"的惯性,不然会画虎不成反类犬。

4.1 依赖与配置

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-mongodb</artifactId> </dependency>

配置文件里注意URI的写法,一定要带上数据库名,否则会连到默认的test库:

spring: data: mongodb: uri: mongodb://user:password@127.0.0.1:27017/mydatabase?authSource=admin

4.2 实体设计:别再把MongoDB当MySQL用

这是我在实际项目中见过最多的问题。很多人把MongoDB当MySQL用,把一个复杂的业务对象拆成好几张集合,外键手动关联,然后又使用@DBRef去拉关联数据。这种用法不能说不行,但完全抛弃了MongoDB的文档优势,还引入了额外的麻烦。

MongoDB的核心思想是"为应用查询建模",也就是说你存储的文档结构应该尽量贴近你的读取视角。举个例子,一个订单可能包含多个商品明细,如果放在MySQL里,是orders表和order_items表做一对多关联。放在MongoDB里,通常就应该这样:

@Document(collection = "orders") public class Order { @Id private String id; private String orderNo; private BigDecimal totalAmount; private List<OrderItem> items; private Address address; // getters and setters... }

orderItems直接内嵌在订单文档里,一次查询就能把整条订单数据全部拿出来。这样设计一方面减少了跨集合查询的网络开销,另一方面也跟业务直觉完全一致:一个订单就是一个整体。当然后续不同系统间对账如果需要单独的订单项数据,那就另说,工程设计本来就是取舍的艺术。

为了说明文档模型设计的差异,这里列一个对比表:

场景关系型建模思路MongoDB文档建模思路
一对多且子表独立查询频繁拆表 + 外键 + JOIN内嵌数组,一次性取回
一对多且子表数据极大(如几十万条日志)拆表 + 分页内嵌数组不合适,用独立集合引用
多对多中间表数组双向引用或冗余关键字段
字段经常变化频繁ALTER TABLE天然支持不同文档拥有不同字段

4.3 MongoRepository与条件查询

Spring Data MongoDB提供了和JPA几乎一致的查询方法派发机制。你写一个接口继承MongoRepository,方法名就能自动映射到查询:

public interface OrderRepository extends MongoRepository<Order, String> { List<Order> findByStatusAndCreateTimeBetween(String status, LocalDateTime startTime, LocalDateTime endTime); @Query("{ 'address.city' : ?0 }") List<Order> findByCity(String city); }

findByStatusAndCreateTimeBetween这种命名式查询,MongoRepository会自动解析成Criteria查询条件。但注意字段嵌套查询时,方法名要用address.city这种点号路径,映射规则支持得很好。条件复杂的时候,用@Query传入原生的MongoDB JSON查询语法也行,但日常业务大部分查询通过方法名就能搞定,不必过度设计。

4.4 MongoTemplate下的一对多聚合操作

还有一类场景,是分组统计。比如订单系统要统计每个城市的订单总金额,用Repository方法就不太顺手了,这时候就用MongoTemplate配合Aggregation:

Aggregation agg = Aggregation.newAggregation( Aggregation.group("address.city") .sum("totalAmount").as("totalAmount") .count().as("orderCount"), Aggregation.sort(Sort.Direction.DESC, "totalAmount") ); AggregationResults<Map> results = mongoTemplate.aggregate( agg, "orders", Map.class);

这段代码干的事是:按城市分组,求和总金额,统计订单数,按总金额倒序排序。就一个聚合管道,跑了2000万条订单数据也没有太大压力,MongoDB在分组统计这种场景下有它自己的优势,前提是你索引建对了。

4.5 MongoDB事务:别以为不支持,4.0之后就支持了

很多老开发对MongoDB有个刻板印象:不支持事务。这句话放到MongoDB 4.0之前成立,4.0版本之后副本集内已经支持多文档事务,4.2之后分布式集群也支持了。Spring Boot集成MongoDB使用事务也比较直接,在方法上加@Transactional注解即可。但注意前提条件:

  • 数据库必须是副本集模式,不能是单机模式,单节点是不支持事务的。
  • 事务内的所有操作要在同一个会话中执行,Spring Data MongoDB从2.1版本开始支持在事务中使用Repository操作,底层会绑定session。
  • 事务处理效率和ACID保障还是不如成熟的关系型数据库,所以不要把所有金额操作都一股脑搬到MongoDB上,对事务一致性要求顶级的核心交易链路建议还是留在MySQL。

5. Elasticsearch实战:全文搜索、高亮与索引生命周期管理

第三种常见的NoSQL是非关系型搜索引擎Elasticsearch。这类数据库在处理"全文搜索""模糊匹配""聚合统计日志"上表现极其出色。Spring Boot里集成Elasticsearch要注意的地方比较多,因为ES的版本迭代太快,API变动大,网上随便搜一篇教程很可能就是过时的,依赖版本没对齐,跑起来全是NoClassDefFoundError。

5.1 版本匹配问题

Elasticsearch的客户端和服务器端版本必须严格一致或兼容。Spring Boot 2.7.x默认支持的ES版本是7.15.x左右的版本,如果你的ES服务器装的是8.x,那就要检查两边的兼容性。Spring Boot 3.x开始支持Elasticsearch 8.x客户端。这个匹配关系是很多初学者在集成路上最崩溃的环节。我的建议是:做任何Spring Boot与ES集成之前,先确定ES服务器的准确版本号,再去查看当前Spring Boot版本对应的Spring Data Elasticsearch的版本兼容矩阵,不要上来就去Maven仓库搜最新依赖,拿最新版的库连老版本的ES。

pom里的依赖大概这样加:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-elasticsearch</artifactId> <version>2.7.18</version> </dependency>

然后配置文件指定ES地址:

spring: elasticsearch: uris: - http://127.0.0.1:9200

5.2 实体映射与索引管理

在Spring Data Elasticsearch里,实体和索引的映射靠注解:

@Document(indexName = "article_index", createIndex = true) public class Article { @Id private String id; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String title; @Field(type = FieldType.Text, analyzer = "ik_max_word") private String content; @Field(type = FieldType.Keyword) private String author; @Field(type = FieldType.Date) private LocalDateTime publishTime; }

注意Text类型的字段默认会进行分词,这决定了它适合做全文搜索;Keyword类型的字段不分词,适合做精确匹配、聚合和排序。这两者的使用差异是ES的核心基本功,混用会导致明明数据里有值你却搜索不到。中文分词一般用IK分词器,如果服务器上没有安装IK插件,analyzer = "ik_max_word"这种配置在创建索引时会报错,这个坑很常见,记得先在ES上装好插件。

启动时设置createIndex = true可以在程序首次启动时自动创建索引,但生产环境我建议关掉这个自动行为,改用手动执行索引初始化脚本,因为索引一旦创建,mapping里的字段类型就不能随便改了,改错字段类型只能重建索引,代价很大。

5.3 ElasticsearchRestTemplate的CRUD和搜索操作

使用ElasticsearchRestTemplate完成基本的索引操作:

@Service public class ArticleSearchService { private final ElasticsearchRestTemplate template; public ArticleSearchService(ElasticsearchRestTemplate template) { this.template = template; } public void save(Article article) { template.save(article); } public List<Article> searchByKeyword(String keyword) { NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder(); queryBuilder.withQuery( QueryBuilders.multiMatchQuery(keyword, "title", "content") ); queryBuilder.withHighlightBuilder( new HighlightBuilder() .field("title") .preTags("<em>") .postTags("</em>") ); queryBuilder.withPageable(PageRequest.of(0, 10)); SearchHits<Article> searchHits = template.search( queryBuilder.build(), Article.class); return searchHits .map(hit -> { Article article = hit.getContent(); // 高亮字段注意从highlight里取,覆盖原字段 List<String> titleHighlights = hit.getHighlightFields() .get("title"); if (titleHighlights != null && !titleHighlights.isEmpty()) { article.setTitle(titleHighlights.get(0)); } return article; }) .toList(); } }

multiMatchQuery可以在多个字段上同时做全文检索,高亮的时候注意,取出来的高亮片段是带标签的HTML片段,前端要做富文本渲染,不然看到的是一堆标签。很多第一次做搜索功能的开发者都会在这种地方纳闷为什么页面上出现尖括号,其实就是少了一步把普通字符串换成高亮字段。

5.4 索引生命周期管理

还有一类容易被忽视的问题是ES索引的增长。日志型数据如果每天往同一个索引里写,没几天索引就会膨胀到几TB,查询性能急剧下降不说,磁盘也扛不住。常见的做法是用索引模板+日期后缀,比如log-2025.01.28这种格式。Spring Boot接入时可以在@Document注解里设置动态索引:

@Document(indexName = "log-#{T(java.time.LocalDate).now()}")

这样每天生成一个新索引,再配合ES的Index Lifecycle Management自动把超过30天的索引转为只读、删掉或者迁移到冷存储。集成层面这些事情做扎实了,运维后期就很省心。

6. 三种NoSQL的实战选型:别被"流行"带偏方向

很多开发者看到网上充斥的"Redis缓存万岁""MongoDB天下无敌""ES万物可搜",容易把一个数据库方案硬套到不适用的场景里,最后搞到代码复杂、运维痛苦。这篇文章读到这,我已经把三种数据库在Spring Boot里的交互方式都讲了一遍,最后聊一聊选型时的判断框架。

6.1 Redis:用内存换速度,适合在热点路径上做性能加速

判断标准很直白:你的系统是否存在明显的热点数据——同一份数据被大量用户反复读取?比如商品详情、用户会话、排行榜、验证码、限流计数器。这类数据天然适合放Redis,因为它的本质是高速缓存与计数器。还有分布式锁、分布式唯一ID、消息发布订阅等场景也是Redis的主场。但是,Redis不适合存那些必须持久化保障的数据,虽然它有RDB和AOF持久化机制,但那更多是恢复手段,不能当主存储用;也不适合存大文本富媒体,内存太金贵了。把MySQL里一张一千万行的用户表整体搬到Redis,内存吃紧不说,按主键范围查询这类操作Redis也很吃力,明显是想不开。

6.2 MongoDB:灵活与高性能,适合业务模型变化快的文档类数据

判断标准是:你的数据结构在快速演化吗?比如早期的需求记录了"用户名、手机号",三个月后又要加"头像、昵称、积分等级"——MySQL每次变更都要ALTER TABLE,几十万行数据的表加列耗时不说,线上变更还会锁表。MongoDB不需要这样,字段随文档走,新字段直接由代码写入,老文档没有就返回null。同时,大量的读写偏斜、单表数据量极大的场景,比如物联网设备上报、内容管理系统的文章体裁、产品目录数据,MongoDB都表现很好。不适合的场景也很明确:事务依赖极重、需要复杂多表JOIN的逻辑——别强行用MongoDB模拟,那是给自己挖坑。

6.3 Elasticsearch:一切为了搜索和分析,别拿来当在线交易库

判断标准是:你的业务是否高度依赖全文搜索、模糊匹配、多维聚合分析?例如商品站点的关键字搜索、日志平台的关键词检索、运维监控中的指标聚合统计。ES做这些,效果是关系型数据库用肉眼可见的代价也赶不上的。但是,ES底层是Lucene倒排索引,写入是有近实时延迟的,它不适合做业务强一致的主数据库,也不适合做频繁单点更新的场景。曾经有项目把订单数据直接写到ES做展示,上线后很多新订单查询不到,原因就是ES默认每秒刷新,刚写入的数据不会立刻对搜索可见。正确设计应该是业务数据以MySQL或MongoDB为主存储,再通过消息队列异步同步到ES做搜索索引。

6.4 混用是一线项目的常态

坦白讲,成熟的互联网项目极少只依赖一种NoSQL产品。一个典型的订单系统可能是这样的:

  • MySQL存订单和账户这些核心交易数据,强一致。
  • Redis存会话、商品缓存、限流数据,扛高并发。
  • MongoDB存商品评价、操作日志这类结构变化快、量大但对一致性不太敏感的数据。
  • Elasticsearch存搜索的索引数据,提供商品搜索和后台报表分析。

Spring Boot的好处就在于,你不用为每种数据库单独搭建一个框架体系,统一的配置方式、统一的Repository抽象、统一的依赖管理,让这些异构数据源可以和谐共处在一个应用里。每次引入新的数据源,本质上是引入一个starter,配好连接参数,写好实体模型和数据访问层代码。整个过程是渐进式和增量的,不会像上古时期那样牵一发而动全身。

7. 事务、一致性与多数据源的隐形坑

一个应用里同时挂了MySQL、Redis、MongoDB的连接,各种数据之间的同步和一致性问题就浮现出来了。新手容易忽略这些"看不见的水面下礁石",等线上出了问题才追悔莫及。

7.1 缓存与数据库的双写一致性

最经典的坑是Redis缓存和MySQL数据保持一致的问题。很多项目用"先更新数据库,再删除缓存"的策略,但高并发场景下,一个线程A更新了数据库还没来得及删缓存,另一个线程B读到了旧缓存,刚好又有一个线程C写入新数据删了新缓存,整个链路的时序稍微错乱,脏数据就停留在Redis里了。

比较合理的方式是:

  • 更新场景尽量用"Cache Aside"模式,先更新数据库,再删除缓存。
  • 对一致性要求高的数据,可以加版本号放在value里;每次读缓存时校验版本,版本过期就回源并重建缓存。
  • 对极端一致性有硬要求的场景,可以考虑直接把缓存去掉,别缓存这种数据,省得为了省几次数据库查询最后却要搭进去一套Binlog监听。

7.2 多数据源事务的取舍

Spring的@Transactional只能保证同一个数据源内的事务。如果你的业务里一个方法既写了MySQL又好巧不巧写了MongoDB,还顺手删了Redis的缓存,那么任何一步出错,都无法用一个本地事务把这几个操作全部回滚。怎么办,业界方案也无外乎两种:

  • 引入分布式事务中间件,比如Seata,用AT/TCC模式协调多个数据源。但带来的开销和复杂度不小,如果不是核心资金链路,我一般不建议所有接口都套分布式事务。
  • 换思路,用最终一致性。核心步骤写在本地事务里,其他次要操作通过消息队列异步触发,配合重试和补偿机制。比如订单创建用本地事务写MySQL,然后发一条消息,消费者异步去更新MongoDB的用户积分,更新失败就进重试队列,实在不行就报警让人工介入。

后者在生产环境落地更多,因为异步化和解耦带来的收益往往超过事务强一致的心理安全感。要知道所谓的强一致也是相对的,业务要考虑的是"能不能在用户可感知的时间窗口内收敛到一致状态"。

7.3 多数据源配置的工程实践

如果确实一个项目要同时连接多个数据源,启动类上一定要做好主从区分。常见做法是用@Primary标注主数据源,其余的用@Qualifier引用:

@Bean(name = "mongoTemplatePrimary") public MongoTemplate mongoTemplatePrimary(MongoDatabaseFactory factory) { return new MongoTemplate(factory); }

同时,配置类要放在能被启动类扫描到的包路径下,否则会出现"Bean明明写了却不生效"的问题。这个坑十次有六次是包扫描没覆盖到造成的,第一次配多数据源的朋友排查的时候可以优先看这里。

8. 从工程角度看Spring Boot集成NoSQL的最佳实践

写代码之前,先把工程思路理顺,比多写几行CRUD有价值得多。集成NoSQL看上去是加依赖和配置的事,但真正拉开差距的是以下这些工程层面的细节。

8.1 序列化问题要一开始就定好

无论接Redis、MongoDB还是ES,第一个要问的问题是:持久化的对象用什么格式?我的建议是能用JSON就用JSON,不要用Java原生序列化。原因是多方面的:JSON可读、可跨语言、体积小、不会因为Java类字段变动导致整个反序列化崩溃。MongoDB本身存的就是BSON,天然跟JSON兼容,所以实体类上不用太折腾;Redis的value和key的序列化器一定要显式配置;ES的source字段存的也是JSON,这一点不用担心,但索引mapping里字段类型的设计要想清楚。

8.2 连接管理必须统一

每一个NoSQL中间件都有连接池或者连接管理机制,但很多Spring Boot项目根本不管,直接用默认配置。默认配置在小流量时没问题,一旦压测或者上线,连接数不够、连接泄漏、空闲断开这些问题就会像幽灵一样冒出来。经验是:每接入一种数据源,都要根据你的压测流量模型预估连接池大小,并在配置里显式声明最小空闲连接和最大连接数,同时开启健康检查,让连接池帮你回收坏连接,而不是等报错之后去重启应用。

8.3 监控告警宁可早做不可后补

NoSQL中间件普遍有监控指标,但不接入Prometheus或者别的监控系统就等于白搭。比如Redis可用内存降低到阈值、MongoDB的慢查询变多、ES的红色集群状态、Lettuce连接池活跃连接数飙升,这些都是需要在告警列表里的。Spring Boot可以引入spring-boot-starter-actuator,暴露出health和metrics端点,把指标接入Grafana之后,何时扩容、何时清理脏数据、何时优化索引,会变得一目了然。

9. 写在最后:我的几点亲身体会

这十几期Spring Boot系列写下来,不少朋友问我,搞这么多数据库是不是过度设计?我的回答是:判断标准不是"用了几个数据库技术",而是"每一类数据的存储方式是不是最匹配它被使用的方式"。我从项目实战中获得的经验来看,核心业务数据该用MySQL的地方别犟着用NoSQL,高并发缓存该用Redis的地方别心疼内存,文档化了的数据结构该用MongoDB的好处就别只盯着SQL不放,搜索该用ES也别用LIKE硬撑。技术选型的核心不是追新求多,而是要让每一份数据以最高效的方式被读写。

另外还有一个小建议:集成这些组件的时候,不要一次性全部铺开。一个项目从零开始加NoSQL,最好的节奏是先用Redis解决一个具体的性能瓶颈,跑通一整个月的线上验证,再考虑引入MongoDB去承载某一个模块的数据,最后才是ES做搜索增强。每加一个新组件,都要让整个团队有一个学习和消化的过程,运维上也要跟上监控和备份策略。技术架构这东西,稳妥推进比激进跃进要舒服得多,也可靠得多。

如果这篇内容对你有帮助,建议亲手把Demo跑一遍:建一个小项目,先只用Redis做缓存,再开一个MongoDB集合保存文章和评论,最后往ES里灌一批测试数据做搜索。不需要复杂的业务功能,只是把Spring Boot与这三种数据库的交互链路走通,你就已经比90%只看不练的人强了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询