2023_08—第四周

Druid driverClassName

Context: 数据库从 Oracle 迁移至 OceanBase(Oracle 租户模式),顺便将数据库配置从项目文件迁移至 Apollo,代码无改动。框架为定制化的 Spring。

OK,下面讲问题,,

不出意外,出意外了,,发版时,服务启动成功后,查询数据库报错:👇🏻

1
2
3
4
5
6
nested exception is org.apache.ibatis.exceptions.PersistenceException: 
### Error updating database. Cause: java.lang.NullPointerException
### The error may involve com.*.*.dao.*Mapper.updateByPrimaryKeySelective-Inline
### The error occurred while setting parameters
### SQL: update xxx
### Cause: java.lang.NullPointerException

报错的堆栈信息其实并不能说明特别具体的问题,因为执行任何一条 SQL 都会报错,可以排除跟代码无关。

另外,经过深入查找还发现其他报错信息:unknown jdbc driver : ***,位于 Druid 包中,以 1.1.9 版本为例,代码如下:👇🏻

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 该方法主要使用来通过数据库链接串来确定对应数据库的驱动名称
* https://github.com/alibaba/druid/blob/1.1.9/src/main/java/com/alibaba/druid/util/JdbcUtils.java
*/
public static String getDriverClassName(String rawUrl) throws SQLException {
if (rawUrl == null) {
return null;
}
if (rawUrl.startsWith("jdbc:derby:")) {
return "org.apache.derby.jdbc.EmbeddedDriver";
} else if (rawUrl.startsWith("jdbc:mysql:")) {
if (mysql_driver_version_6 == null) {
mysql_driver_version_6 = Utils.loadClass("com.mysql.cj.jdbc.Driver") != null;
}
if (mysql_driver_version_6) {
return MYSQL_DRIVER_6;
} else {
return MYSQL_DRIVER;
}
}
...
} else if (rawUrl.startsWith("jdbc:clickhouse:")) {
return JdbcConstants.CLICKHOUSE_DRIVER;
} else {
throw new SQLException("unkow jdbc driver : " + rawUrl); // 👈🏻👈👈
}

那基本确定问题了,,当前 Druid 版本有点低,没有对 OceanBase 做兼容,理论上升级高版本的可以解决。事实上,升级到 2.X 版本后也确实解决了问题[^1],但还有个疑点,测试环境没问题啊。

此时已经将近凌晨 12 点,不少关联方同事都在等待验证,先试探性的找到运维同学帮忙替换高版本 jar 之后,重启试试。重启之后,业务一切正常,验证完自己负责的业务功能后,继续寻找答案,,

此情此景,不免心生疑问,,Druid 这么 Low 的么?通过这种方式查询对应的数据库驱动,那岂不是很耽误事儿,,市场每出一款数据库,就要立即出版本做兼容?不是可以指定驱动名称的么?有妖气!!!

其实到这里,问题基本定位到了,,就是驱动名称配置的问题。搞笑的是,出问题的不是配置中的驱动名称:com.alipay.oceanbase.jdbc.Driver,而是对应的 Key:driverClassName。。测试环境配置的没问题,生产环境配置的是 driverClass 😂。

以👆🏻上内容都是后知后觉,因为当初核对生产配置时,有三个开发在场,大家的关注点都在 value 上,key 反而忽略了。。

继续追查妖气,,在 druid 包下查找 JdbcUtils.getDriverClassName() 的上层引用,发现了蛛丝马迹:👇🏻

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static DataSourceProxyConfig parseConfig(String url, Properties info) throws SQLException {
String restUrl = url.substring(DEFAULT_PREFIX.length());

DataSourceProxyConfig config = new DataSourceProxyConfig();

if (restUrl.startsWith(DRIVER_PREFIX)) {
int pos = restUrl.indexOf(':', DRIVER_PREFIX.length());
String driverText = restUrl.substring(DRIVER_PREFIX.length(), pos);
if (driverText.length() > 0) {
config.setRawDriverClassName(driverText.trim()); // 👈👈👈
}
restUrl = restUrl.substring(pos + 1);
}
// ...
String rawUrl = restUrl;
config.setRawUrl(rawUrl);

if (config.getRawDriverClassName() == null) {
String rawDriverClassname = JdbcUtils.getDriverClassName(rawUrl); // 👈👈👈
config.setRawDriverClassName(rawDriverClassname);
}

config.setUrl(url);
return config;
}

接着追查 parseConfig() 可以发现 druid 的数据源工厂在生产数据源时,会通过配置文件组装实例:👇🏻

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class DruidDataSourceFactory implements ObjectFactory {

public static final String PROP_DRIVERCLASSNAME = "driverClassName";
public static final String PROP_INIT = "init";

public static DataSource createDataSource(Map properties) throws Exception {
DruidDataSource dataSource = new DruidDataSource();
config(dataSource, properties); // 👈👈👈 使用配置文件组装实例
return dataSource;
}

public static void config(DruidDataSource dataSource, Map<?, ?> properties) throws SQLException {
// ...
value = (String) properties.get(PROP_DRIVERCLASSNAME);
if (value != null) {
dataSource.setDriverClassName(value); // 👈👈👈 setDriverClassName
}
// ...
value = (String) properties.get(PROP_INIT);
if ("true".equals(value)) {
dataSource.init(); // 👈👈👈 如果 `init` 设置为 true 的话,会在 init() 中执行 validationQuery,验证数据库是否可用,否则就只能等服务启动后执行 SQL 语句时 getConnection() 时验证了。
}
}
}

在定制框架中,在 DruidBean 在实现 InitializingBean#afterPropertiesSet() 的方法中, 指定了配置项 A,如果该配置项开启,则执行 dataSource.init(); 方法,遗憾的是,,该配置项并未开启,导致问题在服务启动阶段并未暴露。。

看了下 SpringBoot 框架通过 @EnableAutoConfiguration 机制注入的 DruidDataSource 执行流程与上述有差异,大家需要注意下,挖个坑,慢慢填。。

OceanBase ORA-24761

在 OceanBase Oracle 租户模式下,存在一个特殊的配置:ob_trx_idle_timeout,数据库迁移时需额外注意下。官方解释为:事务空闲超时指当事务两条语句执行间隔超过指定阈值。可以通过以下语句查看数据库配置:

obclient> SHOW variables like ‘ob_trx_idle_timeout’;

| ob_trx_idle_timeout | 120000000 |

需要注意的是,单位为微秒,即默认 120s。官方在 v4.2 版本[^2]中的例子比较好理解:(吐槽下..下载的 v3.2.3 版本的 PDF 文档例子不容易理解):👇🏻

obclient [SYS]> SELECT * FROM ordr;

+—-+——+——-+——————————+

| ID | NAME | VALUE | GMT_CREATE |

+—-+——+——-+——————————+ |

| 1 | CN | NULL | 04-NOV-22 06.06.16.843024 PM |

| 2 | UK | NULL | 04-NOV-22 06.06.16.843024 PM |

| 3 | US | NULL | 04-NOV-22 06.06.16.843024 PM |

+—-+——+——-+——————————+

3 rows in set

obclient [SYS]> UPDATE ordr SET value=1003 WHERE id=3;

Query OK, 1 rows affected

Rows matched: 1 Changed: 1 Warnings: 0

/* 等待较长一段时间不操作*/ // 👈👈👈 时间超过 ob_trx_idle_timeout 即报错

obclient [SYS]> SELECT * FROM ordr;

ORA-24761: transaction rolled back: transaction idle timeout

简单来说就是,,开启事务后,事务内两条语句执行间隔超过 ob_trx_idle_timeout 就会报 ORA-24761。

快速解决方案就是将间隔时间配置长一点,要从根本上解决还得是分析业务逻辑,重新评审代码实现方案缩小事务范围,可以考虑最终一致 + 局部事务 +补偿,保证不会产生脏数据,再结合报警+运营手段解决。


[^1]: JdbcUtils.java V2.X
[^2]: 事务空闲超时,错误代码 ORA-24761