Golang1.23之前版本复用timer

问题背景

高频执行Timer时,可以池化Timer优化内存分配,但是池化后发生了回收Timer的逻辑阻塞问题,到底时道德的沦丧还是?

1.23之前版本如何复用timer

旧版本timer.C是缓冲chan,导致一定要Stop()后,GC才能回收

reuse timer之前要确保排空chan,所以一开始我是这么写的:

1
2
3
if !t.Stop() {
<-t.C
}

可惜Stop返回false无法判断timer.C是否已经排空,如果排空,则代码必然阻塞,需要考虑到C是否为空两种情况

1
2
3
4
5
6
7
if !t.Stop() {
select {
case <-t.C:
default:
}
}

1.23之后版本如何复用timer

新版本的逻辑不需要考虑timer.C是否排空了

1
2
3
4
5
6
7
8
9
10
11
12
13

func GetTimer(d time.Duration) *time.Timer {
t := timerPool.Get().(*time.Timer)
_ = t.Stop()
t.Reset(d)
return t
}

func PutTimer(t *time.Timer) {
_ = t.Stop()
timerPool.Put(t)
}

SSL证书与ip关联探测

问题背景

SSL 证书本身不会直接 “泄露” IP,但服务器配置不当、证书公开日志、证书指纹匹配、SNI 探测这四类场景,会让攻击者通过证书信息反推出源站真实 IP,尤其在使用 CDN 隐藏源站时风险更高。

攻击者向任意公网 IP 的 443 端口发起 TLS 握手,服务器会返回 SSL 证书;证书里的CN/SAN 域名、指纹、颁发者可与目标域名关联,从而锁定该 IP 就是目标域名的源站。

四大泄露路径

  1. 直接 IP 访问返回证书
    场景:源站 443 端口开放,且未做默认站点隔离(Nginx/Apache 默认配置)。
    攻击手法:
1
2
# 向未知IP发起握手,获取证书
openssl s_client -connect 1.2.3.4:443 2>&1 | grep -E "subject=|issuer=|Verify"

结果:服务器返回默认站点证书,证书里有真实域名(CN/SAN)。攻击者批量扫 IP 段,就能建立 “IP ↔ 域名” 映射表。
典型:用 CDN 后,源站 IP 仍开放 443,且返回和 CDN 节点一样的证书 → 直接暴露。

  1. 证书透明日志(CT Logs)反查
    原理:Let’s Encrypt 等 CA 会公开所有签发证书(CT 日志),任何人可查。
    攻击手法:
    去 crt.sh、Censys、censys.io 搜目标域名,拿到证书详情(指纹、SAN、有效期)。
    再用证书指纹 / 公钥哈希,反向查哪些 IP 在使用这张证书。
    结果:直接得到源站 IP(因为源站必须部署证书才能提供 HTTPS)。

  2. 证书指纹 / 公钥匹配(绕过 CDN)
    原理:每张证书有唯一SHA256 指纹、公钥、序列号。
    攻击手法:
    先从 CDN 节点获取目标域名的证书指纹:

1
openssl s_client -connect example.com:443 | openssl x509 -noout -fingerprint -sha256

用 Censys/Shodan 等,搜索全球所有 IP 中,使用相同指纹 / 公钥的 443 端口。
结果:匹配到的 IP,大概率就是源站(CDN 节点通常用统一证书,与源站不同)。

  1. SNI 探测(精准定位)
    场景:源站做了默认站点隔离,但未拒绝带正确 SNI 的直接请求。
    攻击手法:
1
2
# 向IP发送带目标域名SNI的握手,看是否返回对应证书
openssl s_client -connect 1.2.3.4:443 -servername example.com 2>&1 | grep "subject="

结果:若返回 subject=example.com,则 1.2.3.4 就是源站 IP。

防御配置(Nginx 为主,必做)

  1. 拒绝 IP 直接访问(默认站点)
1
2
3
4
5
6
7
# 80端口:IP访问直接返回444(关闭连接)
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
return 444;
}
1
2
3
4
5
6
7
8
9
# 443端口:拒绝所有未匹配SNI的握手(Nginx 1.19.4+)
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on; # 核心:直接拒绝TLS握手,不返回任何证书
ssl_certificate /dev/null; # 占位,避免报错
ssl_certificate_key /dev/null;
}

效果:IP:443 直接被拒绝,不返回任何证书,无法被扫到。

  1. 源站仅允许 CDN IP 访问(防火墙 / 安全组)
1
2
3
# iptables 示例:仅允许CDN节点IP访问443
iptables -A INPUT -p tcp --dport 443 -s CDN_IP_RANGE -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j DROP

效果:即使知道源站 IP,非 CDN 请求也被防火墙拦截,无法建立连接。

  1. 源站与 CDN 使用不同证书
    做法:CDN 节点用一张证书,源站用另一张(不同指纹 / 公钥)。
    效果:攻击者无法通过证书指纹匹配找到源站。

  2. 关闭不必要端口、禁用泛域名解析
    源站只开放必要端口(如 443),不开放 80/22 等到公网。
    避免泛域名解析(*.example.com),防止子域名扫描泄露 IP。

接触DAT(DoubleArrayTrie)小记'

问题背景

最经典的字典树trie空间冗余太大了,设备只有64M内存实在顶不住,稍微学习了下其他字典树,DAT因为是第一个接触的,同时结构和理论很简洁,所以对它好感度较高,但这不意味着DAT是某种最优实现。

接触印象

优化冗余的一大方向就是去掉指针,将树结构映射成数组,DAT是此方向的佼佼者,DAT结构正如其名,将字典树映射成了两个数组base和check。去掉指针之后,DAT更加偏向一台记录状态的状态机,base负责记录当前状态转到下个状态的绝对数组下标,check负责记录状态转移是否有效。

其转移公式为t = base[p] + c, check[t] = p, 有部分实现将状态检查设计为 check[t] = base[p], 我还不能完全理解,可能在索引冲突时,在处理check数组时后者更加容易迁移数据。

关于DAT的一些优劣势

首先DAT的压缩率很高,base和check一个item各占用4byte,即使数组是稀疏数组,浪费的空间也极少。

DAT最擅长做的是精确匹配和前缀匹配,不擅长动态更新(实现复杂,涉及扩容和子孙数据迁移)和遍历数据(需要遍历所有可能状态)。

在构建DAT时,大部分时候还是使用静态构建,因为动态修改DAT非常容易造成索引冲突,解决冲突的实现和操作都较为复杂,同时需要注意,静态构建一般还是先构建成一颗传统字典树,然后按照排序构建DAT,因此程序的瞬时内存压力仍然存在,这方面应该存在优化方向。

自动化更新TLS证书

自动化更新TLS证书

问题背景

使用浏览器访问app域名时强制要求使用TLS加密,因此申请和域名绑定的TLS证书是必不可少的步骤。ZeroSSL,Let’s Encrypt都是免费提供证书的机构,但是申请的证书有效期仅仅只有90天,手工更新已经不太现实,定时自动化更新成为必然。

acme.sh工具实现了 acme 协议,可以从 ZeroSSL,Let’s Encrypt 等 CA 生成免费的证书,将程序运行在后台已经可以定时更新。但是现在运维部署已经全面容器化,可以直接定时启动容器更新证书,完成后释放容器,使用存储空间换取后台运行的cpu资源和内存还是划算的。

这里不详细介绍如何申请和更新证书,只专注自动化过程。

具体实现

首先需要一个有操作容器权限的账号,一个包含acme工具的镜像,创建完成的容器项目。在执行周期上,我选择在每月第一个平日的凌晨进行更新,因为在部署证书时nginx需要轮流重启

执行步骤

  • 启动容器
  • 续签证书
  • 部署证书
  • 清理容器

简单脚本示例,这里容器管理使用docker-compose,部署使用acme deploy-hook部署,实际按照集群场景具体实现

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
#/bin/bash

LOG_FILE="/volume3/docker/acme/acme_renewal_$(date +'%Y-%m-%d_%H-%M-%S').log"

{
echo "=== ACME 证书自动续签脚本开始 ==="
echo "时间: $(date)"
echo ""

echo "[1/4] 启动 acme 容器..."
docker-compose -f /volume3/docker/acme/docker-compose.yml up -d
sleep 2

echo "[2/4] 续签证书 (pooi.app)..."
docker exec acme.sh acme.sh --renew --force -d pooi.app
sleep 2

echo "[3/4] 部署证书到 Synology (含通配符)..."
docker exec acme.sh acme.sh --insecure --deploy --deploy-hook synology_dsm -d pooi.app -d *.pooi.app
sleep 2

echo "[4/4] 停止 acme 容器..."
docker-compose -f /volume3/docker/acme/docker-compose.yml down

echo ""
echo "=== 脚本执行完毕 ==="
echo "时间: $(date)"
} >> "$LOG_FILE" 2>&1

echo "📝 日志已保存到: $LOG_FILE"
在windows上安装Rust

在windows上安装Rust

问题背景

在windows系统上安装Rust需要C++ 编译工具,正常使用Visual Studio安装工具将消耗大量空间和时间下载安装组件,这里提供另外一种安装流程,以最快速度在新电脑上成功安装并运行Rust代码

再次安装时发现一篇英文文档,描述如何安装 build tools 可以参考(windows-msvc)[https://rust-lang.github.io/rustup/installation/windows-msvc.html]

安装流程

  1. 使用vs_BuildTools.exe安装C++ build tools,选择安装单个组件,选择MSVC v143 - VS 2022 C++ x64/x86 build tools (Latest)和Windows 11 SDK (10.0.22621.0)
  2. Rust官网下载rustup-init.exe 进行安装
  3. 使用rustup show来验证安装的组件信息

最终效果为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
rustup show
Default host: x86_64-pc-windows-msvc


installed toolchains
--------------------
stable-x86_64-pc-windows-msvc (active, default)

active toolchain
----------------
name: stable-x86_64-pc-windows-msvc
active because: it's the default toolchain
installed targets:
x86_64-pc-windows-msvc

最后小小吐槽

本地编译rust代码需要一台高性能电脑,并且最好在编译的同时干些别的事 :)

OIDC与Keycloak的奇妙冒险

OIDC与Keycloak的奇妙冒险

KeyCloak Client scopes

Client scopes 的Assigned type 有两个可选项:

  • Default - 不需要client指定scope name,默认返回scope值
  • Optional - 与Default相反,需要client指定scope name才返回指定scope值

client中的client scopes的创建时会引用realm配置,需要留意点在于Assigned type字段可以在client层级单独设置,创建时从realm层级继承,修改时与realm层级互相独立

Client scopse 的Include in token scope 也有两个可选项:

  • On - 返回scope值时,client scope中添加该scope name
  • Off - 返回scope值时,client scope中隐藏该scope name

验证 Signature verify

springboot oauth resource server中,配置spring.security.oauth2.resourceserver.jwt.jwk-set-uri可以通过公钥校验jwt签名, 这里框架没有打印日志,只能通过代码调试验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
org.springframework.security.oauth2.jwt.NimbusJwtDecoder#createJwt

try {
// Verify the signature
JWTClaimsSet jwtClaimsSet = this.jwtProcessor.process(parsedJwt, null);
Map<String, Object> headers = new LinkedHashMap<>(parsedJwt.getHeader().toJSONObject());
Map<String, Object> claims = this.claimSetConverter.convert(jwtClaimsSet.getClaims());
// @formatter:off
return Jwt.withTokenValue(token)
.headers((h) -> h.putAll(headers))
.claims((c) -> c.putAll(claims))
.build();
// @formatter:on
}

对于apisix网关,需要配置openid-connect插件对Signature校验

1
2
3
4
5
6
7
8
9
10
11
{
"openid-connect": {
"client_id": "client_id",
"client_secret": "client_secret",
"discovery": "https://keycloak.pooi.app/realms/pooi/.well-known/openid-configuration",
"bearer_only": true,
"ssl_verify": true,
"scope": "openid",
"use_jwks": true
}
}
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 (其实就是为了保证两者时区最新且一致) 能避免上述隐患
Eureka服务发现机制

Eureka服务发现机制

Eureka服务发现机制

1568279922476.png

注册: 服务提供者启动后发送注册请求到Eureka-Server,默认注册信息生效时长为30秒。

续约: 服务提供者定时发送心跳给Eureka-Service,默认30秒一次,以刷新注册信息过期时间。若服务提供方不能续约,eureka-server将会注销该微服务节点(默认三个心跳周期90s)

注销: 服务提供者停机时发送注销请求到Eureka-Server。

调用: 服务消费者定时从Eureka-Server拉取服务注册表,并刷新本地缓存,默认30秒一次。服务消费者的负载均衡器(如Ribbon)向Eureka-Client获取服务提供者信息后,即可向服务提供者发送HTTP请求。

Eureka缓存机制

1568280499563.png

读写缓存定时刷新信息到只读缓存。刷新间隔:eureka.responseCacheUpdateIntervalMs,默认为30秒。

是否启用只读缓存可配:eureka.shouldUseReadOnlyResponseCache,默认开启。

Client定时从Eureka-Server拉取注册表,刷新本地缓存。拉取频率:eureka.client.registry-fetch-interval-seconds,默认为30秒。

LoadBalancer定时同步Client里的服务列表。同步间隔:ribbon.ServerListRefreshInterval,默认为30秒。可优化为实时刷新

服务下线不主动通知,则依赖剔除任务清除过期数据的机制。相关参数:续约间隔:eureka.instance.lease-renewal-interval-in-seconds,默认为30秒;节点有效期:eureka.instance.lease-expiration-duration-in-seconds,默认为90秒;清理时间间隔:eureka.server.eviction-interval-timer-in-ms,默认为60秒。

Eureka缓存机制造成的问题

在Client未同步到服务提供方下线信息前,流量仍会请求到下线节点上,导致Client报错,影响上游服务稳定性

多级缓存造成的同步延迟:

eureka.responseCacheUpdateIntervalMs+eureka.client.registry-fetch-interval-seconds+ribbon.ServerListRefreshInterval

默认为90s

解决方案

  1. Server端 关闭eureka.shouldUseReadOnlyResponseCache 或 缩短eureka.responseCacheUpdateIntervalMs
  2. Client端 缩短eureka.client.registry-fetch-interval-seconds
  3. ribbon配置 优化为实时从Client获取
  4. 延迟关闭服务,等待未同步的Client同步完成