Sharding-JDBC入门

Sharding-JDBC入门

一、Sharding-JDBC 简介

Sharding-JDBC 最早是当当网内部使用的一款分库分表框架,到2017年的时候才开始对外开源,这几年在大量社区贡献者的不断迭代下,功能也逐渐完善,现已更名为 ShardingSphere,2020年4⽉16⽇正式成为 Apache 软件基⾦会的顶级项⽬。

随着版本的不断更迭 ShardingSphere 的核心功能也变得多元化起来。从最开始 Sharding-JDBC 1.0 版本只有数据分片,到 Sharding-JDBC 2.0 版本开始支持数据库治理(注册中心、配置中心等等),再到 Sharding-JDBC 3.0版本又加分布式事务 (支持 Atomikos、Narayana、Bitronix、Seata),如今已经迭代到了 Sharding-JDBC 4.0 版本。

37cd2d870a2f68a5304997aec17b53d81f7f49.png

现在的 ShardingSphere 不单单是指某个框架而是一个生态圈,这个生态圈 Sharding-JDBC、Sharding-Proxy 和 Sharding-Sidecar 这三款开源的分布式数据库中间件解决方案所构成。

ShardingSphere 的前身就是 Sharding-JDBC,所以它是整个框架中最为经典、成熟的组件,我们先从 Sharding-JDBC 框架入手学习分库分表。

二、核心概念

在开始 Sharding-JDBC分库分表具体实战之前,我们有必要先了解分库分表的一些核心概念。

分片

一般我们在提到分库分表的时候,大多是以水平切分模式(水平分库、分表)为基础来说的,数据分片将原本一张数据量较大的表 t_order 拆分生成数个表结构完全一致的小数据量表 t_order_0、t_order_1、···、t_order_n,每张表只存储原大表中的一部分数据,当执行一条SQL时会通过 分库策略、分片策略 将数据分散到不同的数据库、表内。

357c9a6065cf897f9ac02664575f01f8fd7f8f.png

数据节点

数据节点是分库分表中一个不可再分的最小数据单元(表),它由数据源名称和数据表组成,例如上图中 order_db_1.t_order_0、order_db_2.t_order_1 就表示一个数据节点。

逻辑表

逻辑表是指一组具有相同逻辑和数据结构表的总称。比如我们将订单表t_order 拆分成 t_order_0 ··· t_order_9 等 10张表。此时我们会发现分库分表以后数据库中已不在有 t_order 这张表,取而代之的是 t_order_n,但我们在代码中写 SQL 依然按 t_order 来写。此时 t_order 就是这些拆分表的逻辑表。

真实表

真实表也就是上边提到的 t_order_n 数据库中真实存在的物理表。

分片键

用于分片的数据库字段。我们将 t_order 表分片以后,当执行一条SQL时,通过对字段 order_id 取模的方式来决定,这条数据该在哪个数据库中的哪个表中执行,此时 order_id 字段就是 t_order 表的分片健。

391fef3850fd6eaef447679258468417b4fabf.png

这样以来同一个订单的相关数据就会存在同一个数据库表中,大幅提升数据检索的性能,不仅如此 sharding-jdbc 还支持根据多个字段作为分片健进行分片。

分片算法

上边我们提到可以用分片健取模的规则分片,但这只是比较简单的一种,在实际开发中我们还希望用 >=、<=、>、<、BETWEEN 和 IN 等条件作为分片规则,自定义分片逻辑,这时就需要用到分片策略与分片算法。

从执行 SQL 的角度来看,分库分表可以看作是一种路由机制,把 SQL 语句路由到我们期望的数据库或数据表中并获取数据,分片算法可以理解成一种路由规则。

咱们先捋一下它们之间的关系,分片策略只是抽象出的概念,它是由分片算法和分片健组合而成,分片算法做具体的数据分片逻辑。

分库、分表的分片策略配置是相对独立的,可以各自使用不同的策略与算法,每种策略中可以是多个分片算法的组合,每个分片算法可以对多个分片健做逻辑判断。

17e8c31099548f64886625a65b19ab87893fd8.png

分片算法和分片策略的关系

注意:sharding-jdbc 并没有直接提供分片算法的实现,需要开发者根据业务自行实现。

sharding-jdbc 提供了4种分片算法:

1、精确分片算法

精确分片算法(PreciseShardingAlgorithm)用于单个字段作为分片键,SQL中有 = 与 IN 等条件的分片,需要在标准分片策略(StandardShardingStrategy )下使用。

2、范围分片算法

范围分片算法(RangeShardingAlgorithm)用于单个字段作为分片键,SQL中有 BETWEEN AND、>、<、>=、<= 等条件的分片,需要在标准分片策略(StandardShardingStrategy )下使用。

3、复合分片算法

复合分片算法(ComplexKeysShardingAlgorithm)用于多个字段作为分片键的分片操作,同时获取到多个分片健的值,根据多个字段处理业务逻辑。需要在复合分片策略(ComplexShardingStrategy )下使用。

4、Hint分片算法

Hint分片算法(HintShardingAlgorithm)稍有不同,上边的算法中我们都是解析SQL 语句提取分片键,并设置分片策略进行分片。但有些时候我们并没有使用任何的分片键和分片策略,可还想将 SQL 路由到目标数据库和表,就需要通过手动干预指定SQL的目标数据库和表信息,这也叫强制路由。

分片策略

上边讲分片算法的时候已经说过,分片策略是一种抽象的概念,实际分片操作的是由分片算法和分片健来完成的。

1、标准分片策略

标准分片策略适用于单分片键,此策略支持 PreciseShardingAlgorithm 和 RangeShardingAlgorithm 两个分片算法。

其中 PreciseShardingAlgorithm 是必选的,用于处理 = 和 IN 的分片。RangeShardingAlgorithm 是可选的,用于处理BETWEEN AND, >, <,>=,<= 条件分片,如果不配置RangeShardingAlgorithm,SQL中的条件等将按照全库路由处理。

2、复合分片策略

复合分片策略,同样支持对 SQL语句中的 =,>, <, >=, <=,IN和 BETWEEN AND 的分片操作。不同的是它支持多分片键,具体分配片细节完全由应用开发者实现。

3、行表达式分片策略

行表达式分片策略,支持对 SQL语句中的 = 和 IN 的分片操作,但只支持单分片键。这种策略通常用于简单的分片,不需要自定义分片算法,可以直接在配置文件中接着写规则。

t_order_$->{t_order_id % 4} 代表 t_order 对其字段 t_order_id取模,拆分成4张表,而表名分别是t_order_0 到 t_order_3。

4、Hint分片策略

Hint分片策略,对应上边的Hint分片算法,通过指定分片健而非从 SQL中提取分片健的方式进行分片的策略。

分布式主键

数据分⽚后,不同数据节点⽣成全局唯⼀主键是⾮常棘⼿的问题,同⼀个逻辑表(t_order)内的不同真实表(t_order_n)之间的⾃增键由于⽆法互相感知而产⽣重复主键。

尽管可通过设置⾃增主键 初始值 和 步⻓ 的⽅式避免ID碰撞,但这样会使维护成本加大,乏完整性和可扩展性。如果后去需要增加分片表的数量,要逐一修改分片表的步长,运维成本非常高,所以不建议这种方式。

实现分布式主键⽣成器的方式很多,可以参考我之前写的《9种分布式ID生成方式》

为了让上手更加简单,ApacheShardingSphere 内置了UUID、SNOWFLAKE 两种分布式主键⽣成器,默认使⽤雪花算法(snowflake)⽣成64bit的⻓整型数据。不仅如此它还抽离出分布式主键⽣成器的接口,⽅便我们实现⾃定义的⾃增主键⽣成算法。

广播表

广播表:存在于所有的分片数据源中的表,表结构和表中的数据在每个数据库中均完全一致。一般是为字典表或者配置表 t_config,某个表一旦被配置为广播表,只要修改某个数据库的广播表,所有数据源中广播表的数据都会跟着同步。

绑定表

绑定表:那些分片规则一致的主表和子表。比如:t_order 订单表和 t_order_item 订单服务项目表,都是按 order_id 字段分片,因此两张表互为绑定表关系。

那绑定表存在的意义是啥呢?

通常在我们的业务中都会使用 t_order 和 t_order_item 等表进行多表联合查询,但由于分库分表以后这些表被拆分成N多个子表。如果不配置绑定表关系,会出现笛卡尔积关联查询,将产生如下四条SQL。

1
2
3
4
SELECT * FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id 
SELECT * FROM t_order_0 o JOIN t_order_item_1 i ON o.order_id=i.order_id
SELECT * FROM t_order_1 o JOIN t_order_item_0 i ON o.order_id=i.order_id
SELECT * FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id

476aa9d21749fad3d77018b69ecac4c58f5d4c.png

笛卡尔积查询

而配置绑定表关系后再进行关联查询时,只要对应表分片规则一致产生的数据就会落到同一个库中,那么只需 t_order_0 和 t_order_item_0 表关联即可。

1
2
SELECT * FROM t_order_0 o JOIN t_order_item_0 i ON o.order_id=i.order_id 
SELECT * FROM t_order_1 o JOIN t_order_item_1 i ON o.order_id=i.order_id

420c8da9949389203424118e98818afec717d9.png

绑定表关系

注意:在关联查询时 t_order 它作为整个联合查询的主表。所有相关的路由计算都只使用主表的策略,t_order_item 表的分片相关的计算也会使用 t_order 的条件,所以要保证绑定表之间的分片键要完全相同。

三、和JDBC的猫腻

从名字上不难看出,Sharding-JDBC 和 JDBC有很大关系,我们知道 JDBC 是一种 Java 语言访问关系型数据库的规范,其设计初衷就是要提供一套用于各种数据库的统一标准,不同厂家共同遵守这套标准,并提供各自的实现方案供应用程序调用。

77c415b51b33e9ed0b8844aaaa64fcba718a7d.png

但其实对于开发人员而言,我们只关心如何调用 JDBC API 来访问数据库,只要正确使用 DataSource、Connection、Statement 、ResultSet 等 API 接口,直接操作数据库即可。所以如果想在 JDBC 层面实现数据分片就必须对现有的 API 进行功能拓展,而 Sharding-JDBC 正是基于这种思想,重写了 JDBC 规范并完全兼容了 JDBC 规范。

a5eceb153a77640d6f4504e893dc74ae00507a.png

JDBC流程

对原有的 DataSource、Connection 等接口扩展成 ShardingDataSource、ShardingConnection,而对外暴露的分片操作接口与 JDBC 规范中所提供的接口完全一致,只要你熟悉 JDBC 就可以轻松应用 Sharding-JDBC 来实现分库分表。

c86fa7c9708dfeaf0dc590c13b018b96ec07b9.png

因此它适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate,Mybatis,Spring JDBC Template 或直接使用的 JDBC。完美兼容任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP,Druid, HikariCP 等,几乎对主流关系型数据库都支持。

Sharding-JDBC **又是如何拓展这些接口的呢?**想知道答案我们就的从源码入手了,下边我们以 JDBC API 中的 DataSource 为例看看它是如何被重写扩展的。

数据源 DataSource 接口的核心作用就是获取数据库连接对象 Connection,我们看其内部提供了两个获取数据库连接的方法 ,并且继承了 CommonDataSource 和 Wrapper 两个接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public interface DataSource  extends CommonDataSource, Wrapper {

/**
* <p>Attempts to establish a connection with the data source that
* this {@code DataSource} object represents.
* @return a connection to the data source
*/
Connection getConnection() throws SQLException;

/**
* <p>Attempts to establish a connection with the data source that
* this {@code DataSource} object represents.
* @param username the database user on whose behalf the connection is
* being made
* @param password the user's password
*/
Connection getConnection(String username, String password)
throws SQLException;
}

其中 CommonDataSource 是定义数据源的根接口这很好理解,而 Wrapper 接口则是拓展 JDBC 分片功能的关键。

由于数据库厂商的不同,他们可能会各自提供一些超越标准 JDBC API 的扩展功能,但这些功能非 JDBC 标准并不能直接使用,而 Wrapper 接口的作用就是把一个由第三方供应商提供的、非 JDBC 标准的接口包装成标准接口,也就是适配器模式。

既然讲到了适配器模式就多啰嗦几句,也方便后边的理解。

适配器模式个种比较常用的设计模式,它的作用是将某个类的接口转换成客户端期望的另一个接口,使原本因接口不匹配(或者不兼容)而无法在一起工作的两个类能够在一起工作。比如用耳机听音乐,我有个圆头的耳机,可手机插孔却是扁口的,如果我想要使用耳机听音乐就必须借助一个转接头才可以,这个转接头就起到了适配作用。举个栗子:假如我们 Target 接口中有 hello() 和 word() 两个方法。

1
2
3
4
5
6
public interface Target {

void hello();

void world();
}1.2.3.4.5.6.

可由于接口版本迭代Target 接口的 word() 方法可能会被废弃掉或不被支持,Adaptee 类的 greet()方法将代替hello() 方法。

1
2
3
4
5
6
7
8
9
public class Adaptee {

public void graeet(){

}
public void world(){

}
}

但此时旧版本仍然有大量 word() 方法被使用中,解决此事最好的办法就是创建一个适配器Adapter,这样就适配了 Target 类,解决了接口升级带来的兼容性问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Adapter extends Adaptee implements Target {

@Override
public void world() {

}

@Override
public void hello() {
super.greet();
}

@Override
public void greet() {

}
}

而 Sharding-JDBC 提供的正是非 JDBC 标准的接口,所以它也提供了类似的实现方案,也使用到了 Wrapper 接口做数据分片功能的适配。除了 DataSource 之外,Connection、Statement、ResultSet 等核心对象也都继承了这个接口。

下面我们通过 ShardingDataSource 类源码简单看下实现过程,下图是继承关系流程图。

49b41c48798299718fa1014a36868eea76537d.png

ShardingDataSource实现流程

ShardingDataSource 类它在原 DataSource 基础上做了功能拓展,初始化时注册了分片SQL路由包装器、SQL重写上下文和结果集处理引擎,还对数据源类型做了校验,因为它要同时支持多个不同类型的数据源。到这好像也没看出如何适配,那接着向上看 ShardingDataSource 的继承类 AbstractDataSourceAdapter 。

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
27
28
29
30
31
32
33
34
35
36
37
@Getter
public class ShardingDataSource extends AbstractDataSourceAdapter {

private final ShardingRuntimeContext runtimeContext;

/**
* 注册路由、SQl重写上下文、结果集处理引擎
*/
static {
NewInstanceServiceLoader.register(RouteDecorator.class);
NewInstanceServiceLoader.register(SQLRewriteContextDecorator.class);
NewInstanceServiceLoader.register(ResultProcessEngine.class);
}

/**
* 初始化时校验数据源类型 并根据数据源 map、分片规则、数据库类型得到一个分片上下文,用来获取数据库连接
*/
public ShardingDataSource(final Map<String, DataSource> dataSourceMap, final ShardingRule shardingRule, final Properties props) throws SQLException {
super(dataSourceMap);
checkDataSourceType(dataSourceMap);
runtimeContext = new ShardingRuntimeContext(dataSourceMap, shardingRule, props, getDatabaseType());
}

private void checkDataSourceType(final Map<String, DataSource> dataSourceMap) {
for (DataSource each : dataSourceMap.values()) {
Preconditions.checkArgument(!(each instanceof MasterSlaveDataSource), "Initialized data sources can not be master-slave data sources.");
}
}

/**
* 数据库连接
*/
@Override
public final ShardingConnection getConnection() {
return new ShardingConnection(getDataSourceMap(), runtimeContext, TransactionTypeHolder.get());
}
}

AbstractDataSourceAdapter 抽象类内部主要获取不同类型的数据源对应的数据库连接对象,实现 AutoCloseable 接口是为在使用完资源后可以自动将这些资源关闭(调用 close方法),那再看看继承类 AbstractUnsupportedOperationDataSource 。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Getter
public abstract class AbstractDataSourceAdapter extends AbstractUnsupportedOperationDataSource implements AutoCloseable {

private final Map<String, DataSource> dataSourceMap;

private final DatabaseType databaseType;

public AbstractDataSourceAdapter(final Map<String, DataSource> dataSourceMap) throws SQLException {
this.dataSourceMap = dataSourceMap;
databaseType = createDatabaseType();
}

public AbstractDataSourceAdapter(final DataSource dataSource) throws SQLException {
dataSourceMap = new HashMap<>(1, 1);
dataSourceMap.put("unique", dataSource);
databaseType = createDatabaseType();
}

private DatabaseType createDatabaseType() throws SQLException {
DatabaseType result = null;
for (DataSource each : dataSourceMap.values()) {
DatabaseType databaseType = createDatabaseType(each);
Preconditions.checkState(null == result || result == databaseType, String.format("Database type inconsistent with '%s' and '%s'", result, databaseType));
result = databaseType;
}
return result;
}

/**
* 不同数据源类型获取数据库连接
*/
private DatabaseType createDatabaseType(final DataSource dataSource) throws SQLException {
if (dataSource instanceof AbstractDataSourceAdapter) {
return ((AbstractDataSourceAdapter) dataSource).databaseType;
}
try (Connection connection = dataSource.getConnection()) {
return DatabaseTypes.getDatabaseTypeByURL(connection.getMetaData().getURL());
}
}

@Override
public final Connection getConnection(final String username, final String password) throws SQLException {
return getConnection();
}

@Override
public final void close() throws Exception {
close(dataSourceMap.keySet());
}
}

AbstractUnsupportedOperationDataSource 实现DataSource 接口并继承了 WrapperAdapter 类,它内部并没有什么具体方法只起到桥接的作用,但看着是不是和我们前边讲适配器模式的例子方式有点相似。

1
2
3
4
5
6
7
8
9
10
11
12
public abstract class AbstractUnsupportedOperationDataSource extends WrapperAdapter implements DataSource {

@Override
public final int getLoginTimeout() throws SQLException {
throw new SQLFeatureNotSupportedException("unsupported getLoginTimeout()");
}

@Override
public final void setLoginTimeout(final int seconds) throws SQLException {
throw new SQLFeatureNotSupportedException("unsupported setLoginTimeout(int seconds)");
}
}

WrapperAdapter 是一个包装器的适配类,实现了 JDBC 中的 Wrapper 接口,其中有两个核心方法 recordMethodInvocation 用于添加需要执行的方法和参数,而 replayMethodsInvocation 则将添加的这些方法和参数通过反射执行。仔细看不难发现两个方法中都用到了 JdbcMethodInvocation类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public abstract class WrapperAdapter implements Wrapper {

private final Collection<JdbcMethodInvocation> jdbcMethodInvocations = new ArrayList<>();

/**
* 添加要执行的方法
*/
@SneakyThrows
public final void recordMethodInvocation(final Class<?> targetClass, final String methodName, final Class<?>[] argumentTypes, final Object[] arguments) {
jdbcMethodInvocations.add(new JdbcMethodInvocation(targetClass.getMethod(methodName, argumentTypes), arguments));
}

/**
* 通过反射执行 上边添加的方法
*/
public final void replayMethodsInvocation(final Object target) {
for (JdbcMethodInvocation each : jdbcMethodInvocations) {
each.invoke(target);
}
}
}

JdbcMethodInvocation 类主要应用反射通过传入的 method 方法和 arguments 参数执行对应的方法,这样就可以通过 JDBC API 调用非 JDBC 方法了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RequiredArgsConstructor
public class JdbcMethodInvocation {

@Getter
private final Method method;

@Getter
private final Object[] arguments;

/**
* Invoke JDBC method.
*
* @param target target object
*/
@SneakyThrows
public void invoke(final Object target) {
method.invoke(target, arguments);
}
}

那 Sharding-JDBC 拓展 JDBC API 接口后,在新增的分片功能里又做了哪些事情呢?

一张表经过分库分表后被拆分成多个子表,并分散到不同的数据库中,在不修改原业务 SQL 的前提下,Sharding-JDBC 就必须对 SQL进行一些改造才能正常执行。

大致的执行流程:SQL 解析 -> 执⾏器优化 -> SQL 路由 -> SQL 改写 -> SQL 执⾏ -> 结果归并 六步组成,一起瞅瞅每个步骤做了点什么。
83520a2514c3691be5b36638329a602e3feef1.png

SQL 解析

SQL解析过程分为词法解析和语法解析两步,比如下边这条查询用户订单的SQL,先用词法解析将SQL拆解成不可再分的原子单元。在根据不同数据库方言所提供的字典,将这些单元归类为关键字,表达式,变量或者操作符等类型。

1
SELECT order_no,price FROM t_order_ where user_id = 10086 and order_status > 0

接着语法解析会将拆分后的SQL转换为抽象语法树,通过对抽象语法树遍历,提炼出分片所需的上下文,上下文包含查询字段信息(Field)、表信息(Table)、查询条件(Condition)、排序信息(Order By)、分组信息(Group By)以及分页信息(Limit)等,并标记出 SQL中有可能需要改写的位置。

e4bc5c402444ed05610427bea57676e831fc5e.png

抽象语法树

执⾏器优化

执⾏器优化对SQL分片条件进行优化,处理像关键字 OR这种影响性能的坏味道。

SQL 路由

SQL 路由通过解析分片上下文,匹配到用户配置的分片策略,并生成路由路径。简单点理解就是可以根据我们配置的分片策略计算出 SQL该在哪个库的哪个表中执行,而SQL路由又根据有无分片健区分出 分片路由 和 广播路由。

984dca3599293c51191234fb2fae220084e8a8.png

官方路由图谱

有分⽚键的路由叫分片路由,细分为直接路由、标准路由和笛卡尔积路由这3种类型。

标准路由

标准路由是最推荐也是最为常⽤的分⽚⽅式,它的适⽤范围是不包含关联查询或仅包含绑定表之间关联查询的SQL。

当 SQL分片健的运算符为 = 时,路由结果将落⼊单库(表),当分⽚运算符是BETWEEN 或IN 等范围时,路由结果则不⼀定落⼊唯⼀的库(表),因此⼀条逻辑SQL最终可能被拆分为多条⽤于执⾏的真实SQL。

1
SELECT * FROM t_order  where t_order_id in (1,2)

SQL路由处理后

1
2
SELECT * FROM t_order_0  where t_order_id in (1,2)
SELECT * FROM t_order_1 where t_order_id in (1,2)
直接路由

直接路由是通过使用 HintAPI 直接将 SQL路由到指定⾄库表的一种分⽚方式,而且直接路由可以⽤于分⽚键不在SQL中的场景,还可以执⾏包括⼦查询、⾃定义函数等复杂情况的任意SQL。

比如根据 t_order_id 字段为条件查询订单,此时希望在不修改SQL的前提下,加上 user_id作为分片条件就可以使用直接路由。

笛卡尔积路由

笛卡尔路由是由⾮绑定表之间的关联查询产生的,查询性能较低尽量避免走此路由模式。

无分⽚键的路由又叫做广播路由,可以划分为全库表路由、全库路由、 全实例路由、单播路由和阻断路由这 5种类型。

全库表路由

全库表路由针对的是数据库 DQL和 DML,以及 DDL等操作,当我们执行一条逻辑表 t_order SQL时,在所有分片库中对应的真实表 t_order_0 ··· t_order_n 内逐一执行。

全库路由

全库路由主要是对数据库层面的操作,比如数据库 SET 类型的数据库管理命令,以及 TCL 这样的事务控制语句。

对逻辑库设置 autocommit 属性后,所有对应的真实库中都执行该命令。

1
SET autocommit=0;
全实例路由

全实例路由是针对数据库实例的 DCL 操作(设置或更改数据库用户或角色权限),比如:创建一个用户 order ,这个命令将在所有的真实库实例中执行,以此确保 order 用户可以正常访问每一个数据库实例。

1
CREATE USER order@127.0.0.1 identified BY '程序员内点事';
单播路由

单播路由用来获取某一真实表信息,比如获得表的描述信息:

1
DESCRIBE t_order; 

t_order 的真实表是 t_order_0 ···· t_order_n,他们的描述结构相完全同,我们只需在任意的真实表执行一次就可以。

阻断路由

⽤来屏蔽SQL对数据库的操作,例如:

1
USE order_db;

这个命令不会在真实数据库中执⾏,因为ShardingSphere 采⽤的是逻辑 Schema(数据库的组织和结构) ⽅式,所以无需将切换数据库的命令发送⾄真实数据库中。

SQL 改写

将基于逻辑表开发的SQL改写成可以在真实数据库中可以正确执行的语句。比如查询 t_order 订单表,我们实际开发中 SQL是按逻辑表 t_order 写的。

1
SELECT * FROM t_order

但分库分表以后真实数据库中 t_order 表就不存在了,而是被拆分成多个子表 t_order_n 分散在不同的数据库内,还按原SQL执行显然是行不通的,这时需要将分表配置中的逻辑表名称改写为路由之后所获取的真实表名称。

1
SELECT * FROM t_order_n

SQL执⾏

将路由和改写后的真实 SQL 安全且高效发送到底层数据源执行。但这个过程并不是简单的将 SQL 通过JDBC 直接发送至数据源执行,而是平衡数据源连接创建以及内存占用所产生的消耗,它会自动化的平衡资源控制与执行效率。

结果归并

将从各个数据节点获取的多数据结果集,合并成一个大的结果集并正确的返回至请求客户端,称为结果归并。而我们SQL中的排序、分组、分页和聚合等语法,均是在归并后的结果集上进行操作的。

四、快速实践

下面我们结合 Springboot + mybatisplus 快速搭建一个分库分表案例。

1、准备工作

先做准备工作,创建两个数据库 ds-0、ds-1,两个库中分别建表 t_order_0、t_order_1、t_order_2 、t_order_item_0、t_order_item_1、t_order_item_2,t_config,方便后边验证广播表、绑定表的场景。

表结构如下:

t_order_0 订单表

1
2
3
4
5
6
7
CREATE TABLE `t_order_0` (
`order_id` bigint(200) NOT NULL,
`order_no` varchar(100) DEFAULT NULL,
`create_name` varchar(50) DEFAULT NULL,
`price` decimal(10,2) DEFAULT NULL,
PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;

t_order_0 与 t_order_item_0 互为关联表

1
2
3
4
5
6
7
CREATE TABLE `t_order_item_0` (
`item_id` bigint(100) NOT NULL,
`order_no` varchar(200) NOT NULL,
`item_name` varchar(50) DEFAULT NULL,
`price` decimal(10,2) DEFAULT NULL,
PRIMARY KEY (`item_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=DYNAMIC;

广播表 t_config

1
2
3
4
5
6
  `id` bigint(30) NOT NULL,
`remark` varchar(50) CHARACTER SET utf8 DEFAULT NULL,
`create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_modify_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

ShardingSphere 提供了4种分片配置方式:

  • Java 代码配置

  • Yaml 、properties 配置

  • Spring 命名空间配置

  • Spring Boot配置

为让代码看上去更简洁和直观,后边统一使用 properties 配置的方式,引入 shardingsphere 对应的 sharding-jdbc-spring-boot-starter 和 sharding-core-common 包,版本统一用的 4.0.0-RC1。

2、分片配置

1
2
3
4
5
6
7
8
9
10
11
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-jdbc-spring-boot-starter</artifactId>
<version>4.0.0-RC1</version>
</dependency>

<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>sharding-core-common</artifactId>
<version>4.0.0-RC1</version>
</dependency>

准备工作做完( mybatis 搭建就不赘述了),接下来我们逐一解读分片配置信息。

我们首先定义两个数据源 ds-0、ds-1,并分别加上数据源的基础信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 定义两个全局数据源
spring.shardingsphere.datasource.names=ds-0,ds-1

# 配置数据源 ds-0
spring.shardingsphere.datasource.ds-0.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds-0.driverClassName=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds-0.url=jdbc:mysql://127.0.0.1:3306/ds-0?useUnicode=true&characterEncoding=utf8&tinyInt1isBit=false&useSSL=false&serverTimezone=GMT
spring.shardingsphere.datasource.ds-0.username=root
spring.shardingsphere.datasource.ds-0.password=root

# 配置数据源 ds-1
spring.shardingsphere.datasource.ds-1.type=com.alibaba.druid.pool.DruidDataSource
spring.shardingsphere.datasource.ds-1.driverClassName=com.mysql.jdbc.Driver
spring.shardingsphere.datasource.ds-1.url=jdbc:mysql://127.0.0.1:3306/ds-1?useUnicode=true&characterEncoding=utf8&tinyInt1isBit=false&useSSL=false&serverTimezone=GMT
spring.shardingsphere.datasource.ds-1.username=root
spring.shardingsphere.datasource.ds-1.password=root

配置完数据源接下来为表添加分库和分表策略,使用 sharding-jdbc 做分库分表需要我们为每一个表单独设置分片规则。

1
2
3
# 配置分片表 t_order
# 指定真实数据节点
spring.shardingsphere.sharding.tables.t_order.actual-data-nodes=ds-$->{0..1}.t_order_$->{0..2}

actual-data-nodes 属性指定分片的真实数据节点,$是一个占位符,{0..1}表示实际拆分的数据库表数量。

ds-$->{0..1}.t_order_$->{0..2} 表达式相当于 6个数据节点

  • ds-0.t_order_0
  • ds-0.t_order_1
  • ds-0.t_order_2
  • ds-1.t_order_0
  • ds-1.t_order_1
  • ds-1.t_order_2
1
2
3
4
5
### 分库策略
# 分库分片健
spring.shardingsphere.sharding.tables.t_order.database-strategy.inline.sharding-column=order_id
# 分库分片算法
spring.shardingsphere.sharding.tables.t_order.database-strategy.inline.algorithm-expression=ds-$->{order_id % 2}

为表设置分库策略,上边讲了 sharding-jdbc 它提供了四种分片策略,为快速搭建我们先以最简单的行内表达式分片策略来实现,在下一篇会介绍四种分片策略的详细用法和使用场景。

database-strategy.inline.sharding-column 属性中 database-strategy 为分库策略,inline 为具体的分片策略,sharding-column 代表分片健。

database-strategy.inline.algorithm-expression 是当前策略下具体的分片算法,ds-$->{order_id % 2} 表达式意思是 对 order_id字段进行取模分库,2 代表分片库的个数,不同的策略对应不同的算法,这里也可以是我们自定义的分片算法类。

1
2
3
4
5
6
7
8
9
# 分表策略
# 分表分片健
spring.shardingsphere.sharding.tables.t_order.table-strategy.inline.sharding-column=order_id
# 分表算法
spring.shardingsphere.sharding.tables.t_order.table-strategy.inline.algorithm-expression=t_order_$->{order_id % 3}
# 自增主键字段
spring.shardingsphere.sharding.tables.t_order.key-generator.column=order_id
# 自增主键ID 生成方案
spring.shardingsphere.sharding.tables.t_order.key-generator.type=SNOWFLAKE

分表策略 和 分库策略 的配置比较相似,不同的是分表可以通过 key-generator.column 和 key-generator.type 设置自增主键以及指定自增主键的生成方案,目前内置了SNOWFLAKE 和 UUID 两种方式,还能自定义的主键生成算法类,后续会详细的讲解。

1
2
# 绑定表关系
spring.shardingsphere.sharding.binding-tables= t_order,t_order_item

必须按相同分片健进行分片的表才能互为成绑定表,在联合查询时就能避免出现笛卡尔积查询。

1
2
# 配置广播表
spring.shardingsphere.sharding.broadcast-tables=t_config

广播表,开启 SQL解析日志,能清晰的看到 SQL分片解析的过程

1
2
# 是否开启 SQL解析日志
spring.shardingsphere.props.sql.show=true

3、验证分片

分片配置完以后我们无需在修改业务代码了,直接执行业务逻辑的增、删、改、查即可,接下来验证一下分片的效果。

我们同时向 t_order、t_order_item 表插入 5条订单记录,并不给定主键 order_id ,item_id 字段值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public String insertOrder() {

for (int i = 0; i < 4; i++) {
TOrder order = new TOrder();
order.setOrderNo("A000" + i);
order.setCreateName("订单 " + i);
order.setPrice(new BigDecimal("" + i));
orderRepository.insert(order);

TOrderItem orderItem = new TOrderItem();
orderItem.setOrderId(order.getOrderId());
orderItem.setOrderNo("A000" + i);
orderItem.setItemName("服务项目" + i);
orderItem.setPrice(new BigDecimal("" + i));
orderItemRepository.insert(orderItem);
}
return "success";
}

看到订单记录被成功分散到了不同的库表中, order_id 字段也自动生成了主键ID,基础的分片功能就完成了。

基础分片

那向广播表 t_config 中插入一条数据会是什么效果呢?

1
2
3
4
5
6
7
8
9
public String config() {

TConfig tConfig = new TConfig();
tConfig.setRemark("我是广播表");
tConfig.setCreateTime(new Date());
tConfig.setLastModifyTime(new Date());
configRepository.insert(tConfig);
return "success";
}

发现所有库中 t_config 表都执行了这条SQL,广播表和 MQ广播订阅的模式很相似,所有订阅的客户端都会收到同一条消息。

广播表

简单SQL操作验证没问通,接下来在试试复杂一点的联合查询,前边我们已经把 t_order 、t_order_item 表设为绑定表,直接联表查询执行一下。

关联查询

通过控制台日志发现,逻辑表SQL 经过解析以后,只对 t_order_0 和 t_order_item_0 表进行了关联产生一条SQL。

绑定表SQL

那如果不互为绑定表又会是什么情况呢?去掉 spring.shardingsphere.sharding.binding-tables试一下。

发现控制台解析出了 3条真实表SQL,而去掉 order_id 作为查询条件再次执行后,结果解析出了 9条SQL,进行了笛卡尔积查询。所以相比之下绑定表的优点就不言而喻了。

笛卡尔积查询

五、总结

以上对分库分表中间件 sharding-jdbc 的基础概念做了简单梳理,快速的搭建了一个分库分表案例,但这只是实践分库分表的第一步,下一篇我们会详细的介绍四种分片策略的具体用法和使用场景(必知必会),后边将陆续讲解自定义分布式主键、分布式数据库事务、分布式服务治理,数据脱敏等。

Mongo Decimal128 类型转换问题排查解决

Mongo Decimal128 类型转换问题排查解决

问题背景

java中对于精确小数,我们通常使用Bigdecimal进行存储,而mongo中是不存在Bigdecimal类型,对应的是Decimal128。
项目中使用mongo的地方,为了能够在插入mongo时将Bigdecimal转为Decimal128,查询时将Decimal128转回Bigdecimal,可以利用spring中的org.springframework.core.convert.converter.Converter。如下

1
2
3
4
5
6
7
8
@WritingConverter
public class BigDecimalToDecimal128Converter implements Converter<BigDecimal, Decimal128> {

@Override
public Decimal128 convert(BigDecimal bigDecimal) {
return new Decimal128(bigDecimal);
}
}
1
2
3
4
5
6
7
8
@ReadingConverter
public class Decimal128ToBigDecimalConverter implements Converter<Decimal128, BigDecimal> {

@Override
public BigDecimal convert(Decimal128 decimal128) {
return decimal128.bigDecimalValue();
}
}

在向容器中注入MongoTemplate时添加自定义的Converter后
我们就可以自由的对BigDecimal类型进行mongo存储和读取了。

真的没有问题了吗

当使用Map类型去接收Mongo查询结果时,上面的自定义Converter就会失效:

1
return mongoTemplate.find(query, JSONObject.class, TABLE_NAME + year);

![企业微信截图_16535653001637.png](https://object-storage.mihoyo.com:9000/plat-knowledge-management/prod/92d2afc9c6cac8dd3ca5391f046577f0_1653616202505.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=plat-knowledge-management-admin%2F20221130%2F%2Fs3%2Faws4_request&X-Amz-Date=20221130T124433Z&X-Amz-Expires=21600&X-Amz-SignedHeaders=host&X-Amz-Signature=9df161b1d3a1811f4b65f2e064e1a2084aae5d5b340c2c82da580e165c5f2226 =100%x)
经过代码调试,定位到MappingMongoConverter负责类型转换,其核心代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {
// 使用Map<String,Object>接收结果,target=Object.class 而Decimal128 是 Object子类,因此不进行转换
if (value == null || target == null || ClassUtils.isAssignableValue(target, value)) {
return value;
}

if (conversions.hasCustomReadTarget(value.getClass(), target)) {
return conversionService.convert(value, target);
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

由此发现使用Map<String,Object>接收结果时,target=Object.class ,而Decimal128属于Object子类,因此自定义Converter不生效

一波三折的解决方案-增加自定义Converter

由于Decimal128在下游系统中不存在,因此下游接收到数字被转换为Map类型且很难再次转换,因此准备从源头解决问题,修改类型转换逻辑,尝试将Decimal128转换为BigDecimal输出至下游,因此增加新的自定义Converter

1
2
3
4
5
6
7
8
9
10
@ReadingConverter
public class Decimal128ToObjectConverter implements Converter<Decimal128, Object> {
public Decimal128ToObjectConverter() {
}

@Override
public Object convert(Decimal128 decimal128) {
return decimal128.bigDecimalValue();
}
}

结论是不行的,原因为子类判断优先自定义Converter转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {

if (value == null || target == null || ClassUtils.isAssignableValue(target, value)) {
return value;
}
// 优先判断是否为子类,所以即使存在自定义Converter,也不会走该逻辑
if (conversions.hasCustomReadTarget(value.getClass(), target)) {
return conversionService.convert(value, target);
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

第二折 自定义MappingMongoConverter

我们自定义转换逻辑,重写核心转换逻辑,使得自定义Converter优先转换,是否还有问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {

if (value == null || target == null) {
return value;
}

if (conversions.hasCustomReadTarget(value.getClass(), target)) {
return conversionService.convert(value, target);
}
// 将 子类判断移动至自定义Converter之后
if (ClassUtils.isAssignableValue(target, value)) {
return value;
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

可以看到,当target=Object.class时,已经可以成功将Decimal128转换为BigDecimal,但是target=null时还是无法转换

第三折 解决嵌套转换问题

当对象存在嵌套时,MappingMongoConverter默认使用Map类型进行类型转换,此时target=null,造成了嵌套对象中的Decimal128对象并没有走到自定义Converter转换逻辑,因此再次修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {

if (value == null) {
return value;
}
// target == null 时,尝试寻找Object.class类型转换器
if (conversions.hasCustomReadTarget(value.getClass(), ObjectUtils.defaultIfNull(target, Object.class))) {
return conversionService.convert(value, ObjectUtils.defaultIfNull(target, Object.class));
}
// 如果没有匹配到 converter 则返回原始值
if (target == null || ClassUtils.isAssignableValue(target, value)) {
return value;
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

至此使用Map接收Decimal128将被正确的转换至BigDecimal类型

总结与收获

此方案需要改动两处代码

  1. 增加Decimal128ToObjectConverter
  2. 自定义MappingMongoConverter

排查后收获:

  1. 除非上游改动代价太大,尽量在上游解决问题,不要抛给下游处理
  2. mongo-spring对于Map等动态类型转换支持不完善,尽量定义准确类型数据结构接收结果,重写MappingMongoConverter不是上策
  3. 固定位数小数也可转换为整数存储,来绕过数字精度和类型转换问题
排查诡异的夏令时问题

排查诡异的夏令时问题

现象描述与复现

线上环境签署合同时发现某一员工生日为1988-04-15,但合同上显示的是1988-04-14缺少一天
合同生日问题

初步怀疑是夏令时问题,所以一开始排查方向重点放在数据存储和读取时的时区转换,走了不少弯路

Tip: 夏令时是什么

1986年4月,中国中央有关部门发出“在全国范围内实行夏时制的通知”,具体做法是:每年从四月中旬第一个星期日的凌晨2时整(北京时间),将时钟拨快一小时,即将表针由2时拨至3时,夏令时开始;到九月中旬第一个星期日的凌晨2时整(北京夏令时),再将时钟拨回一小时,即将表针由2时拨至1时,夏令时结束。

1986年至1991年,中华人民共和国在全国范围实行了六年夏令时,每年从4月中旬的第一个星期日2时整(北京时间)到9月中旬第一个星期日的凌晨2时整(北京夏令时)。除1986年因是实行夏令时的第一年,从5月4日开始到9月14日结束外,其它年份均按规定的时段施行。夏令时实施期间,将时间调快一小时。1992年4月5日后不再实行。

问题排查

第一步: 日期的来源

日期从数据库读取而来,类型为DATE类型,在数据库中保存的日期是1988-4-15,所以写入日期功能应该没有问题的

第二步: 线下环境确认

测试环境发现改问题能复现,并且在页面显示上也能看出问题,代码如下所示:

1
entryBasicInfo.setBirthday(new DateTime(entryInfoDetail.getBirthday(), DateTimeZone.forID("Asia/Shanghai")).plusHours(8).toDate());

可以看到页面展示的地方有+8小时的逻辑,也就是说读取到数据库时间为数据库中读取到的时间+8小时,代码如下所示:

1
2
3
4
5
6
/**
* 出生日期
*/
@NotNull(message = "出生日期不能为空", groups = EntryInfoV1.Group_EntryInfo.class)
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
private Date birthday;

可以看到json序列化指定为GMT+8时区,如果是夏令时时间会转为为不执行夏令时的时间(-1hour), 返回值如下:

1
2
3
4
5
{
entryBasicInfo: {
birthday: "1988-04-15 07:00:00"
}
}

结合页面的返回值和阅读页面展示生日的代码,可以推断从数据库读取到的时间为夏令时时间1988-04-15 0:00:00(CDT)

再详细解释下为啥:

1988-04-15 07:00:00(GMT+8) = 1988-04-15 00:00:00(CDT) + 8 hours

为啥要从页面接口看?

因为生成合同逻辑在测试环境不好调试,所以借助页面展示接口推测从数据库读取到的Date数据

第三步 调试验证判断

如何验证我们前面推断出来的结论呢?肯定是依靠调试啦!造好数据,打上断点:

2664c4efc0c6ede126b16614c5d05446.png

如我所料, 读取到的时间正是 1988-04-15 00:00:00(CDT) 下面继续追查,看在什么地方格式化为1988-04-14

第四步 本地与线上JDK版本不同引起的一段插曲

实际上在本人本地执行的结果如下,相同的输入下,本地的时间戳和远程调试的并不一样,很是困惑

a00ebef19599799f392dd238ef75b045.png

对比发现zoneOffsets信息不同

iShot2021-07-15 19.19.57.png

经过光闪闪提醒,jdk会更新时区和夏令时信息,于是打开jdk更新公告查询,地址:https://www.oracle.com/java/technologies/tzdata-versions.html

Timezone Tzdata Version Introduced in Main Changes in this Timezone Data Release
tzdata2018f 2018/10/18 11.0.2 8u201 7u211 Changes to past timestamps: China’s 1988 spring-forward transition was on April 17, not April 10. Its DST transitions in 1986/91 were at 02:00, not 00:00. Fix several issues for Macau before 1992.

8u202版本后,1988年夏令时开始于1988-04-17此前版本开始于1988-04-10,而1988-04-15正好落在此区间,因此本地与线上读取到的时间不同

再深究下不同JDK返回不同时间的原因:

找到mysql-connect-driver中解析DATE类型的代码:

1
2
3
4
5
6
7
8
9
10
11
12
>public <T> T decodeDate(byte[] bytes, int offset, int length, ValueFactory<T> vf) {
if (length == 0) {
return vf.createFromDate(0, 0, 0);
} else if (length != MysqlaConstants.BIN_LEN_DATE) {
throw new DataReadException(Messages.getString("ResultSet.InvalidLengthForType", new Object[] { length, "DATE" }));
}
int year = (bytes[offset] & 0xff) | ((bytes[offset + 1] & 0xff) << 8);
int month = bytes[offset + 2];
int day = bytes[offset + 3];
return vf.createFromDate(year, month, day);
}

可以发现DATE类型 不包含时区信息,因此构建出来的是日期是本地时间+默认时区,因此在8u202版本后返回为1988-04-15 00:00:00(GMT+8) ,在此版本前返回为1988-04-15 00:00:00(CDT) 这两个时间相差1小时

关于JDK中存放时区信息的位置:

位于TzdbZoneRulesProvider类型,在我本地的位置为:/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/tzdb.dat

1
2
3
4
5
6
7
8
9
10
11
12
>public TzdbZoneRulesProvider() {
try {
String libDir = System.getProperty("java.home") + File.separator + "lib";
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(
new File(libDir, "tzdb.dat"))))) {
load(dis);
}
} catch (Exception ex) {
throw new ZoneRulesException("Unable to load TZDB time-zone rules", ex);
}
>}

在本地可以替换该文件为旧版本从而可以在本地复现该问题

第四步 定位问题代码

一路搜索代码,找到合同中设置生日的地方:

1
2
//  出生日期
template.setPbBirthDate(new DateTime(eu.getBirthday()).toString("yyyy-MM-dd"));

需要注意的是DateTime属于第三方库joda time,基于上一段“小插曲”,我们大胆猜测下joda time是使用自己的时区信息还是读取jdk的时区信息?

铛铛铛揭晓下答案:

iShot2021-07-15 20.27.23.png

joda time使用自己的时区信息,不难推测,如果joda time的时区信息较新,在8u202版本前的jdk运行就会发生诡异的问题:

iShot2021-07-15 20.34.58.png

至此,我们已经完全查明少一天的前因后果

  1. 由于jdk版本较旧,时区信息较老, DATE类型数据1988-04-15被解析为 1988-04-15 0:00:00(CDT)
  2. 由于joda time时区信息较新,1988-04-15 0:00:00(CDT) 被解析为1988-04-14 23:00:00(GMT+8)

总结和收获

  1. Mysql DATE 类型不包含时区信息,使用配置的时区解析为java.sql.Date
  2. 不同JDK版本的时区信息和夏令时信息可能会发生变化,信息存储在$JAVA_HOME/lib/tzdb.dat文件中
  3. 8u201 以前版本认为中国时区在1988年时于04-10开始夏令时,8u201 后认为于04-17开始夏令时, 1988-04-10~1988-04-17期间的日期会因jdk不同而返回不同数据
  4. joda time的时区信息使用自己独立的数据, 在和jdk Date类型转换时有转换错误的隐患
  5. 升级jdk8版本到8u201以上,同时使用新版本joda time (其实就是为了保证两者时区最新且一致) 能避免上述隐患
接口延迟问题排查整理

接口延迟问题排查整理

问题接口流程梳理

根据报错信息,可以看出调用PersonnelSpi.getEmployeeInfo超时,且调用时长超过5s

1
com.netflix.hystrix.exception.HystrixRuntimeException: PersonnelSpi.getEmployeeInfo timed-out and fallback disabled

先梳理出PersonnelSpi.getEmployeeInfo执行流程

image-20210311152931902.png

  1. 先从网关获取oauthToken
  2. 通过网关调用人事cr/employee/info接口

目前可以得出结论是这两步耗时大于5s

排除人事接口问题

通过报警的data_trace字段,可以在fast7层中查询Proxy的access日志:

image-20210311153152323.png

查询结果类似这样:

企业微信截图_29f580c0-057f-4382-8cff-270e8f3a81ea.png

可以看到Proxy的accesslog中 Proxy返回cr/employee/info耗时5ms

image-20210311153925229.png

排除oauth/token接口问题

通过时间+clientIP 筛选方式,找到fast7层日志中 oauth/token的日志

比如13:20:15 调用了cr/employee/info接口

image-20210311154317930.png

通过时间+clientIP+接口筛选,找到oauth/token的日志

image-20210311154458147.png

从中可以得出两个结论:

  1. o auth/token接口耗时6ms
  2. o auth/tocken 和 cr/employee/info接口访问延迟在1s以内

如图,绿色部分是有日志证明没有问题的部分

image-20210311155024925.png

检查DNS问题

从上图可以看到,出现问题的部分只可能出现在sm-ps-server解析域名过程中

查看jk上DNS监控发现:idc-aroute.ke.com解析存在5s超时情况,但是发生超时时间和报错时间不一致

image-20210311155449332.png

查看代码发现,jk的dns监控并不是调用接口解析域名耗时,而是定时任务去检查域名解析耗时,所以报错时DNS监控数据正常并不说明当时DNS解析没有超时

通过DNS监控可以得出结论: DNS解析超时5s的情况存在,并且会影响oauth client真实请求延迟5s,并导致接口超时

结论

  1. 优化oauthclient, 减少调用auth/token接口次数可以减少超时情况发生
  2. 解决在容器环境中DNS解析超时问题

更新

networkaddress.cache.ttl (default: -1)
Specified in java.security to indicate the caching policy for successful
name lookups from the name service. The value is specified as as integer
to indicate the number of seconds to cache the successful lookup.
A value of -1 indicates “cache forever”.

networkaddress.cache.negative.ttl (default: 10)
Specified in java.security to indicate the caching policy for un-successful
name lookups from the name service. The value is specified as as integer to
indicate the number of seconds to cache the failure for un-successful lookups.
A value of 0 indicates “never cache”. A value of -1 indicates “cache forever”.

若DNS解析失败,会放入Negative Cache,默认缓存10秒(可调整/禁用),后续DNS解析直接返回UnknownHostException。也就是说,极端情况下,DNS解析失败会导致10秒不可用。所以建议禁用Negative Cache。

数据库数据迁移方案

数据库数据迁移方案

更换数据库这个事儿,是一个非常大的技术挑战,因为我们需要保证整个迁移过程中,既不能长时间停服,也不能丢数据。

实际上,无论是新版本的程序,还是新的数据库,即使我们做了严格的验证测试,做了高可用方案,刚刚上线的系统,它的稳定性总是没有那么好的,需要一个磨合的过程,才能逐步达到一个稳定的状态,这是一个客观规律。这个过程中一旦出现故障,如果不能及时恢复,造成的损失往往是我们承担不起的。

所以我们在设计迁移方案的时候,一定要做到,每一步都是可逆的。要保证,每执行一个步骤后,一旦出现问题,能快速地回滚到上一个步骤。这是很多同学在设计这种升级类技术方案的时候,容易忽略的问题。

第一步-复制旧库数据

首先要做的就是,把旧库的数据复制到新库中。对于大规模数据可以使用自增字段(自增主键/创建时间)作为复制的区间,在业务低谷期分批复制数据到新库中。

第二步-同步数据

因为旧库还在服务线上业务,所以不断会有数据写入旧库,我们不仅要往新库复制数据,还要保证新旧两个库的数据是实时同步的。所以,我们需要用一个同步程序来实现新旧两个数据库实时同步。

我们可以使用 Binlog 实时同步数据。如果源库不是 MySQL 的话,就麻烦一点儿,但也可以参考复制状态机理论来实现。这一步不需要回滚,原因是,只增加了一个新库和一个同步程序,对系统的旧库和程序都没有任何改变。即使新上线的同步程序影响到了旧库,只要停掉同步程序就可以了。

第三步-双写

然后,我们需要改造一下业务,业务逻辑部分不需要变,DAO 层需要做如下改造:

  1. 支持双写新旧两个库,并且预留热切换开关,能通过开关控制三种写状态:只写旧库、只写新库和同步双写。
  2. 支持读新旧两个库,同样预留热切换开关,控制读旧库还是新库。

然后上线新版的业务服务,这个时候业务服务仍然是只读写旧库,不读写新库。让这个新版的服务需要稳定运行至少一到二周的时间,期间除了验证新版服务的稳定性以外,还要验证新旧两个库中的数据是否是一致的。这个过程中,如果新版服务有问题,可以立即下线新版服务,回滚到旧版本的服务。

稳定一段时间之后,就可以开启服务的双写开关了。开启双写开关的同时,需要停掉同步程序。这里面有一个问题需要注意一下,就是这个双写的业务逻辑,一定是先写旧库,再写新库,并且以写旧库的结果为准。旧库写成功,新库写失败,返回写成功,但这个时候要记录日志,后续我们会用到这个日志来验证新库是否还有问题。旧库写失败,直接返回失败,就不写新库了。这么做的原因是,不能让新库影响到现有业务的可用性和数据准确性。上面这个过程如果出现问题,可以关闭双写,回滚到只读写旧库的状态。

第四步-对比补偿

切换到双写之后,新库与旧库的数据可能会存在不一致的情况,原因有两个:一是停止同步程序和开启双写,这两个过程很难做到无缝衔接,二是双写的策略也不保证新旧库强一致,这时候我们需要上线一个对比和补偿的程序,这个程序对比旧库最近的数据变更,然后检查新库中的数据是否一致,如果不一致,还要进行补偿。

开启双写后,还需要至少稳定运行至少几周的时间,并且期间我们要不断地检查,确保不能有旧库写成功,新库写失败的情况出现。对比程序也没有发现新旧两个库的数据有不一致的情况,这个时候,我们就可以认为,新旧两个库的数据是一直保持同步的。

第五步-流量切换

接下来就可以用类似灰度发布的方式,把读请求一点儿一点儿地切到新库上。同样,期间如果出问题的话,可以再切回旧库。全部读请求都切换到新库上之后,这个时候其实读写请求就已经都切换到新库上了,实际的切换已经完成了,但还有后续的收尾步骤。

第六步-下线历史逻辑

再稳定一段时间之后,就可以停掉对比程序,把服务的写状态改为只写新库。到这里,旧库就可以下线了。注意,整个迁移过程中,只有这个步骤是不可逆的。但是,这步的主要操作就是摘掉已经不再使用的旧库,对于在用的新库并没有什么改变,实际出问题的可能性已经非常小了。

到这里,我们就完成了在线更换数据库的全部流程。双写版本的服务也就完成了它的历史使命,可以在下一次升级服务版本的时候,下线双写功能。

如何实现对比和补偿程序?

在上面的整个切换过程中,如何实现这个对比和补偿程序,是整个这个切换设计方案中的一个难点。这个对比和补偿程序的难度在于,我们要对比的是两个都在随时变换的数据库中的数据。这种情况下,我们没有类似复制状态机这样理论上严谨实际操作还很简单的方法,来实现对比和补偿。但还是可以根据业务数据的实际情况,来针对性地实现对比和补偿,经过一段时间,把新旧两个数据库的差异,逐渐收敛到一致。

像订单这类时效性强的数据,是比较好对比和补偿的。因为订单一旦完成之后,就几乎不会再变了,那我们的对比和补偿程序,就可以依据订单完成时间,每次只对比这个时间窗口内完成的订单。补偿的逻辑也很简单,发现不一致的情况后,直接用旧库的订单数据覆盖新库的订单数据就可以了。

这样,切换双写期间,少量不一致的订单数据,等到订单完成之后,会被补偿程序修正。后续只要不是双写的时候,新库频繁写入失败,就可以保证两个库的数据完全一致。

比较麻烦的是更一般的情况,比如像商品信息这类数据,随时都有可能会变化。如果说数据上有更新时间,那我们的对比程序可以利用这个更新时间,每次在旧库取一个更新时间窗口内的数据,去新库上找相同主键的数据进行对比,发现数据不一致,还要对比一下更新时间。如果新库数据的更新时间晚于旧库数据,那可能是对比期间数据发生了变化,这种情况暂时不要补偿,放到下个时间窗口去继续对比。另外,时间窗口的结束时间,不要选取当前时间,而是要比当前时间早一点儿,比如 1 分钟前,避免去对比正在写入的数据。如果数据连时间戳也没有,那只能去旧库读取 Binlog,获取数据变化,然后去新库对比和补偿。

有一点需要说明的是,上面这些方法,如果严格推敲,都不是百分之百严谨的,都不能保证在任何情况下,经过对比和补偿后,新库的数据和旧库就是完全一样的。但是,在大多数情况下,这些实践方法还是可以有效地收敛新旧两个库的数据差异,你可以酌情采用。

小结

设计在线切换数据库的技术方案,首先要保证安全性,确保每一个步骤一旦失败,都可以快速回滚。此外,还要确保迁移过程中不丢数据,这主要是依靠实时同步程序和对比补偿程序来实现。

我把这个复杂的切换过程的要点,按照顺序总结成下面这个列表:

  1. 上线同步程序,从旧库中复制数据到新库中,并实时保持同步;
  2. 上线双写服务,只读写旧库;
  3. 开启双写,同时停止同步程序;
  4. 开启对比和补偿程序,确保新旧数据库数据完全一样;
  5. 逐步切量读请求到新库上;
  6. 下线对比补偿程序,关闭双写,读写都切换到新库上;
  7. 下线旧库和服务的双写功能。
排查docker oomkillded问题

排查docker oomkillded问题

年前docker oomkilled 问题一直在困扰我们项目组,大致现象为java堆Xmx配置了6G,但运行一段时间后常驻内存RSS从5G逐渐增长到8G容器阈值,最后报出om killed 之后重启。因为我们业务对内存需求不是很迫切,所以占用8G内存明显不合理,所以之后有了一场漫长的排查问题之旅。

基础中的基础-JVM内存模型

开始逐步对堆外内存进行排查,首先了解一下JVM内存模型。根据JVM规范,JVM运行时数据区共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
内存模型

  • PC 寄存器,也叫程序计数器。可以看成是当前线程所执行的字节码的行号指示器。不是重点。

  • 虚拟机栈,描述Java方法执行的内存区域,它是线程私有的,栈帧在整个JVM体系中的地位颇高,包括局部变量表、操作栈、动态连接、方法返回地址等。当申请不到空间时,会抛出 OutOfMemoryError。

  • 本地方法栈,和虚拟机栈实现的功能与抛出异常几乎相同。

  • 堆内存。堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。

  • Metaspace(元空间)在JDK 1.8开始,方法区实现采用Metaspace代替,这些元数据信息直接使用本地内存来分配。元空间与永久代之间最大的区别在于:元空间不属于JVM使用的内存,而是使用(进程中的)直接内存。当申请不到空间时会抛出 OutOfMemoryError。

直接内存

java 8下是指除了Xmx设置的java堆外,java进程使用的其他内存。主要包括:DirectByteBuffer分配的内存,JNI里分配的内存,线程栈分配占用的系统内存,jvm本身运行过程分配的内存,codeCache,metaspace元数据空间。

JVM监控分析

2.png

可以看到重启前堆内存、栈内存、元空间、直接内存占用空间都没有异常,多数问题通过监控就能定位大致方向,可惜这次监控大法没有生效,怀疑是JVM问题转向JVM原生内存使用方向排查。

使用NMT排查JVM原生内存使用

Native Memory Tracking(NMT)使用

NMT是Java7U40引入的HotSpot新特性,可用于监控JVM原生内存的使用,但比较可惜的是,目前的NMT不能监控到JVM之外或原生库分配的内存。java进程启动时指定开启NMT(有一定的性能损耗),输出级别可以设置为“summary”或“detail”级别。如:

1
2
-XX:NativeMemoryTracking=summary 或者 
-XX:NativeMemoryTracking=detail

开启后,通过jcmd可以访问收集到的数据。

1
jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff 

如:jcmd 1 VM.native_memory,输出如下:

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Native Memory Tracking:

Total: reserved=12259645KB(保留内存), committed=11036265KB (提交内存)
堆内存使用情况,保留内存和提交内存和Xms、Xmx一致,都是8G。
- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)
用于存储类元数据信息使用到的原生内存,总共12045个类,整体实际使用了79M内存。
- Class (reserved=1119963KB, committed=79751KB)
(classes #12045)
(malloc=1755KB #29277)
(mmap: reserved=1118208KB, committed=77996KB)
总共2064个线程,提交内存是2.1G左右,一个线程1M,和设置Xss1m相符。
- Thread (reserved=2130294KB, committed=2130294KB)
(thread #2064)
(stack: reserved=2120764KB, committed=2120764KB)
(malloc=6824KB #10341)
(arena=2706KB #4127)
JIT的代码缓存,12045个类JIT编译后代码缓存整体使用79M内存。
- Code (reserved=263071KB, committed=79903KB)
(malloc=13471KB #15191)
(mmap: reserved=249600KB, committed=66432KB)
GC相关使用到的一些堆外内存,比如GC算法的处理锁会使用一些堆外空间。118M左右。
- GC (reserved=118432KB, committed=118432KB)
(malloc=93848KB #453)
(mmap: reserved=24584KB, committed=24584KB)
JAVA编译器自身操作使用到的一些堆外内存,很少。
- Compiler (reserved=975KB, committed=975KB)
(malloc=844KB #1074)
(arena=131KB #3)
Internal:memory used by the command line parser, JVMTI, properties等。
- Internal (reserved=117158KB, committed=117158KB)
(malloc=117126KB #44857)
(mmap: reserved=32KB, committed=32KB)
Symbol:保留字符串(Interned String)的引用与符号表引用放在这里,17M左右
- Symbol (reserved=17133KB, committed=17133KB)
(malloc=13354KB #145640)
(arena=3780KB #1)
NMT本身占用的堆外内存,4M左右
- Native Memory Tracking (reserved=4402KB, committed=4402KB)
(malloc=396KB #5287)
(tracking overhead=4006KB)
不知道啥,用的很少。
- Arena Chunk (reserved=272KB, committed=272KB)
(malloc=272KB)
其他未分类的堆外内存占用,100M左右。
- Unknown (reserved=99336KB, committed=99336KB)
(mmap: reserved=99336KB, committed=99336KB)
  • 保留内存(reserved):reserved memory 是指JVM 通过mmaped PROT_NONE 申请的虚拟地址空间,在页表中已经存在了记录(entries),保证了其他进程不会被占用,且保证了逻辑地址的连续性,能简化指针运算。
  • 提交内存(commited):committed memory 是JVM向操做系统实际分配的内存(malloc/mmap),mmaped PROT_READ | PROT_WRITE,仍然会page faults,但是跟 reserved 不同,完全内核处理像什么也没发生一样。

这里需要注意的是:由于malloc/mmap的lazy allocation and paging机制,即使是commited的内存,也不一定会真正分配物理内存。

malloc/mmap is lazy unless told otherwise. Pages are only backed by physical memory once they’re accessed.

Tips:由于内存是一直在缓慢增长,因此在使用NMT跟踪堆外内存时,一个比较好的办法是,先建立一个内存使用基线,一段时间后再用当时数据和基线进行差别比较,这样比较容易定位问题。

1
jcmd 1 VM.native_memory baseline

同时pmap看一下物理内存的分配,RSS占用了10G。

1
pmap -x 1 | sort -n -k3

3.jpg

运行一段时间后,做一下summary级别的diff,看下内存变化,同时再次pmap看下RSS增长情况。

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
jcmd 1 VM.native_memory summary.diff
Native Memory Tracking:

Total: reserved=13089769KB +112323KB, committed=11877285KB +117915KB

- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)

- Class (reserved=1126527KB +2161KB, committed=85771KB +2033KB)
(classes #12682 +154)
(malloc=2175KB +113KB #37289 +2205)
(mmap: reserved=1124352KB +2048KB, committed=83596KB +1920KB)

- Thread (reserved=2861485KB +94989KB, committed=2861485KB +94989KB)
(thread #2772 +92)
(stack: reserved=2848588KB +94576KB, committed=2848588KB +94576KB)
(malloc=9169KB +305KB #13881 +460)
(arena=3728KB +108 #5543 +184)

- Code (reserved=265858KB +1146KB, committed=94130KB +6866KB)
(malloc=16258KB +1146KB #18187 +1146)
(mmap: reserved=249600KB, committed=77872KB +5720KB)

- GC (reserved=118433KB +1KB, committed=118433KB +1KB)
(malloc=93849KB +1KB #487 +24)
(mmap: reserved=24584KB, committed=24584KB)

- Compiler (reserved=1956KB +253KB, committed=1956KB +253KB)
(malloc=1826KB +253KB #2098 +271)
(arena=131KB #3)

- Internal (reserved=203932KB +13143KB, committed=203932KB +13143KB)
(malloc=203900KB +13143KB #62342 +3942)
(mmap: reserved=32KB, committed=32KB)

- Symbol (reserved=17820KB +108KB, committed=17820KB +108KB)
(malloc=13977KB +76KB #152204 +257)
(arena=3844KB +32 #1)

- Native Memory Tracking (reserved=5519KB +517KB, committed=5519KB +517KB)
(malloc=797KB +325KB #9992 +3789)
(tracking overhead=4722KB +192KB)

- Arena Chunk (reserved=294KB +5KB, committed=294KB +5KB)
(malloc=294KB +5KB)

- Unknown (reserved=99336KB, committed=99336KB)
(mmap: reserved=99336KB, committed=99336KB

4.jpg

发现这段时间pmap看到的RSS增长了3G多,但NMT观察到的内存增长了不到120M,还有大概2G多常驻内存不知去向,因此也基本排除了由于JVM自身管理的堆外内存的嫌疑。

gdb分析内存块内容

上面提到使用pmap来查看进程的内存映射,pmap命令实际是读取了/proc/pid/maps和/porc/pid/smaps文件来输出。发现一个细节,pmap取出的内存映射发现很多64M大小的内存块。这种内存块逐渐变多且占用的RSS常驻内存也逐渐增长到reserved保留内存大小,内存增长的2G多基本上也是由于这些64M的内存块导致的,因此看一下这些内存块里具体内容。

pmap -x 1看一下实际内存分配情况:
5.jpg

找一块内存块进行dump:

1
gdb --batch --pid 1 -ex "dump memory a.dump 0x7fd488000000 0x7fd488000000+56124000"

简单分析一下内容,发现绝大部分是乱码的二进制内容,看不出什么问题。
strings a.dump | less
或者: hexdump -C a.dump | less
或者: view a.dump

没啥思路的时候,随便搜了一下发现貌似很多人碰到这种64M内存块的问题,了解到glibc的内存分配策略在高版本有较大调整:

从glibc 2.11(为应用系统在多核心CPU和多Sockets环境中高伸缩性提供了一个动态内存分配的特性增强)版本开始引入了per thread arena内存池,Native Heap区被打散为sub-pools ,这部分内存池叫做Arena内存池。也就是说,以前只有一个main arena,目前是一个main arena(还是位于Native Heap区) + 多个per thread arena,多个线程之间不再共用一个arena内存区域了,保证每个线程都有一个堆,这样避免内存分配时需要额外的锁来降低性能。main arena主要通过brk/sbrk系统调用去管理,per thread arena主要通过mmap系统调用去分配和管理。

一个32位的应用程序进程,最大可创建 2 CPU总核数个arena内存池(MALLOC_ARENA_MAX),每个arena内存池大小为1MB,一个64位的应用程序进程,最大可创建 8 CPU总核数个arena内存池(MALLOC_ARENA_MAX),每个arena内存池大小为64MB

ptmalloc2内存分配和释放

当某一线程需要调用 malloc()分配内存空间时, 该线程先查看线程私有变量中是否已经存在一个分配区,如果存在, 尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败, 该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么 malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。用户 free 掉的内存并不是都会马上归还给系统,ptmalloc2 会统一管理 heap 和 mmap 映射区域中的空闲的chunk,当用户进行下一次分配请求时, ptmalloc2 会首先试图在空闲的chunk 中挑选一块给用户,这样就避免了频繁的系统调用,降低了内存分配的开销。

ptmalloc2的内存收缩机制

业务层调用free方法释放内存时,ptmalloc2先判断 top chunk 的大小是否大于 mmap 收缩阈值(默认为 128KB),如果是的话,对于主分配区,则会试图归还 top chunk 中的一部分给操作系统。但是最先分配的 128KB 空间是不会归还的,ptmalloc 会一直管理这部分内存,用于响应用户的分配 请求;如果为非主分配区,会进行 sub-heap 收缩,将 top chunk 的一部分返回给操 作系统,如果 top chunk 为整个 sub-heap,会把整个 sub-heap 还回给操作系统。做 完这一步之后,释放结束,从 free() 函数退出。可以看出,收缩堆的条件是当前 free 的 chunk 大小加上前后能合并 chunk 的大小大于 64k,并且要 top chunk 的大 小要达到 mmap 收缩阈值,才有可能收缩堆。

ptmalloc2的mmap分配阈值动态调整

M_MMAP_THRESHOLD 用于设置 mmap 分配阈值,默认值为 128KB,ptmalloc 默认开启 动态调整 mmap 分配阈值和 mmap 收缩阈值。当用户需要分配的内存大于 mmap 分配阈值,ptmalloc 的 malloc()函数其实相当于 mmap() 的简单封装,free 函数相当于 munmap()的简单封装。相当于直接通过系统调用分配内存, 回收的内存就直接返回给操作系统了。因为这些大块内存不能被 ptmalloc 缓存管理,不能重用,所以 ptmalloc 也只有在万不得已的情况下才使用该方式分配内存。

如何优化解决
三种方案:

**第一种:*控制分配区的总数上限。默认64位系统分配区数为:cpu核数8,如当前环境40核系统分配区数为320个,每个64M上限的话最多可达20G,限制上限后,后续不够的申请会直接走mmap分配和munmap回收,不会进入ptmalloc2的buffer池。
所以第一种方案调整一下分配池上限个数到4:

1
export MALLOC_ARENA_MAX=4

**第二种:**之前降到ptmalloc2默认会动态调整mmap分配阈值,因此对于较大的内存请求也会进入ptmalloc2的内存buffer池里,这里可以去掉ptmalloc的动态调整功能。可以设置 M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一个。这里可以固定分配阈值为128K,这样超过128K的内存分配请求都不会进入ptmalloc的buffer池而是直接走mmap分配和munmap回收(性能上会有损耗):

1
2
3
4
export MALLOC_MMAP_THRESHOLD_=131072
export MALLOC_TRIM_THRESHOLD_=131072
export MALLOC_TOP_PAD_=131072
export MALLOC_MMAP_MAX_=65536

**第三种:**使用tcmalloc来替代默认的ptmalloc2。google的tcmalloc提供更优的内存分配效率,性能更好,ThreadCache会阶段性的回收内存到CentralCache里。 解决了ptmalloc2中arena之间不能迁移导致内存浪费的问题。

总结收获

  • 定位问题,一定要了解问题的领域范围,在这次排查中,定位OOM问题领域顺序就是 jvm内存 -> jvm内部内存 -> 进程内存。

  • 操作系统知识不能丢,扎实的基础知识可以节省非常多百度的时间和推理问题的时间

  • 知识领域是相同的,比如这次的ptmalloc内存分配基本原理和metaspace内存分配、netty的内存分配原理非常相似

  • 当时排查问题时因为已经定位到是内存分配问题,所以没有留下问题排查中间过程的相关数据。最近偶然看到一篇博客的记录和我的经历极为相似,于是我参考博客和自己的排查经验整合了这篇排查问题记录。结果和过程都很重要,只有结果,没有过程容易招致他人的不理解,能被人理解也是一门学问~

季度总结 如何管理自我时间

季度总结 如何管理自我时间

最近这个季度

最近比较忙,算了下自己可以利用的空闲时间,忙时一天可能只有1到3个小时空闲时间,甚至一天没有空余时间。一周大约只有30小时时间是可以利用的,如果算上玩手机时间只会更短,如果再把这些时间再浪费掉,可能最近半年的成长就只有一些项目经验而已了。所幸平常关注的博客和《软技能》这本书里有提及一些关于自我时间管理相关内容,我简单实践了2个月,分享下这方面的心得感悟。

计划和休息

《软技能》中作者是如何做计划的?

  • 季度计划 明确宏观目标,以及如何实现
  • 月计划 估算当月能完成多少工作量
  • 周计划 为每周安排必须完成的强制性任务
  • 日计划 排除干扰,按需调整
  • 休息 每隔一段时间休息,只做强制性任务

很惭愧,工作刚开始一两年我少有计划,单纯凭借好奇心学习到了不少东西。大约从去年写年度总结开始才刚刚做一些计划。目前我使用微软to-do跟踪自己的计划进度和deadline效果显著,治好了我的拖延症。

简单来说《软技能》中阐述的几个观点我感觉十分受益:

  1. 要有计划
  2. 完成计划时保持专注
  3. 使用番茄工作法可以保证保证一段时间的专注, 同时还可以确定自己的工作效率, 总结不足提高自身效率,从而帮助自己精确且高效的指定计划
  4. 只有你完成了计划的工作,接下来的休息时间才能安心

这边年读书情况

这半年看的书比较少,但是刷题和博客总结写的多了些

技术类:
《sql反模式》推荐, 应该叫数据库结构设计的最佳实践
《软技能,代码之外的生存指南》 有很多事比代码更重要的多,推荐

心理类:
《你的灯亮着吗》 解决问题前,先要搞清楚问题的本质。 一般般

[项目] 多角色权限展示数据的一种实现

[项目] 多角色权限展示数据的一种实现

多角色权限如果遇到不同角色能看到不同的列可以怎么做

  • 逐行读取

最简单的解决方法,实现简单。但是在微服务中调用接口次数太多,性能很差。

  • 批量读取

实现较复杂,但是性能好很多,下面主要介绍这种方法的思路

批量读取

以分页读取数据为例:

  1. 读取第一页数据,包含需要展示数据的id和所属权限(多个)

为什么需要所属权限这个字段呢? 因为决定能否看到这行是有你所拥有的所有权限决定的,而决定能否看到哪个列是由这行所拥有的权限决定的。

如何获取该行所拥有的权限呢,我的做法是分不同的权限查询结果通过union 组合起来

  1. 将第一页数据原始顺序保存, 然后按行拥有权限分组

记录原始顺序是因为后面分组后会打乱, 为什么要分组?分组后同样的查询才能聚合在一起,可以简化代码

  1. 根据权限分组多次查询所需要的字段,然后将查询结果合并

这里我使用的graphql来选择需要查询的字段

  1. 最后还原成原来的顺序

可以使用guava Ordering工具类方便生成Compartor

[]: https://blog.yamato.moe/2018/11/06/2018-11-06-biz/ “根据权限查询时避免角色切换的一种思路”
[]: https://blog.yamato.moe/2019/04/04/Mybatis%20ResultSetHandler_2019-04-04%20%E7%BB%AD/ “【片段】 Mybatis ResultSetHandler 实践-续”
[]: https://blog.yamato.moe/2019/01/09/Mybatis%20ResultSetHandler_2019-01-09/ “【片段】 Mybatis ResultSetHandler 实践”

[项目] 根据权限查询时避免角色切换遇到的坑

前情概要

1. 问题背景

使用多个角色查询列表时,会遇到两个维度的不同点:

  • 行维度:多个角色能够看到行的并集,sql需要多次查询取并集,之后还要去重分页排序
  • 列维度:如果不同角色可见列不同,计算出当前行能看到列的并集

举一个例子:

假设存在一个登录员工拥有两个角色:

  1. 长期激励负责人:能看到拥有长期激励的人(行维度),能看到基本信息和长期激励信息(列维度)
  2. 薪酬负责人:能看到低职级的人(行维度),能看到基本信息和薪酬信息(列维度)

那么,在列表中他能看见:

基本信息 薪酬信息 长期激励信息
低职级/无长期激励 x
低职级/长期激励
高职级/无长期激励 x x x
高职级/长期激励 x

2. 实际遇到的问题(困难重重)

基本思路已经在前期概要里介绍,本人已经实践了一段时间,挖了两个深坑正在解决中。

性能问题(已解决)

最开始的实现中数据是一条一条读取的,同时薪酬字段属于加密信息,使用了第三方微服务提供解密,读取字段多+解密字段多 导致了在百条分页的情况下接口在超时的边缘不断试探。。。

解决方案:

  • 合并查询sql,批量查询数据
  • 合并解密请求,批量调用解密微服务

因为之前为了方便我们解密使用了mybatis的TypeHandler做到字段隐式加解密,目前我们的做法是对于单条数据的加解密,还是保持原来的typeHandler做法,而对批量数据处理,重新写一套数据实体,同时使用mybatis的拦截器对查询的批量数据做批量解密的处理。具体做法可以参见我的另一片文章:【片段】 Mybatis ResultSetHandler 实践-续

批量查询带来的问题

批量查询返回的列表中列字段都是一致的,而我们的需求是不同的行能看见不同的列字段,把批量查询出来的列表直接返回是有问题的,这个问题因为疏忽导致了线上的一次故障。

所以目前的思路是先做一次数据批量预取,之后在对列字段做处理,隐藏掉不能看见的字段。

3. 总结

没有想到当时想解决权限查询时避免角色切换这个问题时会遇到这么多困难,想法是正确的,在实际执行时还是困难重重。值得欣慰的在最开始的时候思路和方向都是正确的,同时也把其中遇到的各种问题和心得记录了下来,经过层层积累,才到达现在的高度。

[]: https://blog.yamato.moe/2018/11/06/2018-11-06-biz/ “根据权限查询时避免角色切换的一种思路”
[]: https://blog.yamato.moe/2019/04/04/Mybatis%20ResultSetHandler_2019-04-04%20%E7%BB%AD/ “【片段】 Mybatis ResultSetHandler 实践-续”
[]: https://blog.yamato.moe/2019/01/09/Mybatis%20ResultSetHandler_2019-01-09/ “【片段】 Mybatis ResultSetHandler 实践”

[感悟] 2018我的所见所闻所感

1. 我就喜欢写代码行不行?

如果刚刚投入工作,工作还无法完全胜任,那我的建议是多写写代码,打怪升级完成自己的年度绩效。

如果已经能够胜任当前工作,下一步应该集中在提效上。这一步和之前自己积累的经验和知识密不可分,只有真正了解代码懂代码,才能从同龄人中的“熟练工”脱颖而出,两者的提效虽然结果一样,但是本质却完全不一样。

如果已经摆脱了熟练工的称号,实际上已经完成了自我提效,提前完成自己的本职任务,下一步可以计划推动团队的效率。

如果太过专注技术,专心自己的一亩三分田,相当于给自己设限成毕业两三年的水平。这种人应该被打上不胜任的标签,在寒冬中很容易被优化掉。专注技术还有一个误区,就是容易把“实施细节”和“技术”两者混淆,特别在软件行业“实施细节”很容易随时代改变,基本三年就会大变样,而“技术”类似于“知识”不会过时。看到这里大家可以自己思考下,自己学到的到底是“实施细节”还是真正的“技术”。

2.如何推动团队进步

恭喜你跨过了第一道坎,推动团队进步不是一个人的事,一般推动团队分为两部分:个人影响团队,团队自我驱动。

个人影响团队比较简单,就是把自我提效所积累的经验和知识共享给整个团队,完成的手段可以是博客分享,会议分享,文档分享。

团队自我驱动,实际上我把整个团队拟作了一个人,一个人找出别人的缺点很容易,但是找出一个团队的问题却没那么容易,同时也会受到公司和领导的局限,比如一些项目管理的领导就是二传手,只催你进度的那种,这时候就需要你主动找他讨论。

如何找到团队的缺点?可以通过下面两个大类的套路:管理手段和技术手段。

管理手段可以从知识管理、代码规范、需求分析三处着手:

知识管理: 建立知识库,避免重复的培训,重复的解答问题

代码规范:借助代码缺陷检查工具,具体到负责人提升代码规范

需求分析:避免低效,无效会议,避免各种妥协下产生的需求

技术手段比较简单:

重构升级:弃用老的架构,拥抱新技术,带领团队提升技术

内部造轮子:内部定制工具,帮助团队高效完成任务

找到了团队的缺点接下来可以制定度量,衡量团队的推进程度,可以从两个角度进行度量:

跟自己比较:比如这次做需求提测bug数减少,需求delay少了,满足需求不需要上线只要配置上线,等等

和别的团队比较:这个比较凶残,我也不知道用什么度量比,但是有的大公司就是这样做的。

3.总结与晋升

从发现缺点到最后得到成果完成团队推进目标,是时候写个ppt在领导面前吹一波了,这个就不用我教了。