0%


title: Rust环境安装和初体验

date: 2023-12-25

updated: 2023-12-25


  1. 下载Rustup工具rustup-init.exe,点击运行,出现cmd命令行,提示需要安装C++环境(Rust需要C/C++的编译工具)

  2. 先安装Rust需要的C/C++环境,可以选择Visual Studio(占用体积特别大),也可以安装Mingw-w64(占用体积很小),这里选择安装Mingw-w64,下载完解压缩,将bin目录添加到环境变量PATH中,打开cmd,输入gcc -v,查看是否正常显示gcc相关信息

  3. 重新运行rustup-init.exe

    输入[3],不安装前提条件

    输入[2],自定义安装,接着输入每个提示末尾的内容,可以右键复制

    输入[1],默认安装,等待安装进度完成

  4. 重新打开cmd窗口,输入rustc -V验证Rust是否安装成功,输入cargo --version验证cargo是否安装成功(cargo是Rust 构建工具和包管理器)

  5. 使用cargo命令创建Rust项目

    1
    cargo new hello-rust
  6. 下载VSCode,安装插件rust-analyzerCodeLLDB(用于调试Rust)

  7. 使用VSCode打开刚刚创建的Rust项目

  8. 点击运行后报错

  9. 安装Visual C++ xxx Build Tools或者使用gnu工具链

    1
    2
    rustup toolchain install stable-x86_64-pc-windows-gnu
    rustup default stable-x86_64-pc-windows-gnu
  10. 重新运行Rust项目,程序正常运行并输出

作为JAVA开发者,一提到锁,我们第一时间想到的应该是synchronized关键字、ReentrantLock和juc包下的各种并发工具类。随着业务的增长和微服务的流行,系统由单体应用拆分为独立的模块,每个模块服务都运行在独立的JVM中,此时JDK提供的锁就显得力不从心了,于是分布式锁这样一把利器就应运而生了。

分布式锁的特性

  • 互斥

    同一时刻只能有一个客户端持有

  • 可重入

    同一个客户端获取到锁后可以再次加锁

  • 避免死锁

    通过设置过期时间或其他方式保证即使客户端崩溃也不会影响锁的释放

  • 加锁解锁同一人

    每个客户端只能释放自己加的锁

  • 容错

    允许若干个锁节点发生故障仍能正常加锁和解锁

分布式锁实现方式

  • 数据库

    select xxx for update

  • zookeeper

    临时顺序节点

  • redis

    setnx、lua脚本

使用Redis作为分布式锁

伪代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 生成分布式锁的key,使用业务id代替
lockKey = "lock:" + bizId;

// 使用setnx命令进行加锁,setnx即set if not exist,不存在则设置
result = redis.setnx(lockKey, value);

if (result == 1) {
// 加锁成功,设置过期时间
redis.expire(key, 10);

// 执行业务操作
doSomething();

// 释放锁
redis.del(lockKey);
}

存在的问题

  1. 加锁和设置过期时间不是原子操作,如果加锁成功但设置过期时间失败,则锁会一直存在
  2. 锁的过期时间是固定的,如果设置过小,业务耗时较久,锁会提前释放,被其他线程获取,造成业务混乱和异常;如果设置过大,业务操作发生异常,造成锁长时间无法释放且无法获取
  3. 可能存在误释放其他线程的锁,线程1加锁并设置过期时间成功,但是由于业务耗时较久,锁已经过期,所以线程2能够获取到锁并执行业务操作,此时线程1执行完业务后释放了本该属于线程2的锁,然后线程3又获取到了锁……很明显,这种情况页会造成业务的混乱和异常

如何解决

  1. Redis的set命令提供了nx和ex、px参数,相当于将setnx + expire合并为一个操作

    1
    2
    3
    // ex 过期时间单位为秒
    // px 过期时间单位为毫秒
    set key value nx ex|px time
  2. 通过类似TimerTask之类的定时工具类来对锁进行自动续期(Redisson中的看门狗机制),每隔一段时间进行检查,如果锁存在,重新设置过期时间保证业务能够正常完成

  3. 线程加锁时将线程id作为value存入Redis,解锁时判断锁中的value是否和当前线程id一致,一致则删除锁

    伪代码如下:

    1
    2
    3
    4
    5
    6
    7
    threadId = Threads.currentThread().getId();

    ......

    if (threadId == redis.get(lockKey)) {
    redis.del(lockKey);
    }

    这里仍然存在几个问题:

    • 上述删除锁一共三个操作,获取锁的value、判断value和线程id是否相等、删除锁,如果线程1在执行完前两步操作后锁刚好过期,此时线程2成功获取到锁,然后执行业务操作,接着线程1执行删除操作,就会释放掉线程2的锁。因此需要上述操作为原子性操作,可以使用lua脚本将三个操作合在一起执行
    • 在不同的JVM中,线程id是会重复的,因此不能只使用线程id作为锁的value,可以使用UUID或分布式ID(如使用雪花算法生成的id)

到这里,我们设计的分布式锁才算勉强满足我们的需求。

Redis部署方式带来的问题

  1. 上述的分布式锁在Redis单节点环境下是能够正常使用的,但是单节点部署注定无法提供高可用性
  2. Redis除了单节点部署外,还支持主从(哨兵)、分片集群两种部署方式,而这两种方式都是需要进行主从同步的,如果某个master节点在客户端获取锁后下线,而slave节点又未及时同步锁数据,经过选举后slave节点成为新的master节点,但是客户端锁数据已经丢失,其他客户端可以正常获取锁,这样会造成程序和业务上的混乱
  3. 为了解决上述问题,Redis作者提出了一种算法来保证即使在主从(哨兵)、分片集群环境下也能保证分布式锁的正常使用

Redlock算法

简单概括来说,将上述的操作在每个master节点上分别执行,如果在容忍时间内,至少有一半以上的master节点能够加锁成功,则认为分布式锁获取成功

详情查看官网

Redisson分布式锁简单使用

Redisson是一个方便我们连接和使用Redis的客户端,它提供了很多常用功能,其中就包括分布式锁。Redisson分布式锁支持单实例、主从、哨兵、分片集群等部署模式,详见wiki

  1. 创建SpringBoot Web项目,引入redisson依赖

    1
    2
    3
    4
    5
    <dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.16.7</version>
    </dependency>
  2. 在application.properties配置文件中添加redis连接信息

    1
    2
    3
    spring.redis.host=xxx.xxx.xxx.xxx
    spring.redis.port=6379
    spring.redis.password=password
  3. 编写Redisson配置类,创建RedissonClient对象实例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Configuration
    public class RedissonConfig {

    @Value("${spring.redis.host}")
    private String host;

    @Value("${spring.redis.port}")
    private String port;

    @Value("${spring.redis.password}")
    private String password;

    @Bean
    public RedissonClient redissonClient() {
    Config config = new Config();
    // 这里使用单实例模式
    config.useSingleServer().setAddress("redis://" + host + ":" + port).setPassword(password);

    return Redisson.create(config);
    }

    }
  4. 编写controller类

    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
    @Slf4j
    @RestController
    @RequestMapping(value = "/lottery")
    public class LotteryController {

    @Resource
    private RedissonClient redissonClient;

    @PostMapping(value = "/draw")
    public String draw() {
    RLock lock = redissonClient.getLock("prize:1001");

    try {
    if (!lock.tryLock()) {
    log.info("===> {} didn't get lock", Thread.currentThread().getName());

    return "离大奖只差一步,别灰心,下次还有机会!";
    }

    log.info("===> {} got lock", Thread.currentThread().getName());
    } finally {
    if (lock.isHeldByCurrentThread()) {
    log.info("===> {} unlock", Thread.currentThread().getName());
    lock.unlock();
    }
    }

    return "你就是天选之子!";
    }

    }
  5. 使用Jmeter工具模拟并发请求

  6. 查看Redis中分布式锁信息

  7. 查看控制台日志

可以看到,在并发场景下,Redisson分布式锁可以保证同一时刻只有一个线程能够获取锁,可以满足我们的业务需求。

源码分析

加锁源码

  • tryLock():==带过期时间参数的tryLock()和lock()方法逻辑和以下类似,多了重试和订阅/取消订阅channel的操作==

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public boolean tryLock() {
    return get(tryLockAsync());
    }

    public RFuture<Boolean> tryLockAsync() {
    return tryLockAsync(Thread.currentThread().getId());
    }

    public RFuture<Boolean> tryLockAsync(long threadId) {
    return tryAcquireOnceAsync(-1, -1, null, threadId);
    }
  • tryAcquireOnceAsync()

    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
    private RFuture<Boolean> tryAcquireOnceAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
    RFuture<Boolean> ttlRemainingFuture;

    // 如果自己设置了锁租借时间
    if (leaseTime != -1) {
    ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
    } else {
    // 没有设置锁租借时间,默认为30s
    ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
    TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_NULL_BOOLEAN);
    }

    ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
    if (e != null) {
    return;
    }

    // lock acquired
    if (ttlRemaining) {
    // 设置自定义锁租借时间
    if (leaseTime != -1) {
    internalLockLeaseTime = unit.toMillis(leaseTime);
    } else {
    // 未设置,开启锁续期定时任务(看门狗)
    scheduleExpirationRenewal(threadId);
    }
    }
    });
    return ttlRemainingFuture;
    }
  • tryLockInnerAsync():==加锁核心逻辑==

    解释一下lua脚本中的参数

    • KEYS[1] prize:1001 锁对应的key,即 Collections.singletonList(getRawName())
    • ARGV[1] 30000 锁过期时间,未设置则默认为30000
    • ARGV[2] d5f6-xxxxx:107 锁的field,即 getLockName(threadId),格式为 uuid:threadId
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    // 加锁逻辑:
    // 1.如果redis中不存在名为 prize:1001 的key,创建key为 prize:1001 的hash,并将field为 d5f646f3-c3ea-47d7-89fb-199d561f4084:107 的value值加一,最后设置过期时间,返回nil,表示加锁成功
    // 2.如果存在并且该key存在指定的field ,将field对应的value值加一并设置过期时间,返回nil,表示加锁成功
    // 3.如果key存在但不存在指定的field,返回锁的剩余过期时间,表示加锁失败

    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
    "if (redis.call('exists', KEYS[1]) == 0) then " +
    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
    "return nil; " +
    "end; " +
    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
    "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
    "return nil; " +
    "end; " +
    "return redis.call('pttl', KEYS[1]);",
    Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
    }

续约源码

  • scheduleExpirationRenewal()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // 需要设置过期续约的锁的集合
    private static final ConcurrentMap<String, ExpirationEntry> EXPIRATION_RENEWAL_MAP = new ConcurrentHashMap<>();

    protected void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    // 如果当前锁第一次设置过期续约,向EXPIRATION_RENEWAL_MAP中添加一个续约信息;如果不是第一次设置,返回已有的续约信息
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    // 如果不是第一次设置过期续约,将当前线程id添加到原续约信息的线程id集合中
    if (oldEntry != null) {
    oldEntry.addThreadId(threadId);
    } else {
    // 如果第一次设置,将当前线程id添加到刚创建的续约信息的线程id集合中
    entry.addThreadId(threadId);
    // 开启锁过期续约定时任务,即看门狗任务
    try {
    renewExpiration();
    } finally {
    // 如果检测到线程中断,取消续期
    if (Thread.currentThread().isInterrupted()) {
    cancelExpirationRenewal(threadId);
    }
    }
    }
    }
  • renewExpiration():==看门狗核心逻辑==

    Timeout是netty工具包下的一个关于定时任务的接口,它可以设置定时任务和定时信息

    TimerTask同上,它是定时任务的抽象接口

    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
    private void renewExpiration() {
    // 获取锁续约信息,不存在则返回
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
    return;
    }

    // 创建定时任务,周期为 internalLockLeaseTime/3 ,未设置锁租借时间,默认为 10s
    // Config.java: private long lockWatchdogTimeout = 30 * 1000;
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
    @Override
    public void run(Timeout timeout) throws Exception {
    // 获取锁续约信息(线程id+过期时间)
    ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ent == null) {
    return;
    }

    // 获取第一个添加的线程id
    Long threadId = ent.getFirstThreadId();
    if (threadId == null) {
    return;
    }

    // 执行锁续期lua脚本
    RFuture<Boolean> future = renewExpirationAsync(threadId);
    future.onComplete((res, e) -> {
    // 续期失败,删除续约信息
    if (e != null) {
    log.error("Can't update lock " + getRawName() + " expiration", e);
    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
    return;
    }
    // 续期成功后自己调用自己,即重复创建并执行看门狗任务
    if (res) {
    // reschedule itself
    renewExpiration();
    } else {
    // 续期失败后取消续期
    cancelExpirationRenewal(null);
    }
    });
    }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

    ee.setTimeout(task);
    }
  • renewExpirationAsync():==锁续期核心逻辑==

    lua脚本参数参考这里

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 续期逻辑:
    // 1.判断锁中指定的field是否存在,如果存在,使用pexpire指令重新设置过期时间(单位毫秒),返回1,表示续期成功
    // 2.如果指定的field不存在,返回0,表示续期失败

    protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
    "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
    "return 1; " +
    "end; " +
    "return 0;",
    Collections.singletonList(getRawName()),
    internalLockLeaseTime, getLockName(threadId));
    }
  • cancelExpirationRenewal():取消续约

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    protected void cancelExpirationRenewal(Long threadId) {
    // 获取续约信息
    ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (task == null) {
    return;
    }

    // 若线程id存在,则从续约信息的线程id集合中移除
    if (threadId != null) {
    task.removeThreadId(threadId);
    }

    // 如果线程id不存在或者续约信息中无线程id,取消定时任务和其关联,从过期续约集合中移除该续约信息
    if (threadId == null || task.hasNoThreads()) {
    Timeout timeout = task.getTimeout();
    if (timeout != null) {
    timeout.cancel();
    }
    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
    }
    }

解锁源码

  • unlock()

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public void unlock() {
    try {
    get(unlockAsync(Thread.currentThread().getId()));
    } catch (RedisException e) {
    if (e.getCause() instanceof IllegalMonitorStateException) {
    throw (IllegalMonitorStateException) e.getCause();
    } else {
    throw e;
    }
    }
    }
  • unlockAsync()

    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
    public RFuture<Void> unlockAsync(long threadId) {
    RPromise<Void> result = new RedissonPromise<>();
    // 实际解锁操作
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.onComplete((opStatus, e) -> {
    // 解锁完成后取消续约,这部分逻辑参考之前的解锁源码部分
    cancelExpirationRenewal(threadId);

    if (e != null) {
    result.tryFailure(e);
    return;
    }

    if (opStatus == null) {
    IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
    + id + " thread-id: " + threadId);
    result.tryFailure(cause);
    return;
    }

    result.trySuccess(null);
    });

    return result;
    }
  • unlockInnerAsync():==解锁核心逻辑==

    解释一下lua脚本中的参数

    • KEYS[1] prize:1001 锁对应的key,即 getRawName()
    • KEYS[2] redisson_lock__channel:{prize:1001} 锁的channel
    • ARGV[1] 0 锁的channel发布的信息
    • ARGV[2] 30000 锁过期时间
    • ARGV[3] d5f6-xxxxx:107 锁的field,即 getLockName(threadId),格式为 uuid:threadId
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 解锁逻辑:
    // 1.如果redis中不存在指定的key和field,返回nil,表示解锁成功(锁已经不存在了)
    // 2.如果存在,将field对应的value值减一,如果减一后的value值大于0,使用pexpire指令重新设置过期时间(单位毫秒),即重入次数减一,返回0
    // 3.如果减一后的value值小于或等于0,删除指定的key,发布解锁信息到指定的channel,订阅了该channel并设置了监听事件的客户端会执行回调和释放信号量,唤醒等待的线程重新获取锁(这部分内容下次我会详细写一篇文章进行分析),返回1

    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
    "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
    "return nil;" +
    "end; " +
    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
    "if (counter > 0) then " +
    "redis.call('pexpire', KEYS[1], ARGV[2]); " +
    "return 0; " +
    "else " +
    "redis.call('del', KEYS[1]); " +
    "redis.call('publish', KEYS[2], ARGV[1]); " +
    "return 1; " +
    "end; " +
    "return nil;",
    Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }

总结

​ 从零开始使用Redis作为分布式锁会遇到各种各样的问题,通过一步步的分析,我们慢慢摸索到了分布式锁的雏形,而Redlock算法就是为了解决这些问题而提出的。但是它仍然不是完美的,比如过渡依赖时钟、网络、设计过重等,具体可以网上搜索Redis作者Antirez和分布式系统专家Martin的论战。在实际使用时,应该根据我们的业务场景和需求来决定采用何种解决方案。

什么是ShardingSphere?

Apache ShardingSphere 是一款分布式的数据库生态系统, 可以将任意数据库转换为分布式数据库,并通过数据分片、弹性伸缩、加密等能力对原有数据库进行增强。 它主要由ShardingSphere-JDBC和ShardingSphere-Proxy两个可独立使用、也可混合使用的产品组成。

官网

什么是ShardingSphere-Proxy?

ShardingSphere-Proxy 定位为透明化的数据库代理端,通过实现数据库二进制协议,对异构语言提供支持。 目前提供 MySQL 和 PostgreSQL 协议,透明化数据库操作,对 DBA 更加友好。

  • 向应用程序完全透明,可直接当做 MySQL/PostgreSQL 使用;
  • 兼容 MariaDB 等基于 MySQL 协议的数据库,以及 openGauss 等基于 PostgreSQL 协议的数据库;
  • 适用于任何兼容 MySQL/PostgreSQL 协议的的客户端,如:MySQL Command Client, MySQL Workbench, Navicat 等。

什么是ShardingSphere-JDBC?

ShardingSphere-JDBC 定位为轻量级 Java 框架,在 Java 的 JDBC 层提供的额外服务。 它使用客户端直连数据库,以 jar 包形式提供服务,无需额外部署和依赖,可理解为增强版的 JDBC 驱动,完全兼容 JDBC 和各种 ORM 框架。

  • 适用于任何基于 JDBC 的 ORM 框架,如:JPA, Hibernate, Mybatis, Spring JDBC Template 或直接使用 JDBC;
  • 支持任何第三方的数据库连接池,如:DBCP, C3P0, BoneCP, HikariCP 等;
  • 支持任意实现 JDBC 规范的数据库,目前支持 MySQL,PostgreSQL,Oracle,SQLServer 以及任何可使用 JDBC 访问的数据库。

准备工作

  1. 创建Spring Boot项目,引入MySQL、ShardingSphere-JDBC等依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    <dependency>
    <groupId>org.apache.shardingsphere</groupId>
    <artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
    <version>5.1.2</version>
    </dependency>

    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
    </dependency>

    <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
    <version>8.0.28</version>
    </dependency>

    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.1.22</version>
    </dependency>
  2. 创建若干张表,根据表创建controller、service、dao等代码

使用ShardingSphere-JDBC进行读写分离

  1. 数据库:master、slave;表:t_user

  2. 创建application.properties作为基础配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    server.port=9090

    # 使用读写分离配置文件
    spring.profiles.active=separate

    spring.application.name=demo

    mybatis.mapper-locations=classpath*:**/sql/*.xml

    # 打印sql日志
    spring.shardingsphere.props.sql-show=true
  3. 创建application-separate.properties作为读写分离配置文件

    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
    # 数据源名称
    spring.shardingsphere.datasource.names=master,slave

    # 数据源1(主数据源)
    spring.shardingsphere.datasource.master.type=com.alibaba.druid.pool.DruidDataSource
    spring.shardingsphere.datasource.master.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.shardingsphere.datasource.master.url=jdbc:mysql://host1:3306/practice?useUnicode=true&characterEncoding=utf-8&useSSL=false
    spring.shardingsphere.datasource.master.username=root
    spring.shardingsphere.datasource.master.password=password

    # 数据源2(从数据源)
    spring.shardingsphere.datasource.slave.type=com.alibaba.druid.pool.DruidDataSource
    spring.shardingsphere.datasource.slave.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.shardingsphere.datasource.slave.url=jdbc:mysql://host2:3306/practice?useUnicode=true&characterEncoding=utf-8&useSSL=false
    spring.shardingsphere.datasource.slave.username=root
    spring.shardingsphere.datasource.slave.password=password

    # spring.shardingsphere.rules.readwrite-splitting.data-sources.<readwrite-splitting-data-source-name>.type=Static
    # 读写分离类型,如: Static,Dynamic
    spring.shardingsphere.rules.readwrite-splitting.data-sources.my_ds.type=Static
    # 写数据源名称
    spring.shardingsphere.rules.readwrite-splitting.data-sources.my_ds.props.write-data-source-name=master
    # 读数据源名称,多个从数据源用逗号分隔
    spring.shardingsphere.rules.readwrite-splitting.data-sources.my_ds.props.read-data-source-names=slave
    # 负载均衡算法名称
    # 自定义轮询算法名称
    spring.shardingsphere.rules.readwrite-splitting.data-sources.my_ds.load-balancer-name=round_alg
    # 自定义随机算法名称
    #spring.shardingsphere.rules.readwrite-splitting.data-sources.my_ds.load-balancer-name=random_alg
    # 自定义权重算法名称
    #spring.shardingsphere.rules.readwrite-splitting.data-sources.my_ds.load-balancer-name=weight_alg

    # spring.shardingsphere.rules.readwrite-splitting.load-balancers.<load-balance-algorithm-name>.type= # ????????
    # 负载均衡算法类型
    # 使用轮询算法
    spring.shardingsphere.rules.readwrite-splitting.load-balancers.round_alg.type=ROUND_ROBIN
    # 负载均衡算法属性配置
    # 权重算法配置
    #spring.shardingsphere.rules.readwrite-splitting.load-balancers.weight_alg.props.slave1=1
    #spring.shardingsphere.rules.readwrite-splitting.load-balancers.weight_alg.props.slave2=2
  4. 分别测试查询和插入接口,查看数据源是否正常路由

    • 查询sql路由到从数据源

    • 写入sql路由到主数据源

使用ShardingSphere-JDBC进行分库分表

  1. 数据库:ds_0、ds_1;表:t_order_0/1(ds_0/1)、t_order_item_0/1(ds_0/1)

  2. 创建application.properties作为基础配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    server.port=9090

    spring.profiles.active=horizontal

    spring.application.name=demo

    mybatis.mapper-locations=classpath*:**/sql/*.xml

    # 打印sql日志
    spring.shardingsphere.props.sql-show=true
  3. 创建application-horizontal.properties作为读写分离配置文件

    分库分表策略:

    分库:订单表根据id对2取模,结果为0,路由到ds_0数据库;结果为1,路由到ds_1数据库。订单明细表根据order_id对2取模,结果为0,路由到ds_0数据库;结果为1,路由到ds_1数据库,即和订单表保持一致

    分表:订单表、订单明细表根据user_id对2取模,结果为0,路由到t_order_0t_order_item_0;结果为1,路由到t_order_1t_order_item_1

    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
    # 数据源名称
    spring.shardingsphere.datasource.names=ds_0,ds_1

    # 数据源1
    spring.shardingsphere.datasource.ds_0.type=com.alibaba.druid.pool.DruidDataSource
    spring.shardingsphere.datasource.ds_0.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.shardingsphere.datasource.ds_0.url=jdbc:mysql://host1:3306/practice?useUnicode=true&characterEncoding=utf-8&useSSL=false
    spring.shardingsphere.datasource.ds_0.username=root
    spring.shardingsphere.datasource.ds_0.password=password

    spring.shardingsphere.datasource.ds_1.type=com.alibaba.druid.pool.DruidDataSource
    spring.shardingsphere.datasource.ds_1.driver-class-name=com.mysql.cj.jdbc.Driver
    spring.shardingsphere.datasource.ds_1.url=jdbc:mysql://host2:3306/practice?useUnicode=true&characterEncoding=utf-8&useSSL=false
    spring.shardingsphere.datasource.ds_1.username=root
    spring.shardingsphere.datasource.ds_1.password=password

    # 数据节点,由数据源名 + 表名组成,以小数点分隔。多个表以逗号分隔,支持 inline 表达式。缺省表示使用已知数据源与逻辑表名称生成数据节点
    spring.shardingsphere.rules.sharding.tables.t_order.actual-data-nodes=ds_$->{[0,1]}.t_order_$->{0..1}
    spring.shardingsphere.rules.sharding.tables.t_order_item.actual-data-nodes=ds_$->{[0,1]}.t_order_item_$->{0..1}

    # 分库策略
    # 订单表根据id对2取模,路由到ds_0或ds_1数据库
    spring.shardingsphere.rules.sharding.tables.t_order.database-strategy.standard.sharding-column=id
    spring.shardingsphere.rules.sharding.tables.t_order.database-strategy.standard.sharding-algorithm-name=alg_inline_id
    # 订单明细表根据order_id对2取模,路由到ds_0或ds_1数据库,和订单表保持一致
    spring.shardingsphere.rules.sharding.tables.t_order_item.database-strategy.standard.sharding-column=order_id
    spring.shardingsphere.rules.sharding.tables.t_order_item.database-strategy.standard.sharding-algorithm-name=alg_inline_order_id

    # 分表策略
    # 订单表根据user_id对2取模,路由到t_order_0或t_order_1表
    spring.shardingsphere.rules.sharding.tables.t_order.table-strategy.standard.sharding-column=user_id
    spring.shardingsphere.rules.sharding.tables.t_order.table-strategy.standard.sharding-algorithm-name=alg_mod_id
    # 订单明细表根据user_id对2取模,路由到t_order_item_0或t_order_item_1表
    spring.shardingsphere.rules.sharding.tables.t_order_item.table-strategy.standard.sharding-column=user_id
    spring.shardingsphere.rules.sharding.tables.t_order_item.table-strategy.standard.sharding-algorithm-name=alg_mod_id

    # 分片算法
    # 订单表,行表达式分片算法
    spring.shardingsphere.rules.sharding.sharding-algorithms.alg_inline_id.type=INLINE
    spring.shardingsphere.rules.sharding.sharding-algorithms.alg_inline_id.props.algorithm-expression=ds_$->{id % 2}
    # 订单表、订单明细表,取模分片算法
    spring.shardingsphere.rules.sharding.sharding-algorithms.alg_mod_id.type=MOD
    spring.shardingsphere.rules.sharding.sharding-algorithms.alg_mod_id.props.sharding-count=2
    # 订单明细表,行表达式分片算法
    spring.shardingsphere.rules.sharding.sharding-algorithms.alg_inline_order_id.type=INLINE
    spring.shardingsphere.rules.sharding.sharding-algorithms.alg_inline_order_id.props.algorithm-expression=ds_$->{order_id % 2}

    # 绑定表:分片规则一致的一组分片表。使用绑定表进行多表关联查询时,必须使用分片键进行关联,否则会出现笛卡尔积关联或跨库关联,从而影响查询效率
    spring.shardingsphere.rules.sharding.binding-tables[0]=t_order,t_order_item
  4. 编写插入订单和订单行的代码,查看分库分表策略是否生效

    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
    @Override
    public void mockInsert() {
    OrderDO order1 = new OrderDO().setId(snowflake.nextId())
    .setOrderNo(snowflake.nextIdStr())
    .setUserId(202001L)
    .setCreateTime("2023-08-12 17:35:11")
    .setCreateTime("2023-08-12 17:35:11")
    .setPayAmount(new BigDecimal("10889.0"));
    OrderItemDO orderItem1 = new OrderItemDO().setId(snowflake.nextId())
    .setOrderId(order1.getId())
    .setUserId(202001L)
    .setProductName("MacBook Pro")
    .setAmount(new BigDecimal("10889.0"));

    orderMapper.insert(order1);
    orderItemService.insert(orderItem1);

    OrderDO order2 = new OrderDO().setId(snowflake.nextId())
    .setOrderNo(snowflake.nextIdStr())
    .setUserId(202000L)
    .setCreateTime("2023-08-12 17:41:54")
    .setCreateTime("2023-08-12 17:41:54")
    .setPayAmount(new BigDecimal("3899.0"));
    OrderItemDO orderItem2 = new OrderItemDO()
    .setId(snowflake.nextId())
    .setOrderId(order2.getId())
    .setUserId(202000L)
    .setProductName("iWatch S8")
    .setAmount(new BigDecimal("3899.0"));

    orderMapper.insert(order2);
    orderItemService.insert(orderItem2);
    }
  5. 查看分库分表情况

    • order_id为偶数、user_id为奇数

    • order_id为偶数、user_id为偶数

在业务增长到一定数量后,我们的数据库会产生大量的数据,而随着数据量的增大,单表或单库的性能已经达到瓶颈,我们会对数据库进行读写分离、分库分表等优化,而Spring提供的多数据源及动态切换在这些场景就可以派上用场了。

介绍一下AbstractRoutingDataSource

  1. AbstractRoutingDataSource是Spring中的一个抽象类,它的类继承关系如下,查看类注释信息:它是DataSource的抽象实现,根据查找key将getConnection()方法调用路由到多个目标数据源中的一个,后者通常是通过线程绑定的事务上下文确定的。

    Abstract DataSource implementation that routes getConnection() calls to one of various target DataSources based on a lookup key. The latter is usually (but not necessarily) determined through some thread-bound transaction context.

  2. AbstractRoutingDataSource的关键属性和方法

    • afterPropertiesSet():在Spring容器填充bean属性后调用,解析目标数据源集合和默认目标数据源

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      private Map<Object, DataSource> resolvedDataSources;

      private DataSource resolvedDefaultDataSource;

      public void afterPropertiesSet() {
      if (this.targetDataSources == null) {
      throw new IllegalArgumentException("Property 'targetDataSources' is required");
      }

      // 对目标数据源集合和默认目标数据源进行解析,并填充到resolvedDataSources和resolvedDefaultDataSource属性
      this.resolvedDataSources = CollectionUtils.newHashMap(this.targetDataSources.size());
      this.targetDataSources.forEach((key, value) -> {
      Object lookupKey = resolveSpecifiedLookupKey(key);
      DataSource dataSource = resolveSpecifiedDataSource(value);
      this.resolvedDataSources.put(lookupKey, dataSource);
      });
      if (this.defaultTargetDataSource != null) {
      this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
      }
      }
    • getConnection():获取数据库连接

      1
      2
      3
      public Connection getConnection() throws SQLException {
      return determineTargetDataSource().getConnection();
      }
    • determineTargetDataSource():确定目标数据源

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      protected DataSource determineTargetDataSource() {
      Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
      // 获取查找key
      Object lookupKey = determineCurrentLookupKey();
      // 根据查找key从已解析数据源集合中获取目标数据源
      DataSource dataSource = this.resolvedDataSources.get(lookupKey);
      if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
      dataSource = this.resolvedDefaultDataSource;
      }
      if (dataSource == null) {
      throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
      }
      return dataSource;
      }
    • determineCurrentLookupKey():确定当前查找key,不限类型,但是需要和resolvedDataSources中的key类型一致

      ==抽象方法,需要子类重写==

      1
      2
      3
      private Map<Object, DataSource> resolvedDataSources;

      protected abstract Object determineCurrentLookupKey();

具体步骤

  1. 创建一个Spring Boot项目(只要是Spring项目就可以),引入MySQL驱动、MyBatis、druid、aop(使用aop切面编程实现数据源动态切换)等依赖

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>

    <dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.2.2</version>
    </dependency>

    <dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
    <version>8.0.28</version>
    </dependency>

    <!--可以不用druid-spring-boot-starter,因为我们需要自定义数据源,用不上自动配置-->
    <dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>druid</artifactId>
    <version>1.1.22</version>
    </dependency>
  2. 创建UserController、UserService、UserDao和UserMapper.xml(个人喜欢自己写SQL语句,也可以用MP等ORM框架),代码省略,只需要写一个查询接口就可以了

  3. 在两个数据库中添加两张结构一样的表,各添加一条数据,这里用nickname字段值不同代替主库和从库区别

  4. 创建application.yml或application.properties配置文件,添加数据库连接信息,这里如果用主从数据库连接更好,为了简化操作,这里我用两个不同的数据库代替主数据库和从数据库

    1
    2
    3
    4
    5
    6
    7
    8
    9
    spring:
    ds_01:
    username: root
    password: password1
    url: jdbc:mysql://host1:3306/practice?useUnicode=true&characterEncoding=utf-8&useSSL=false
    ds_02:
    username: root
    password: password2
    url: jdbc:mysql://host2:3306/practice?useUnicode=true&characterEncoding=utf-8&useSSL=false
  5. 创建DataSourceHolder,使用ThreadLocal管理查找key,和线程绑定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class DataSourceHolder {

    private static final ThreadLocal<String> DATASOURCE_HOLDER = new ThreadLocal<>();

    public static void set(String name) {
    DATASOURCE_HOLDER.set(name);
    }

    public static String get() {
    return DATASOURCE_HOLDER.get();
    }

    public static void clear() {
    DATASOURCE_HOLDER.remove();
    }

    }
  6. 创建DynamicDataSource,继承AbstractRoutingDataSource,重写determineCurrentLookupKey()方法,通过DataSourceHolder获取查找key

    1
    2
    3
    4
    5
    6
    7
    8
    public class DynamicDataSource extends AbstractRoutingDataSource {

    @Override
    protected Object determineCurrentLookupKey() {
    return DataSourceHolder.get();
    }

    }
  7. 创建DataSourceEnum保存数据源信息(可以直接用字符串代替)

    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 enum DataSourceEnum {

    /**
    * 主数据源
    */
    MASTER("master"),
    /**
    * 从数据源
    */
    SLAVE("slave");

    private String name;

    DataSourceEnum(String name) {
    this.name = name;
    }

    public String getName() {
    return name;
    }

    public void setName(String name) {
    this.name = name;
    }
    }
  8. 创建DataSourceConfig,配置数据源(可配置任意多个),其中dataSource01为主库数据源,dataSource02为从库数据源(实际不是),dynamicDataSource为[步骤6]中自定义的数据源,并且是主数据源(使用@Primary注解)

    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
    @Configuration
    public class DataSourceConfig {

    @Resource
    private Environment environment;

    @Bean
    public DataSource dataSource01() {
    DruidDataSource dataSource = new DruidDataSource();
    dataSource.setUsername(environment.getProperty("spring.ds_01.username"));
    dataSource.setPassword(environment.getProperty("spring.ds_01.password"));
    dataSource.setUrl(environment.getProperty("spring.ds_01.url"));

    return dataSource;
    }

    @Bean
    public DataSource dataSource02() {
    DruidDataSource dataSource = new DruidDataSource();
    dataSource.setUsername(environment.getProperty("spring.ds_02.username"));
    dataSource.setPassword(environment.getProperty("spring.ds_02.password"));
    dataSource.setUrl(environment.getProperty("spring.ds_02.url"));

    return dataSource;
    }

    @Bean
    @Primary
    public DynamicDataSource dynamicDataSource() {
    DynamicDataSource dynamicDataSource = new DynamicDataSource();

    // 将主数据源和从数据源添加到目标数据源集合,设置默认目标数据源
    Map<Object, Object> targetDataSource = new HashMap<>(2);
    targetDataSource.put(DataSourceEnum.MASTER.getName(), dataSource01());
    targetDataSource.put(DataSourceEnum.SLAVE.getName(), dataSource02());

    dynamicDataSource.setDefaultTargetDataSource(dataSource01());
    dynamicDataSource.setTargetDataSources(targetDataSource);

    return dynamicDataSource;
    }

    }
  9. 创建自定义注解SwitchDataSource,控制使用哪个数据源

    1
    2
    3
    4
    5
    6
    7
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface SwitchDataSource {

    DataSourceEnum type() default DataSourceEnum.MASTER;

    }
  10. 创建DynamicDataSourceAspect切面,拦截带有@SwitchDataSource注解的方法,获取注解的name属性作为查找key

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

    @Around("@annotation(switchDataSource)")
    public Object around(ProceedingJoinPoint joinPoint, SwitchDataSource switchDataSource) {
    DataSourceEnum type = switchDataSource.type();

    DataSourceHolder.set(type.getName());
    try {
    return joinPoint.proceed();
    } catch (Throwable e) {
    throw new RuntimeException(e);
    } finally {
    DataSourceHolder.clear();
    }
    }

    }
  11. 在UserService的查找方法上添加自定义注解

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Override
    @SwitchDataSource(type = DataSourceEnum.SLAVE)
    // @SwitchDataSource
    public UserDO getById(Long id) {
    UserDO user = userMapper.getById(id);

    ResponseEnum.USER_NOT_EXIST.assertNotNull(user);

    return user;
    }
  12. 对不使用注解、使用注解(不设置type)、使用注解(设置type为slave)三种情况进行测试,查看测试结果

    • 情况1、情况2

    • 情况3

至此,我们就完成了Spring多数据源配置和动态切换了,关于多数据源的事务问题会在后面的篇章中探究。

软件版本

  • Dubbo 3.X
  • Nacos 2.X

报错信息

Failed to create nacos naming service client. Reason: server status check failed

问题原因

Nacos2.X版本相比1.X新增了gRPC通信,因此添加了额外的端口,详见Nacos文档

问题解决

1
2
3
4
5
# 防火墙开放9848端口
[root@localhost logs]# firewall-cmd --add-port=9848/tcp --permanent
success
[root@localhost logs]# firewall-cmd --reload
success

有时候我们需要关闭或重启Java程序,但是可能某个业务流程还未执行结束或占用的资源还未释放,如果使用kill -9这种暴力的方式关闭应用,有可能会导致我们的业务逻辑混乱,数据不一致等问题,因此我们希望使用一种温柔的方式来关闭程序

在了解优雅关闭前,先了解下什么是shutdown hook


JVM提供的shutdown hook

  1. 创建主程序,添加钩子线程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class JVMTest {

    public static void main(String[] args) {
    Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
    @Override
    public void run() {

    System.out.println("this is a custom shutdown hook");

    }
    }));

    System.out.println("JVM terminated...");
    }

    }
  2. 主程序正常结束,shutdown hook正常被调用

  3. 主程序主动退出,shutdown hook也会正常被调用

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public static void main(String[] args) {
    Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
    @Override
    public void run() {

    System.out.println("this is a custom shutdown hook");

    }
    }));

    System.out.println("JVM terminated...");

    System.exit(0);
    }
  4. 修改主程序,让主程序sleep 20s

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

    public static void main(String[] args) {
    Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
    @Override
    public void run() {

    System.out.println("this is a custom shutdown hook");

    }
    }));

    System.out.println("JVM terminated after 20 seconds");

    try {
    TimeUnit.SECONDS.sleep(20);
    } catch (InterruptedException e) {
    throw new RuntimeException(e);
    }
    }

    }
  5. 通过Linux的kill命令终止主程序,使用kill -9命令,shutdown hook并不会被调用,使用kill -15会被调用

简单来说,shutdown hook是jvm在关闭时会调用的一个钩子


JVM处理信号量通知

  1. System#initializeSystemClass()初始化,调用Terminator.setup()设置信号量处理

    1
    2
    3
    4
    5
    6
    7
    8
    // Initialize the system class. Called after thread initialization.
    private static void initializeSystemClass() {
    ...
    // 对于程序 挂断、终止、中断的处理
    // Setup Java signal handlers for HUP, TERM, and INT (where available).
    Terminator.setup();
    ...
    }
  2. Terminator#setup()设置信号量处理

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    static void setup() {
    if (handler != null) return;
    // 设置信号量处理器
    SignalHandler sh = new SignalHandler() {
    public void handle(Signal sig) {
    // 调用Shutdown.exit()方法
    Shutdown.exit(sig.getNumber() + 0200);
    }
    };
    handler = sh;

    try {
    // 程序中断处理
    Signal.handle(new Signal("INT"), sh);
    } catch (IllegalArgumentException e) {
    }
    try {
    // 程序终止处理
    Signal.handle(new Signal("TERM"), sh);
    } catch (IllegalArgumentException e) {
    }
    }
  3. Runtime#addShutdownHook()添加shutdown hook

    1
    2
    3
    4
    5
    public void addShutdownHook(Thread hook) {
    ...

    ApplicationShutdownHooks.add(hook);
    }
  4. ApplicationShutdownHooks#add()添加钩子线程

    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
    51
    52
    53
    // 保存钩子线程的集合
    private static IdentityHashMap<Thread, Thread> hooks;
    // 静态代码块初始化时,向Shutdown添加一个钩子线程
    static {
    try {
    Shutdown.add(1 /* shutdown hook invocation order */,
    false /* not registered if shutdown in progress */,
    new Runnable() {
    public void run() {
    runHooks();
    }
    }
    );
    hooks = new IdentityHashMap<>();
    } catch (IllegalStateException e) {
    // application shutdown hooks cannot be added if
    // shutdown is in progress.
    hooks = null;
    }
    }

    // 添加钩子线程
    static synchronized void add(Thread hook) {
    ...
    //
    hooks.put(hook, hook);
    }

    // 运行钩子线程
    static void runHooks() {
    Collection<Thread> threads;
    // 获取所有的钩子线程
    synchronized(ApplicationShutdownHooks.class) {
    threads = hooks.keySet();
    hooks = null;
    }

    // 执行
    for (Thread hook : threads) {
    hook.start();
    }

    // 等待线程执行结束
    for (Thread hook : threads) {
    while (true) {
    try {
    hook.join();
    break;
    } catch (InterruptedException ignored) {
    }
    }
    }
    }
  5. Shutdown#add()添加钩子线程

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    private static final int MAX_SYSTEM_HOOKS = 10;
    private static final Runnable[] hooks = new Runnable[MAX_SYSTEM_HOOKS];

    static void add(int slot, boolean registerShutdownInProgress, Runnable hook) {
    synchronized (lock) {
    ...
    // 添加钩子线程
    hooks[slot] = hook;
    }
    }
  6. System#exit()终止JVM,带程序退出状态code,非0为异常

    1
    2
    3
    public static void exit(int status) {
    Runtime.getRuntime().exit(status);
    }
  7. Runtime#exit()

    1
    2
    3
    4
    public void exit(int status) {
    ...
    Shutdown.exit(status);
    }
  8. Shutdown#exit()Shutdown#shutdown()

    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
    51
    52
    53
    54
    55
    56
    57
    // 由Runtime.exit()触发调用
    static void exit(int status) {
    ...
    synchronized (Shutdown.class) {
    /* Synchronize on the class object, causing any other thread
    * that attempts to initiate shutdown to stall indefinitely
    */
    beforeHalt();
    sequence();
    halt(status);
    }
    }

    private static void sequence() {
    ...
    runHooks();
    ...
    }

    // 当最后一个非daemon线程执行结束后,由JNI DestroyJavaVM调用,不会关闭JVM
    static void shutdown() {
    synchronized (lock) {
    switch (state) {
    case RUNNING: /* Initiate shutdown */
    state = HOOKS;
    break;
    case HOOKS: /* Stall and then return */
    case FINALIZERS:
    break;
    }
    }
    synchronized (Shutdown.class) {
    sequence();
    }
    }

    // 实际执行勾子线程的方法
    private static void runHooks() {
    for (int i=0; i < MAX_SYSTEM_HOOKS; i++) {
    try {
    Runnable hook;
    synchronized (lock) {
    // acquire the lock to make sure the hook registered during
    // shutdown is visible here.
    currentRunningHook = i;
    hook = hooks[i];
    }
    // 执行勾子线程的run方法
    if (hook != null) hook.run();
    } catch(Throwable t) {
    if (t instanceof ThreadDeath) {
    ThreadDeath td = (ThreadDeath)t;
    throw td;
    }
    }
    }
    }

流程图展示(大致流程)

可以看到,JVM在System类初始化时会添加信号量处理(如程序中断、程序终止),接收到信号量通知时会触发Shutdown.exit方法,调用自定义的shutdown hook


Spring Boot中的shutdown hook

  1. SpringApplication#run

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // spring容器启动流程
    public ConfigurableApplicationContext run(String... args) {

    ...
    refreshContext(context);
    ...

    }

    static final SpringApplicationShutdownHook shutdownHook = new SpringApplicationShutdownHook();

    // 刷新容器,如果registerShutdownHook为true,调用SpringApplicationShutdownHook#registerApplicationContext()方法注册应用上下文
    private void refreshContext(ConfigurableApplicationContext context) {
    if (this.registerShutdownHook) {
    shutdownHook.registerApplicationContext(context);
    }
    refresh(context);
    }
  2. SpringApplicationShutdownHook#registerApplicationContext()SpringApplicationShutdownHook本身实现了Runnable接口

    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
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    // 注册应用上下文
    // 1.注册spring容器关闭事件监听器
    // 2.调用Runtime#addShutdownHook()方法添加shutdown hook
    void registerApplicationContext(ConfigurableApplicationContext context) {
    // 添加shutdown hook
    addRuntimeShutdownHookIfNecessary();
    synchronized (SpringApplicationShutdownHook.class) {
    assertNotInProgress();
    // 注册容器关闭事件监听器
    context.addApplicationListener(this.contextCloseListener);
    this.contexts.add(context);
    }
    }

    private void addRuntimeShutdownHookIfNecessary() {
    if (this.shutdownHookAdded.compareAndSet(false, true)) {
    addRuntimeShutdownHook();
    }
    }

    // 实际调用Runtime#addShutdownHook()方法添加钩子线程
    void addRuntimeShutdownHook() {
    try {
    Runtime.getRuntime().addShutdownHook(new Thread(this, "SpringApplicationShutdownHook"));
    }
    catch (AccessControlException ex) {
    // Not allowed in some environments
    }
    }

    // 钩子线程最终执行的方法
    @Override
    public void run() {
    Set<ConfigurableApplicationContext> contexts;
    Set<ConfigurableApplicationContext> closedContexts;
    Set<Runnable> actions;
    synchronized (SpringApplicationShutdownHook.class) {
    this.inProgress = true;
    contexts = new LinkedHashSet<>(this.contexts);
    closedContexts = new LinkedHashSet<>(this.closedContexts);
    actions = new LinkedHashSet<>(this.handlers.getActions());
    }
    // 关闭容器并等待容器停止
    contexts.forEach(this::closeAndWait);
    closedContexts.forEach(this::closeAndWait);

    // 执行handlers中的action
    actions.forEach(Runnable::run);
    }

    private void closeAndWait(ConfigurableApplicationContext context) {
    if (!context.isActive()) {
    return;
    }

    // 关闭容器
    context.close();
    try {
    // 等待一段时间
    int waited = 0;
    while (context.isActive()) {
    if (waited > TIMEOUT) {
    throw new TimeoutException();
    }
    Thread.sleep(SLEEP);
    waited += SLEEP;
    }
    }
    catch (InterruptedException ex) {
    Thread.currentThread().interrupt();
    logger.warn("Interrupted waiting for application context " + context + " to become inactive");
    }
    catch (TimeoutException ex) {
    logger.warn("Timed out waiting for application context " + context + " to become inactive", ex);
    }
    }
  3. 容器关闭时,会调用AbstractApplicationContext#close()方法

    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
    // 容器关闭
    @Override
    public void close() {
    synchronized (this.startupShutdownMonitor) {
    doClose();

    // 移除shutdown hook
    if (this.shutdownHook != null) {
    try {
    Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
    }
    catch (IllegalStateException ex) {
    // ignore - VM is already shutting down
    }
    }
    }
    }

    // 实际关闭容器的方法
    protected void doClose() {
    // Check whether an actual close attempt is necessary...
    if (this.active.get() && this.closed.compareAndSet(false, true)) {
    ...

    // 发布容器关闭事件,所有监听了该事件的监听器都会被通知,上面的SpringApplicationShutdownHook类就监听了该事件
    publishEvent(new ContextClosedEvent(this));

    ...

    // 销毁BeanFactory中的单例bean,可以通过实现Disposable接口重写destroy方法,会在这里被调用
    destroyBeans();

    // 关闭BeanFactory
    closeBeanFactory();

    // 留给子类实现
    onClose();

    ...
    }
    }

流程展示

可以看到,SpringBoot启动时会添加一个SpringApplicationShutdownHook对象,该类实现了Runnable接口,run方法中调用context.close来关闭容器,从而可以在JVM关闭前进行一些通知或清理工作


何为优雅关闭

优雅关闭,就是指程序彻底停止前,当前正在处理的任务需要继续执行、服务器不再接受新的请求、应用从注册中心下线等


如何优雅关闭Spring Boot服务

优雅关闭服务的关键在于我们需要找到一个缓冲点,这个缓冲点可以让我们在JVM关闭前执行一系列资源清理工作,根据上面的分析,可以知道SpringBoot给我们提供了两个缓冲点,一个是容器关闭事件监听,一个是实现Disposable接口重写destroy()方法

  1. 添加缓冲点

    • 监听Spring容器关闭事件

      1
      2
      3
      4
      5
      6
      7
      8
      9
      @Component
      public class SpringCloseEventListener implements ApplicationListener<ContextClosedEvent> {

      @Override
      public void onApplicationEvent(ContextClosedEvent event) {
      System.out.println("执行资源清理工作...");
      }

      }
    • 实现Disposable接口或在方法上使用@PreDestroy注解

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      @Component
      public class DisposableClass implements DisposableBean {

      @Override
      public void destroy() throws Exception {
      System.out.println("DisposableClass#destroy");
      }

      }

      @Component
      public class PreDestroyBean {

      @PreDestroy
      public void preDestroy() {
      System.out.println("执行PreDestroy()方法...");
      }

      }
  2. 关闭服务

    • 通过System.exit()直接退出

    • 通过AbstractApplicationContext#close关闭服务

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      @RequestMapping(value = "/service")
      @RestController
      public class ShutdownController implements ApplicationContextAware {

      private ApplicationContext context;

      @Override
      public void setApplicationContext(ApplicationContext context) throws BeansException {
      this.context = context;
      }

      @GetMapping(value = "/shutdown")
      public String shutdown() {
      ((ConfigurableApplicationContext) context).close();

      return "ok";
      }
      }
    • 通过ApplicationPidFileWriter将服务pid写入指定文件,使用脚本kill对应pid的进程

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @SpringBootApplication
      public class UserApplication {

      public static void main(String[] args) {
      SpringApplication application = new SpringApplication(UserApplication.class);
      application.addListeners(new ApplicationPidFileWriter("/home/service/user-service.pid"));
      application.run(args);
      }

      }
      1
      cat /home/service/user-service.pid | xargs kill

使用actuator实现优雅关闭

  • SpringBoot 2.3及以上版本
  1. 添加缓冲点,参考这里

  2. 引入actuator依赖,添加配置信息

    • 引入依赖

      1
      2
      3
      4
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
      </dependency>
    • 修改application.yml

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      server:
      port: 8090
      # 采用优雅停机方式,默认immediate立即停机
      shutdown: graceful

      # 等待超时时间
      spring:
      lifecycle:
      timeout-per-shutdown-phase: 15s

      # 暴露shutdown路径
      management:
      endpoints:
      web:
      exposure:
      include: shutdown
      endpoint:
      shutdown:
      enabled: true
    • 发送shutdown请求(使用POST方式)

      1
      curl -X POST http://localhost:8090/actuator/shutdown
  3. actuator工作原理

    • ShutdownEndpoint类暴露了一个shutdown端点,当请求该端点时,内部调用了AbstractApplicationContext#close方法关闭容器

      1
      2
      3
      4
      5
      6
      7
      8
      9
      private void performShutdown() {
      try {
      Thread.sleep(500L);
      }
      catch (InterruptedException ex) {
      Thread.currentThread().interrupt();
      }
      this.context.close();
      }
  • SpringBoot 2.3版本以下(使用Tomcat容器)
  1. 引入actuator依赖,添加配置信息

    • 引入依赖

      1
      2
      3
      4
      <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-actuator</artifactId>
      </dependency>
    • 修改application.yml

      1
      2
      3
      4
      5
      6
      7
      8
      9
      # 暴露shutdown路径
      management:
      endpoints:
      web:
      exposure:
      include: shutdown
      endpoint:
      shutdown:
      enabled: true
  2. 创建优雅关闭配置类

    • 自定义TomcatCustomizer类实现TomcatConnectorCustomizerApplicationListener接口,获取Tomcat连接器和线程池,接收到容器关闭事件后关闭线程池

      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
      @Configuration
      @Slf4j
      public class GracefulShutdownConfig {

      @Bean
      public TomcatCustomizer tomcatCustomizer() {
      return new TomcatCustomizer();
      }

      @Bean
      public ServletWebServerFactory servletWebServerFactory() {
      TomcatServletWebServerFactory serverFactory = new TomcatServletWebServerFactory();
      // 将自定义的TomcatConnectorCustomizer添加到Tomcat容器中
      serverFactory.addConnectorCustomizers(tomcatCustomizer());
      return serverFactory;
      }

      public class TomcatCustomizer implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> {

      private volatile Connector connector;

      private int timeout = 30;

      @Override
      public void customize(Connector connector) {
      this.connector = connector;
      }

      @Override
      public void onApplicationEvent(ContextClosedEvent event) {
      if (null != connector) {
      this.connector.pause();

      Executor executor = this.connector.getProtocolHandler().getExecutor();
      if (executor instanceof ThreadPoolExecutor) {
      ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor;
      threadPoolExecutor.shutdown();
      try {
      if (threadPoolExecutor.awaitTermination(timeout, TimeUnit.SECONDS)) {
      log.warn("tomcat didn't close gracefully in {} seconds, it turns to shutdown forcibly", timeout);
      }
      } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
      }
      }
      }
      }
      }

      }

注意:暴露shutdown端点存在风险,需要保证该端点不会被其他人随意调用


总结:

  • JVM为我们提供了添加shutdown hook的方法,在JVM关闭前会执行这些hook线程
  • JVM在处理信号量通知的过程中使用了shutdown hook
  • SpringBoot提供了SpringApplicationShutdownHook(实现了Runnable接口)作为shutdown hook,run方法中调用AbstractApplicationContext#close方法关闭容器
  • 容器关闭时会进行一系列资源销毁工作,如发布容器关闭事件、销毁Bean Factory中的bean、关闭Bean Factory、执行子类的onClose方法等
  • 通过监听容器关闭事件或实现DisposableBean接口可以进行资源清理操作,如线程池的销毁、服务从注册中心下线、MQ消费等

上面只是简单描述了如何优雅关闭SpringBoot服务,实际在开发过程中还需要考虑到很多因素,如不同的注册中心、不同的rpc框架、服务部署方式等

道阻且长,行则将至。

相信大部分Java开发者在使用SpringBoot开发项目时,经常会遇到代码频繁改动,每次打包部署到开发/测试环境时,由于jar包体积过大,上传jar包消耗了大量的时间,下面介绍如何给SpringBoot项目jar包减肥(使用maven作为打包工具)

注:Spring Boot版本:2.5.7、JDK版本:jdk8

jar包体积

可以看到,一个普通的Spring Boot项目,其生成的jar包体积就高达几十近百兆


jar包结构

解压生成的jar包,查看其目录结构

  • BOOT-INF

    • classes 业务代码
    • lib 项目依赖
  • META-INF

    • maven maven配置信息
    • MANIFEST.MF jar包的描述信息和属性
    • spring-configuration-metadata.json 配置提示信息,使用IDE编写配置文件会有提示
  • org

    • springframework 启动jar包需要的class


修改maven打包配置

  • 修改项目pom.xml

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    <build>
    <plugins>
    <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>

    <configuration>
    <layout>ZIP</layout>
    <includes>
    <include>
    <groupId>nothing</groupId>
    <artifactId>nothing</artifactId>
    </include>
    </includes>
    </configuration>
    </plugin>
    </plugins>
    </build>
  • 使用maven重新打包,可以发现现在的jar包体积比原来要小很多


准备jar包运行需要的依赖

  • 不修改maven打包配置,生成带有lib目录的jar包,然后解压将lib目录单独提取出来

  • 修改maven打包配置,将依赖copy到指定目录

    在plugins标签中添加下面内容,如果maven-dependency-plugin找不到,可以先引入该依赖,然后删除该依赖配置,以后每次打包后在target目录下会生成一个lib依赖目录

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-dependency-plugin</artifactId>
    <executions>
    <execution>
    <id>copy-dependencies</id>
    <phase>prepare-package</phase>
    <goals>
    <goal>copy-dependencies</goal>
    </goals>
    <configuration>
    <outputDirectory>${project.build.directory}/lib</outputDirectory>
    </configuration>
    </execution>
    </executions>
    </plugin>

运行瘦身后的jar包(仅限JDK8版本)

  • 通过指定loader.path参数指定依赖目录

    1
    java -Dloader.path=./lib -jar user-0.0.1-SNAPSHOT.jar

  • 通过指定java.ext.dirs参数,注意不能覆盖原有的依赖($JAVA_HOME/jre/lib/ext),可以通过:添加多个依赖

    1
    java -Djava.ext.dirs=$JAVA_HOME/jre/lib/ext:./lib -jar user-0.0.1-SNAPSHOT.jar

    ==注意:==java.ext.dirs参数在JDK8以上版本好像不支持

在JDK5以后,官方提供了一种新特性 Java Agent,也叫Java 探针技术,它可以帮助我们构建一个和主程序独立的代理程序,通过代理程序可以在不修改主程序代码的情况下对主程序进行增强,如实现性能监测、日志记录、热加载等,很多著名的软件/工具都使用了这个技术,如arthas、skywalking等

下面通过示例来学习如何使用Java Agent

关于Java Agent的一些简要说明

  • 通过-javaagent:agent.jar参数加载agent程序,可以多次使用以加载多个agent
  • Java Agent有两个方法,一个是premain(String [, Instrumentation]),一个是agentmain(String[, Instrumentation])Instrumentation参数可选
  • premain方法会在主程序JVM启动前执行,因此可以在此方法中进行一些初始化工作;agentmain方法会在主程序JVM启动后执行,但是不能使用-javaagent参数来加载,而是要使用VirtualMachineloadAgent方法来进行加载
  • Instrumentation接口提供了在程序运行期间对程序进行动态调整的能力,比如修改字节码、替换class

开始动手

前提:

创建两个maven项目,一个是主程序,一个是agent

  • 主程序

    • Main.java

      1
      2
      3
      4
      5
      public class Main {
      public static void main(String[] args) {
      System.out.println("hello");
      }
      }
    • pom.xml

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      <build>
      <plugins>
      <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
      <archive>
      <manifest>
      <mainClass>com.codecho.demo.Main</mainClass>
      </manifest>
      </archive>
      </configuration>
      </plugin>
      </plugins>
      </build>
  • agent程序

    • LogAgent.java

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

      // 主程序启动前,代理先执行,如果代理抛出异常,主程序无法正常启动
      // 参数 Instrumentation 可选
      public static void premain(String args, Instrumentation instrumentation) {
      System.out.println("LogAgent#premain executed, args: " + args);
      }

      }
    • pom.xml(这里的配置是为了生成MANIFEST.MF文件,里面有premain class的信息)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      <build>
      <plugins>
      <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
      <archive>
      <manifestEntries>
      <Premain-Class>com.codecho.agent.LogAgent</Premain-Class>
      </manifestEntries>
      </archive>
      </configuration>
      </plugin>
      </plugins>
      </build>

测试premain

  1. 使用maven package得到主程序和agent的jar包

  2. 通过-javaagent参数加载agent(这里我把两个jar包放到同一目录了)

    1
    2
    3
    4
    5
    # 不指定参数
    java -javaagent:log-agent-1.0-SNAPSHOT.jar -jar agent-demo-1.0-SNAPSHOT.jar

    # 指定参数
    java -javaagent:log-agent-1.0-SNAPSHOT.jar=param1,param2,param3 -jar agent-demo-1.0-SNAPSHOT.jar

测试agentmain

  1. 修改agent程序的LogAgent.javapom.xml

    • LogAgent.java

      添加agentmain方法

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public class LogAgent {

      // 主程序启动前,代理先执行,如果代理抛出异常,主程序无法正常启动
      // 参数 Instrumentation 可选
      public static void premain(String args, Instrumentation instrumentation) {
      System.out.println("LogAgent#premain executed, args: " + args);
      }

      // 主程序启动后,代理后执行
      // 参数 Instrumentation 可选
      public static void agentmain(String args, Instrumentation instrumentation) {
      System.out.println("LogAgent#agentmain executed, args: " + args);
      }

      }
    • pom.xml

      Premain-Class改为Agent-Class

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      <build>
      <plugins>
      <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
      <archive>
      <manifestEntries>
      <Agent-Class>com.codecho.agent.LogAgent</Agent-Class>
      </manifestEntries>
      </archive>
      </configuration>
      </plugin>
      </plugins>
      </build>
  2. 修改主程序的Main.javapom.xml,新增Agent.java

    • Main.java

      因为agentmain方法是在主程序JVM启动后执行,因此这里使用输入流保证main方法不会马上结束

      1
      2
      3
      4
      5
      6
      7
      public class Main {

      public static void main(String[] args) throws IOException {
      System.in.read();
      }

      }
    • pom.xml

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      <!--引入jdk的tools-->
      <dependencies>
      <dependency>
      <groupId>com.sun</groupId>
      <artifactId>tools</artifactId>
      <version>1.8</version>
      <scope>system</scope>
      <systemPath>${JAVA_HOME}\lib\tools.jar</systemPath>
      </dependency>
      </dependencies>
    • Attach.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      public class Attach {

      public static void main(String[] args) {
      VirtualMachine vm = null;
      try {
      // 这里的24816是Main主程序的进程号,运行Main.java后使用jps命令查看pid
      vm = VirtualMachine.attach("24816");
      vm.loadAgent("D:/IdeaWorkspace/log-agent/target/log-agent-1.0-SNAPSHOT.jar", "hello");
      } catch (AttachNotSupportedException | IOException | AgentLoadException | AgentInitializationException e) {
      throw new RuntimeException(e);
      }
      }

      }
  3. 先运行Main主程序,通过jps查看主程序pid,再运行Attach程序

测试Instrumentation#addTransformer方法(作用在premain方法)

  1. 修改agent程序的LogAgent.javapom.xml

    • LogAgent.java

      修改premain方法

      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
      public class LogAgent {

      public static void premain(String args, Instrumentation instrumentation) {
      System.out.println("LogAgent#premain executed, args: " + args);

      instrumentation.addTransformer(new ClassFileTransformer() {
      @Override
      public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
      // 当前类为com/codecho/demo/TrafficService时,才替换class
      if (!className.equals("com/codecho/demo/TrafficService")) {
      return classfileBuffer;
      }

      System.out.println("replace class: " + className);
      ByteArrayOutputStream bos;
      // 类加载前替换新的class
      try (FileInputStream fis = new FileInputStream("D:/IdeaWorkspace/agent-demo/target/classes/com/codecho/demo/TrafficService.class")) {
      bos = new ByteArrayOutputStream();
      byte[] buffer = new byte[fis.available()];
      fis.read(buffer);
      bos.write(buffer, 0, buffer.length);
      } catch (IOException e) {
      throw new RuntimeException(e);
      }

      return bos.toByteArray();
      }
      });
      }

      }
    • pom.xml

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      <build>
      <plugins>
      <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
      <archive>
      <manifestEntries>
      <Premain-Class>com.codecho.agent.LogAgent</Premain-Class>
      </manifestEntries>
      </archive>
      </configuration>
      </plugin>
      </plugins>
      </build>
  2. 修改主程序的Main.java,新增TrafficService.java

    • TrafficService

      1
      2
      3
      4
      5
      6
      7
      public class TrafficService {

      public String transport() {
      return "take the train";
      }

      }
    • Main.java

      1
      2
      3
      4
      5
      6
      7
      8
      public class Main {

      public static void main(String[] args) {
      String transport = new TrafficService().transport();
      System.out.println("transport: " + transport);
      }

      }
  3. 单独运行Main.java,查看输出

    1
    2
    PS D:\IdeaWorkspace\agent-demo\target> java -jar agent-demo-1.0-SNAPSHOT.jar
    transport: take the train
  4. 修改TrafficService.java并编译

    1
    2
    3
    4
    5
    6
    7
    public class TrafficService {

    public String transport() {
    return "take the plane";
    }

    }
  5. 通过-javaagent参数加载agent,查看输出

测试Instrumentation#retransformClasses方法(作用在agentmain方法)

  1. 修改agent程序LogAgent.javapom.xml

    • LogAgent.java

      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
      public class LogAgent {

      // 主程序启动前,代理先执行,如果代理抛出异常,主程序无法正常启动
      // 参数 Instrumentation 可选
      public static void premain(String args, Instrumentation instrumentation) {
      System.out.println("LogAgent#premain executed, args: " + args);
      }

      // 主程序启动后,代理后执行
      // 参数 Instrumentation 可选
      public static void agentmain(String args, Instrumentation instrumentation) throws ClassNotFoundException, UnmodifiableClassException {
      System.out.println("LogAgent#agentmain executed, args: " + args);

      instrumentation.addTransformer(new ClassFileTransformer() {
      @Override
      public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
      // 当前类为com/codecho/demo/TrafficService时,才替换class
      if (!className.equals("com/codecho/demo/TrafficService")) {
      return classfileBuffer;
      }

      ByteArrayOutputStream bos;
      // 类加载前替换新的class
      try (FileInputStream fis = new FileInputStream("D:/IdeaWorkspace/agent-demo/target/classes/com/codecho/demo/TrafficService.class")) {
      bos = new ByteArrayOutputStream();
      byte[] buffer = new byte[fis.available()];
      fis.read(buffer);
      bos.write(buffer, 0, buffer.length);
      } catch (IOException e) {
      throw new RuntimeException(e);
      }

      return bos.toByteArray();
      }
      }, true);

      instrumentation.retransformClasses(Class.forName("com.codecho.demo.TrafficService", false, ClassLoader.getSystemClassLoader()));
      }

      }
    • pom.xml

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      <build>
      <plugins>
      <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
      <archive>
      <manifestEntries>
      <Agent-Class>com.codecho.agent.LogAgent</Agent-Class>
      <!--不加此配置会导致load失败-->
      <!--Agent JAR loaded but agent failed to initialize-->
      <Can-Retransform-Classes>true</Can-Retransform-Classes>
      </manifestEntries>
      </archive>
      </configuration>
      </plugin>
      </plugins>
      </build>
  2. 修改主程序的Main.java

    • Main.java

      通过死循环判断class是否重新加载

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      public class Main {

      public static void main(String[] args) {
      for (;;) {
      String transport = new TrafficService().transport();
      System.out.println("transport: " + transport);
      try {
      TimeUnit.SECONDS.sleep(5);
      } catch (InterruptedException e) {
      throw new RuntimeException(e);
      }
      }
      }

      }
    • TrafficService.java

      1
      2
      3
      4
      5
      6
      7
      public class TrafficService {

      public String transport() {
      return "take the bus";
      }

      }
  3. 先运行Main主程序,再修改TrafficServicetransport方法,通过jps查看主程序pid,再运行Attach程序

    可以看到TrafficService#transport的输出从bus变为subway,表明TrafficService类确实重新加载了

总结

  1. Java Agent是JDK提供的一种技术,可以对Java程序进行增强、监测、分析等
  2. Java Agent主要有premainagentmain两个入口方法,Instrumentation接口可以让我们在程序运行期间动态地更改字节码、替换class等
  3. 可以通过JavassistASM等工具来方便地操作字节码

在开发中,我们会经常使用公司或其他平台的各种接口,每个接口的功能各不相同,但是它们的响应结果基本上是一致的,都会包含code(响应码)、msg(错误信息)、data(真实数据)这三部分,有些公司会使用Map来返回这些信息,但是需要编写重复性的代码,不太优雅,而Spring为我们提供了能够简化代码编写的功能,下面就来尝试一下吧

项目准备

  1. 创建一个SpringBoot项目并引入spring-boot-starter-web依赖,分别创建controller和service

    • UserController.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      @RequestMapping(value = "/user")
      @RestController
      public class UserController {

      @Resource
      private UserService userService;

      @GetMapping(value = "/id/{id}")
      public UserDO getById(@PathVariable(value = "id") Long id) {
      UserDO user = userService.getById(id);

      return user;
      }
      }
    • UserService.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      @Service
      public class UserService {

      @Resource
      private UserMapper userMapper;

      public UserDO getById(Long id) {
      return userMapper.getById(id);
      }

      }
    • UserMapper.xml省略(可以直接硬编码返回,省略数据库配置)

  2. 测试controller返回结果

统一结果响应

  1. 创建CommonResponseCommonResponseAdvice

    • CommonResponse.java

      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
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      87
      @Getter
      @Setter
      @ToString
      public class CommonResponse<T> {

      /**
      * 响应是否成功:
      * true: 成功
      * false: 失败
      */
      private Boolean success;

      /**
      * 错误码
      */
      private int errCode;

      /**
      * 错误信息
      */
      private String errMsg;

      /**
      * 响应数据
      */
      private T data;

      public CommonResponse(Boolean success) {
      this.success = success;
      }

      public CommonResponse(Boolean success, T data) {
      this.success = success;
      this.data = data;
      }

      public CommonResponse(Boolean success, int errCode, String errMsg) {
      this.success = success;
      this.errCode = errCode;
      this.errMsg = errMsg;
      }

      /**
      *
      * @desc 响应成功无返回数据
      * @author codecho
      * @date 2021-12-05 15:22:07
      */
      public static <T> CommonResponse<T> success() {
      return new CommonResponse<>(true);
      }

      /**
      *
      * @desc 响应成功并返回数据
      * @author codecho
      * @date 2021-12-05 15:22:28
      * @param data 响应数据
      */
      public static <T> CommonResponse<T> success(T data) {
      return new CommonResponse<>(true, data);
      }

      /**
      *
      * @desc 响应失败,无错误码,有错误信息
      * @author codecho
      * @date 2021-12-05 17:37:39
      * @param errMsg 错误信息
      */
      public static<T> CommonResponse<T> fail(String errMsg) {
      return new CommonResponse<>(false, -1, errMsg);
      }

      /**
      *
      * @desc 响应失败,有错误码和错误信息
      * @author codecho
      * @date 2021-12-05 15:25:27
      * @param errCode 错误码
      * @param errMsg 错误信息
      */
      public static <T> CommonResponse<T> fail(int errCode, String errMsg) {
      return new CommonResponse<>(false, errCode, errMsg);
      }

      }
    • CommonResponseAdvice.java

      supports方法返回true表示响应结果需要进行重写

      beforeBodyWrite方法对响应结果进行封装

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      @RestControllerAdvice(basePackages = {"com.codecho.base.controller"})
      public class CommonResponseAdvice implements ResponseBodyAdvice {

      @Override
      public boolean supports(MethodParameter returnType, Class converterType) {
      return true;
      }

      @Override
      public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
      return CommonResponse.success(body);
      }
      }
  2. 重新运行程序,再次测试controller返回结果

统一异常处理

  1. 当程序出现异常时,controller返回的结果并不是我们希望获得的

  2. 创建ResponseStatusEnum、CommonExceptionCommonExceptionHandler

    • ResponseStatusEnum.java

      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
      public enum ResponseStatusEnum {

      /**
      * 错误
      */
      ERROR_500(500, "服务器未知错误"),
      ERROR_400(400, "错误请求"),
      USER_NOT_FOUND(233, "用户不存在");

      private int code;

      private String msg;

      ResponseStatusEnum(int code, String msg) {
      this.code = code;
      this.msg = msg;
      }

      public int getCode() {
      return code;
      }

      public String getMsg() {
      return msg;
      }
      }
    • CommonException.java

      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
      public class CommonException extends RuntimeException {

      private static final long serialVersionUID = 6124960120588564481L;

      @Getter
      private int code;

      @Getter
      private ResponseStatusEnum statusEnum;

      public CommonException(String message) {
      super(message);
      }

      public CommonException(int code, String message) {
      super(message);
      this.code = code;
      }

      public CommonException(ResponseStatusEnum statusEnum) {
      super(statusEnum.getMsg());
      this.code = statusEnum.getCode();
      this.statusEnum = statusEnum;
      }

      }
    • CommonExceptionHandler.java

      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
      @Slf4j
      @RestControllerAdvice(basePackages = {"com.codecho.base.controller"})
      public class CommonExceptionHandler {

      /**
      * @desc 处理基础异常
      * @author codecho
      * @date 2022-11-23 15:59:48
      * @param ex 基础异常
      */
      @ExceptionHandler(value = {CommonException.class})
      public CommonResponse exceptionHandler(CommonException ex) {
      // 输出日志
      log.error("CommonException: {}", ex);

      return CommonResponse.fail(ex.getCode(), ex.getMessage());

      }

      // ...处理自定义或常见的异常

      /**
      * @desc 处理其他异常
      * @author codecho
      * @date 2022-11-23 16:00:05
      * @param ex 其他异常
      */
      @ExceptionHandler(value = {Exception.class})
      public CommonResponse exceptionHandler(Exception ex) {
      // 输出日志
      log.error("Exception: {}", ex);

      return CommonResponse.fail(ex.getMessage());
      }

      }
  3. 修改CommonResponseAdvice

    • CommonResponseAdvice.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      @RestControllerAdvice(basePackages = {"com.codecho.base.controller"})
      public class CommonResponseAdvice implements ResponseBodyAdvice {

      private static final String COMMON_RESPONSE = "CommonResponse";

      @Override
      public boolean supports(MethodParameter returnType, Class converterType) {
      return true;
      }

      @Override
      public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
      // 配置统一异常处理或controller返回类型为CommonResponse,直接返回,不需要再次封装
      if (Objects.nonNull(body) && COMMON_RESPONSE.equals(body.getClass().getSimpleName())) {
      return body;
      }

      return CommonResponse.success(body);
      }
      }
  4. 修改UserController的getById方法

    • UserController.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      @GetMapping(value = "/{id}")
      public UserDO getById(@PathVariable(value = "id") Long id) {
      UserDO user = userService.getById(id);
      if (null == user) {
      throw new CommonException(ResponseStatusEnum.USER_NOT_FOUND);
      }

      return user;
      }
  5. 重新运行程序,测试controller返回结果

补充

有时候不是所有的接口都必须返回统一的格式,某些情况下我们想要自定义controller的返回,但是配置统一结果响应后,它是对注解@RestControllerAdvice的属性basePackages的包和其子包生效的。如果想要在某些使其不生效,可以考虑使用自定义注解和重写ResponseBodyAdvice接口的supports方法来实现

  1. 创建自定义注解@MyResponse

    • MyResponse.java

      1
      2
      3
      4
      5
      @Target(ElementType.METHOD)
      @Retention(RetentionPolicy.RUNTIME)
      public @interface MyResponse {

      }
  2. 修改CommonResponseAdvicesupports方法

    • CommonResponseAdvice.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      @Override
      public boolean supports(MethodParameter returnType, Class converterType) {
      // 如果方法上有@MyResponse注解,返回false,不需要设置统一响应结果
      if (returnType.hasMethodAnnotation(MyResponse.class)) {
      return false;
      }

      return true;
      }
  3. UserController中创建一个带有@MyResponse注解的请求

    • UserController.java

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @MyResponse
      @GetMapping(value = "/mock")
      public UserDO mockUser() {
      UserDO user = new UserDO();
      user.setUsername("july");
      user.setMobilePhone("18756989090");
      user.setUserState(0);

      return user;
      }
  4. 重新运行程序,测试新的请求

    可以看到,添加自定义注解后,统一结果响应未生效

随着互联网的普及,尤其是移动互联网的发展,如今我们每个人都要和各种各样的网站、APP打交道,每天都会收到很多服务、营销等信息,有邮件、短信、微信推送等,其中短信应该是接触最频繁的,毕竟除了正经的短信外,还有很多不良信息甚至有害信息源源不断地发送到我们的手机上。

不知道大家有没有注意,我们经常可以在服务类短信的最后可以看到一个链接,通常是xx活动链接或xx账单链接,而且基本上都很简短,有些甚至只有几个字母,如下图中的中国电信链接,当我们点击链接时,往往会跳转到另一个网址,并且网址链接会比短信中的要长很多,这里其实就是使用的短链接。

  • 短信中的短链接

  • 实际跳转网址


为什么要使用短链接

  1. 短信文本过长会拆分成多条计费,而网址链接一般都比较长,使用短链接能有效降低企业成本

  2. 某些内容平台限制文本长度

  3. 降低网络传输成本,文本越长,消耗的网络资源也越多

  4. 将链接转为二维码,如果链接过长,生成的二维码会难以识别

  5. 某些平台对长链接识别不友好


短链接原理

  1. 根据上面图片可以看到,点击短链接后,会向服务器发起一个请求,服务器返回的状态码为302,并且在responseLocation属性返回了实际网址,这里用到的就是302重定向
  2. 重定向分为301重定向和302重定向,301是永久重定向,即首次访问后,每次访问不再请求服务器,而是直接根据浏览器缓存跳转到对应的页面;302是临时重定向,即每次访问都会重新请求服务器
  3. 对长链接进行压缩,最常见的就是使用哈希,当然使用其他的算法也可以,比如MD5、SHA等,对于短链接我们关心的是效率,所以使用一般的哈希函数就可以了
  4. 生成短链接后,我们需要在数据库保存短链接和长链接的映射关系,这样在重定向时才知道应该向浏览器返回哪个长链接

知道了原理后,我们可以自己模仿实现上面的效果


开始动手

  1. 创建一个web工程,这里为了方便,直接创建一个SpringBoot项目,引入guava和redis依赖,我们使用guava的哈希算法生成哈希值,使用redis来保存链接数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    <dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>31.0.1-jre</version>
    </dependency>

    <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
  2. 编写BizController负责生成短链接

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @RestController
    @RequestMapping(value = "/biz")
    public class BizController {

    @Resource
    StringRedisTemplate stringRedisTemplate;

    @GetMapping(value = "/generateShortUrl")
    public String generateShorUrl() {
    // 这里使用SpringBoot的官方文档页面作为要重定向的长链接网址
    String longUrl = "https://docs.spring.io/spring-boot/docs/2.7.11/reference/html/getting-started.html#getting-started";

    // 使用guava提供的哈希工具类对长链接进行压缩,这里使用的是murmur3_32_fixed算法,它会生成32位的哈希值,当然也可以使用其他哈希算法,
    String shortUrl = Hashing.murmur3_32_fixed().hashBytes(longUrl.getBytes()).toString();

    // 将短链接和长链接的映射关系保存到redis中,实际开发中可以保存到MySQL数据库,这里为了演示方便直接使用redis
    stringRedisTemplate.opsForValue().set(shortUrl, longUrl);

    return "shorUrl: http://127.0.0.1:8080/short/" + shortUrl;
    }

    }
  3. 编写ShortUrlController负责重定向

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @RestController
    @RequestMapping(value = "/short")
    public class ShortUrlController {

    @Resource
    StringRedisTemplate stringRedisTemplate;

    @GetMapping(value = "/{url}")
    public void redirect(@PathVariable("url") String url, HttpServletResponse response) {
    // 从数据库获取短链接对应的长链接,这里用redis演示
    String longUrl = stringRedisTemplate.opsForValue().get(url);
    if (Strings.isEmpty(longUrl)) {
    response.setStatus(HttpStatus.NOT_FOUND.value());
    }

    // 设置状态码为302
    response.setStatus(HttpStatus.FOUND.value());
    // 设置Location为长链接
    response.setHeader(HttpHeaders.LOCATION, longUrl);
    }

    }
  4. 测试


总结

  1. 使用短链接可以为我们带来很多好处
  2. 生成短链接的方式有很多,除了哈希算法外,还可以使用数据库自增id、redis自增、UUID、雪花算法等,每种方法各有有点,根据自身需求合理选择
  3. 生成的短链接需要保证其唯一性,使用MySQL数据库,可以为短链接字段设置唯一索引,使用其他方式需要自己实现方法保证短链接唯一