436 lines
17 KiB
Markdown
436 lines
17 KiB
Markdown
# 18 | Spring Data 常见错误
|
||
|
||
你好,我是傅健。
|
||
|
||
上一章节我们学习了 Spring Web 开发的常见错误。那么从这节课开始,我们将重点关注其他的一些 Spring 工具使用上的错误。
|
||
|
||
实际上,除了 Spring Web 外,Spring 还提供了很多其他好用的工具集,Spring Data 就是这样的存在。众所周知,基本上所有的项目都会用到数据库,所以 Spring 提供了对市场上主流数据库的贴心支持,我们不妨通过下面的列表快速浏览下:
|
||
|
||
> Spring Data Commons
|
||
> Spring Data JPA
|
||
> Spring Data KeyValue
|
||
> Spring Data LDAP
|
||
> Spring Data MongoDB
|
||
> Spring Data Redis
|
||
> Spring Data REST
|
||
> Spring Data for Apache Cassandra
|
||
> Spring Data for Apache Geode
|
||
> Spring Data for Apache Solr
|
||
> Spring Data for Pivotal GemFire
|
||
> Spring Data Couchbase (community module)
|
||
> Spring Data Elasticsearch (community module)
|
||
> Spring Data Neo4j (community module)
|
||
|
||
而在你使用这些各种各样的数据库时,难免会遇到问题,接下来我会选取3个典型案例,为你总结下那些高频问题。
|
||
|
||
## 案例 1:注意读与取的一致性
|
||
|
||
当使用 Spring Data Redis 时,我们有时候会在项目升级的过程中,发现存储后的数据有读取不到的情况;另外,还会出现解析出错的情况。这里我们不妨直接写出一个错误案例来模拟下:
|
||
|
||
```
|
||
@SpringBootApplication
|
||
public class SpringdataApplication {
|
||
|
||
SpringdataApplication(RedisTemplate redisTemplate,
|
||
StringRedisTemplate stringRedisTemplate){
|
||
String key = "mykey";
|
||
stringRedisTemplate.opsForValue().set(key, "myvalue");
|
||
|
||
Object valueGotFromStringRedisTemplate = stringRedisTemplate.opsForValue().get(key);
|
||
System.out.println(valueGotFromStringRedisTemplate);
|
||
|
||
Object valueGotFromRedisTemplate = redisTemplate.opsForValue().get(key);
|
||
System.out.println(valueGotFromRedisTemplate);
|
||
}
|
||
|
||
public static void main(String[] args) {
|
||
SpringApplication.run(SpringdataApplication.class, args);
|
||
}
|
||
|
||
}
|
||
|
||
```
|
||
|
||
在上述代码中,我们使用了 Redis 提供的两种 Template,一种 RedisTemplate,一种 stringRedisTemplate。但是当我们使用后者去存一个数据后,你会发现使用前者是取不到对应的数据的。输出结果如下:
|
||
|
||
> myvalue
|
||
> null
|
||
|
||
此时你可能会想,这个问题不是很简单么?肯定是这两个 Template 不同导致的。
|
||
|
||
没错,这是一个极度简化的案例,我们的学习目的是举一反三。你可以试想一下,如果我们是不同的开发者开发不同的项目呢?一个项目只负责存储,另外一个项目只负责读取,两个项目之间缺乏沟通和协调。这种问题在实际工作中并不稀奇,接下来我们就了解下这个问题背后的深层次原因。
|
||
|
||
### 案例解析
|
||
|
||
要了解这个问题,需要我们对 Spring Data Redis 的操作流程有所了解。
|
||
|
||
首先,我们需要认清一个现实:我们不可能直接将数据存取到 Redis 中,毕竟一些数据是一个对象型,例如 String,甚至是一些自定义对象。我们需要在存取前对数据进行序列化或者反序列化操作。
|
||
|
||
具体到我们的案例而言,当带着key去存取数据时,它会执行 AbstractOperations#rawKey,使得在执行存储 key-value 到 Redis,或从 Redis 读取数据之前,对 key 进行序列化操作:
|
||
|
||
```
|
||
byte[] rawKey(Object key) {
|
||
|
||
Assert.notNull(key, "non null key required");
|
||
|
||
if (keySerializer() == null && key instanceof byte[]) {
|
||
return (byte[]) key;
|
||
}
|
||
|
||
return keySerializer().serialize(key);
|
||
}
|
||
|
||
```
|
||
|
||
从上述代码可以看出,假设存在 keySerializer,则利用它将 key 序列化。而对于 StringRedisSerializer 来说,它指定的其实是 StringRedisSerializer。具体实现如下:
|
||
|
||
```
|
||
public class StringRedisSerializer implements RedisSerializer<String> {
|
||
|
||
private final Charset charset;
|
||
|
||
|
||
@Override
|
||
public byte[] serialize(@Nullable String string) {
|
||
return (string == null ? null : string.getBytes(charset));
|
||
}
|
||
|
||
}
|
||
|
||
```
|
||
|
||
而如果我们使用的是 RedisTemplate,则使用的是 JDK 序列化,具体序列化操作参考下面的实现:
|
||
|
||
```
|
||
public class JdkSerializationRedisSerializer implements RedisSerializer<Object> {
|
||
|
||
|
||
@Override
|
||
public byte[] serialize(@Nullable Object object) {
|
||
if (object == null) {
|
||
return SerializationUtils.EMPTY_ARRAY;
|
||
}
|
||
try {
|
||
return serializer.convert(object);
|
||
} catch (Exception ex) {
|
||
throw new SerializationException("Cannot serialize", ex);
|
||
}
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
很明显,上面对 key 的处理,采用的是 JDK 的序列化,最终它调用的方法如下:
|
||
|
||
```
|
||
public interface Serializer<T> {
|
||
void serialize(T var1, OutputStream var2) throws IOException;
|
||
|
||
default byte[] serializeToByteArray(T object) throws IOException {
|
||
ByteArrayOutputStream out = new ByteArrayOutputStream(1024);
|
||
this.serialize(object, out);
|
||
return out.toByteArray();
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
你可以直接将"mykey"这个字符串分别用上面提到的两种序列化器进行序列化,你会发现它们的结果确实不同。这也就解释了为什么它们不能读取到"mykey"设置的"myvalue"。
|
||
|
||
至于它们是如何指定 RedisSerializer 的,我们可以以 StringRedisSerializer 为例简单看下。查看下面的代码,它是 StringRedisSerializer 的构造器,在构造器中,它直接指定了KeySerializer为 RedisSerializer.string():
|
||
|
||
```
|
||
public class StringRedisTemplate extends RedisTemplate<String, String> {
|
||
|
||
public StringRedisTemplate() {
|
||
setKeySerializer(RedisSerializer.string());
|
||
setValueSerializer(RedisSerializer.string());
|
||
setHashKeySerializer(RedisSerializer.string());
|
||
setHashValueSerializer(RedisSerializer.string());
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
其中 RedisSerializer.string()最终返回的实例如下:
|
||
|
||
> public static final StringRedisSerializer UTF\_8 = new StringRedisSerializer(StandardCharsets.UTF\_8);
|
||
|
||
### 案例修正
|
||
|
||
要解决这个问题,非常简单,就是检查自己所有的数据操作,是否使用了相同的 RedisTemplate,就是相同,也要检查所指定的各种Serializer是否完全一致,否则就会出现各式各样的错误。
|
||
|
||
## 案例 2:默认值的错误
|
||
|
||
当我们使用 Spring Data 时,就像其他 Spring 模块一样,为了应对大多数场景或者方便用户使用,Spring Data 都有很多默认值,但是不见得所有的默认值都是最合适的。
|
||
|
||
例如在一个依赖 Cassandra 的项目中,有时候我们在写入数据之后,并不能立马读到写入的数据。这里面可能是什么原因呢?这种错误并没有什么报错,一切都是正常的,只是读取不到数据而已。
|
||
|
||
### 案例解析
|
||
|
||
当我们什么都不去配置,而是直接使用 Spring Data Cassandra 来操作时,我们实际依赖了 Cassandra driver 内部的配置文件,具体目录如下:
|
||
|
||
> .m2\\repository\\com\\datastax\\oss\\java-driver-core\\4.6.1\\java-driver-core-4.6.1.jar!\\reference.conf
|
||
|
||
我们可以看下它存在很多默认的配置,其中一项很重要的配置是 Consistency,在 driver 中默认为 LOCAL\_ONE,具体如下:
|
||
|
||
```
|
||
basic.request {
|
||
|
||
|
||
# The consistency level.
|
||
#
|
||
# Required: yes
|
||
# Modifiable at runtime: yes, the new value will be used for requests issued after the change.
|
||
# Overridable in a profile: yes
|
||
consistency = LOCAL_ONE
|
||
|
||
//省略其他非关键配置
|
||
}
|
||
|
||
```
|
||
|
||
所以当我们去执行读写操作时,我们都会使用 LOCAL\_ONE。参考下面的运行时配置调试截图:
|
||
|
||
![](https://static001.geekbang.org/resource/image/20/86/20929453d5b4301e99ec059b29b42386.png?wh=1004*508)
|
||
|
||
如果你稍微了解下 Cassandra 的话,你就知道 Cassandra 使用的一个核心原则,就是要使得R(读)+W(写)>N,即读和写的节点数之和需要大于备份数。
|
||
|
||
例如,假设我们的数据备份是 3 份,待写入的数据分别存储在 A、B、C 三个节点上。那么常见的搭配是 R(读)和 W(写)的一致性都是 LOCAL\_QURAM,这样可以保证能及时读到写入的数据;而假设在这种情况下,我们读写都是用 LOCAL\_ONE,那么则可能发生这样的情况,即用户写入一个节点 A 就返回了,但是用户 B 立马读的节点是 C,且由于是 LOCAL\_ONE 一致性,则读完 C 就可以立马返回。此时,就会出现数据读取可能落空的情况。
|
||
|
||
![](https://static001.geekbang.org/resource/image/6d/18/6d2b27d31d7c994db7ecfb781bb71b18.png?wh=854*498)
|
||
|
||
那么考虑一个问题,为什么 Cassandra driver 默认是使用 LOCAL\_ONE 呢?
|
||
|
||
实际上,当你第一次学习和应用 Cassandra 时,你一定会先只装一台机器玩玩。此时,设置为 LOCAL\_ONE 其实是最合适的,也正因为只有一台机器,你的读写都只能命中一台。这样的话,读写是完全没有问题的。但是产线上的 Cassandra 大多都是多数据中心多节点的,备份数大于1。所以读写都用 LOCAL\_ONE 就会出现问题。
|
||
|
||
### 案例修正
|
||
|
||
通过这个案例的分析,我们知道 Spring Data Cassandra 的默认值不见得适应于所有情况,甚至说,不一定适合于产线环境,所以这里我们不妨修改下默认值,还是以 consistency 为例。
|
||
|
||
我们看下如何修改它:
|
||
|
||
```
|
||
@Override
|
||
protected SessionBuilderConfigurer getSessionBuilderConfigurer() {
|
||
return cqlSessionBuilder -> {
|
||
DefaultProgrammaticDriverConfigLoaderBuilder defaultProgrammaticDriverConfigLoaderBuilder = new DefaultProgrammaticDriverConfigLoaderBuilder();
|
||
driverConfigLoaderBuilderCustomizer().customize(defaultProgrammaticDriverConfigLoaderBuilder);
|
||
cqlSessionBuilder.withConfigLoader(defaultProgrammaticDriverConfigLoaderBuilder.build());
|
||
return cqlSessionBuilder;
|
||
};
|
||
}
|
||
|
||
@Bean
|
||
public DriverConfigLoaderBuilderCustomizer driverConfigLoaderBuilderCustomizer() {
|
||
return loaderBuilder -> loaderBuilder
|
||
.withString(REQUEST_CONSISTENCY, ConsistencyLevel.LOCAL_QUORUM.name())
|
||
}
|
||
|
||
```
|
||
|
||
这里我们将一致性级别从 LOCAL\_ONE 改成了 LOCAL\_QUARM,更符合我们的实际产品部署和应用情况。
|
||
|
||
## 案例 3:冗余的 Session
|
||
|
||
有时候,我们使用 Spring Data 做连接时,会比较在意我们的内存占用。例如我们使用 Spring Data Cassandra 操作 Cassandra 时,可能会发现类似这样的问题:
|
||
|
||
![](https://static001.geekbang.org/resource/image/58/5e/583825f4ff17d73eccbde141be81745e.png?wh=1348*610)
|
||
|
||
Spring Data Cassandra 在连接 Cassandra 之后,会获取 Cassandra 的 Metadata 信息,这个内存占用量是比较大的,因为它存储了数据的 Token Range 等信息。如上图所示,在我们的应用中,占用 40M 以上已经不少了,但问题是为什么有 4 个占用 40 多 M 呢?难道不是只建立一个连接么?
|
||
|
||
### 案例解析
|
||
|
||
要定位这个问题,或许不是特别难,我们只要找到获取 Metadata 的地方加个断点,然后找出触发获取的源头即可。但是毕竟这是 Spring Data 间接操作,Cassandra driver 本身就可能够复杂了,再加上 Spring Data 的复杂度,想迅速定位问题的根源其实也不是一件容易的事情。
|
||
|
||
这里我们可以先写一个例子,直接展示下问题的原因,然后再来看看我们的问题到底出现在什么地方!
|
||
|
||
现在我们定义一个 MyService 类,当它构造时,会输出它的名称信息:
|
||
|
||
```
|
||
public class MyService {
|
||
|
||
public MyService(String name){
|
||
System.err.println(name);
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
然后我们定义两个 Configuration 类,同时让它们是继承关系,其中父 Configuration 命名如下:
|
||
|
||
```
|
||
@Configuration
|
||
public class BaseConfig {
|
||
|
||
@Bean
|
||
public MyService service(){
|
||
return new MyService("myservice defined from base config");
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
子 Configuration 命名如下:
|
||
|
||
```
|
||
@Configuration
|
||
public class Config extends BaseConfig {
|
||
|
||
@Bean
|
||
public MyService service(){
|
||
return new MyService("myservice defined from config");
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
子类的 service() 实现覆盖了父类对应的方法。最后,我们书写一个启动程序:
|
||
|
||
```
|
||
@SpringBootApplication
|
||
public class Application {
|
||
|
||
public static void main(String[] args) {
|
||
SpringApplication.run(Application.class, args);
|
||
}
|
||
|
||
}
|
||
|
||
```
|
||
|
||
为了让程序启动,我们不能将 BaseConfig 和 Config 都放到 Application 的扫描范围。我们可以按如下结构组织代码:
|
||
|
||
![](https://static001.geekbang.org/resource/image/fa/c4/fa77e46568b1dcb5b4eba35ec1d300c4.png?wh=386*164)
|
||
|
||
最终我们会发现,当程序启动时,我们只有一个 MyService 的 Bean 产生,输出日志如下:
|
||
|
||
> myservice defined from config
|
||
|
||
这里可以看出,如果我们的子类标识 Bean 的方法正好覆盖了对应的父类,那么只能利用子类的方法产生一个 Bean。
|
||
|
||
但是假设我们不小心在子类实现时,没有意识到父类方法的存在,定义如下呢?
|
||
|
||
```
|
||
@Configuration
|
||
public class Config extends BaseConfig {
|
||
|
||
@Bean
|
||
public MyService service2(){
|
||
return new MyService("myservice defined from config");
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
经过上述的不小心修改,再次运行程序,你会发现有 2 个 MyService 的 Bean 产生:
|
||
|
||
> myservice defined from config
|
||
> myservice defined from base config
|
||
|
||
说到这里你可能想到一个造成内存翻倍的原因。我们去查看案例程序的代码,可能会发现存在这样的问题:
|
||
|
||
```
|
||
@Configuration
|
||
@EnableCassandraRepositories
|
||
public class CassandraConfig extends AbstractCassandraConfiguration
|
||
@Bean
|
||
@Primary
|
||
public CqlSessionFactoryBean session() {
|
||
log.info("init session");
|
||
CqlSessionFactoryBean cqlSessionFactoryBean = new CqlSessionFactoryBean();
|
||
//省略其他非关键代码
|
||
return cqlSessionFactoryBean ;
|
||
}
|
||
//省略其他非关键代码
|
||
}
|
||
|
||
```
|
||
|
||
CassandraConfig 继承于 AbstractSessionConfiguration,它已经定义了一个 CqlSessionFactoryBean,代码如下:
|
||
|
||
```
|
||
@Configuration
|
||
public abstract class AbstractSessionConfiguration implements BeanFactoryAware
|
||
@Bean
|
||
public CqlSessionFactoryBean cassandraSession() {
|
||
CqlSessionFactoryBean bean = new CqlSessionFactoryBean();
|
||
bean.setContactPoints(getContactPoints());
|
||
//省略其他非关键代码
|
||
return bean;
|
||
}
|
||
//省略其他非关键代码
|
||
}
|
||
|
||
```
|
||
|
||
而比较这两段的 CqlSessionFactoryBean 的定义方法,你会发现它们的方法名是不同的:
|
||
|
||
> cassandraSession()
|
||
> session()
|
||
|
||
所以结合前面的简单示例,相信你已经明白问题出在哪了!
|
||
|
||
### 案例修正
|
||
|
||
我们只要几秒钟就能解决这个问题。我们可以把原始案例代码修改如下:
|
||
|
||
```
|
||
@Configuration
|
||
@EnableCassandraRepositories
|
||
public class CassandraConfig extends AbstractCassandraConfiguration
|
||
@Bean
|
||
@Primary
|
||
public CqlSessionFactoryBean cassandraSession() {
|
||
//省略其他非关键代码
|
||
}
|
||
//省略其他非关键代码
|
||
}
|
||
|
||
```
|
||
|
||
这里我们将原来的方法名session改成cassandraSession。不过你可能会有一个疑问,这里不就是翻倍了么?但也不至于四倍啊。
|
||
|
||
实际上,这是因为使用 Spring Data Cassandra 会创建两个Session,它们都会获取metadata。具体可参考代码CqlSessionFactoryBean#afterPropertiesSet:
|
||
|
||
```
|
||
@Override
|
||
public void afterPropertiesSet() {
|
||
|
||
CqlSessionBuilder sessionBuilder = buildBuilder();
|
||
// system session 的创建
|
||
this.systemSession = buildSystemSession(sessionBuilder);
|
||
|
||
initializeCluster(this.systemSession);
|
||
// normal session 的创建
|
||
this.session = buildSession(sessionBuilder);
|
||
|
||
executeCql(getStartupScripts().stream(), this.session);
|
||
performSchemaAction();
|
||
|
||
this.systemSession.refreshSchema();
|
||
this.session.refreshSchema();
|
||
}
|
||
|
||
```
|
||
|
||
上述代码中的 systemSession 和 session 即为上文提及的两个 Session。
|
||
|
||
## 重点回顾
|
||
|
||
学习完这3个案例,我们会发现,有些错误的直接结果很严重,以至于你很快就能定位并解决问题,但有一些问题会很隐蔽,例如案例 2 引发的问题就是如此,因为它不能 100%被重现。
|
||
|
||
结合案例,我们可以总结出使用 Spring Data 时必须注意的一些关键点:
|
||
|
||
1. 一定要注意一致性,例如读写的序列化方法需要一致;
|
||
2. 一定要重新检查下所有的默认配置是什么,是否符合当前的需求,例如在 Spring Data Cassandra 中,默认的一致性级别在大多情况下都不适合;
|
||
3. 如果你自定义自己的Session,一定要避免冗余的Session产生。
|
||
|
||
记住这3点,你就能规避不少 Spring Data 使用上的问题了。
|
||
|
||
## 思考题
|
||
|
||
在案例 1 中使用 Spring Data Redis 时,我们提到了 StringRedisTemplate 和 RedisTemplate。那么它们是如何被创建起来的呢?
|
||
|
||
期待你的思考,我们留言区见。
|
||
|