码农戏码

新生代农民工的自我修养

0%

最近又收到同事反馈 flow 卡住跑不动了

上一次遇到这类反馈,排查下来是【多线程饥饿死锁】导致的

本着同类问题不能再出现的原则,这次需要彻底排查根因,避免重蹈覆辙

整个flow是基于LiteFlow框架实现,为了性能,每个Flow创建一个线程池,每个节点 NodeComponent 内部使用多线程执行

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
Flow {
ThreadPoolExecutor nodeExecutor = new ThreadPoolExecutor

}

NodeComponent {
List<Task> taskList = new List();

taskList.add(task);

List<Future<TaskResult>> futures = nodeExecutor.invokeAll(taskList);
}

第一次卡住

某个flow中,有个特殊的形态,Node 与 Node 之间会有逻辑依赖关系
如图,NodeD 会依赖 NodeC
当 NodeD 任务数量超过线程池大小时,并且所有线程被分配给 TaskD 时,被依赖的 TaskC 处于资源饥饿
造成整个 flow 卡住

这儿卡住的两个条件:
1、任务数量超过了线程数量
2、所有线程都被分配给了 TaskD

解决:

对于 NodeD ,使用独立线程池,不再使用flow全局线程池,线程池隔离
这样 NodeC 和 NodeD 的线程池竞争关系就没有了

第二次卡住

这次卡住,对照 flow 分析,底层道理是一样的,只是表现形式不一样

运行 flow 本身有一根主线程,当有 when 条件时,会使用内置的 when 线程池

如果不配置,默认 when 线程池 是 16个线程
并且所有 flow 的 when 条件都使用同一个 when 线程池

按上图,如果是一个线程池 =3 ,执行 flow 多次时,按图中分配,所有线程都在等待 TaskC 的完成

可当 TaskC 完成后,when 线程 都在其它地方,等待完成的任务 已经在线程线队列中

此时,只能等待 线程的 timeout , 而 默认 timeout = 3600s

解决

原因同理第一次卡住,只是第一次是因为自定义的线程池NodeExecutor,线程池任务里阻塞等待同池任务

而这一次,是 LiteFlow 底层的 when 线程池

解决的办法还是独立线程池,LiteFlow 提供了配置

在LiteFlow 的源码中,有一段注释:

// 如果设置了线程池隔离,则每个 when 都会有对应的线程池,这是为了避免多层嵌套时如果线程池数量不够时出现单个线程池死锁。用线程池隔离的方式会更加好
// 如果 when 没有超多层的嵌套,还是用默认的比较好。
// 默认设置不隔离。也就是说,默认情况是一个线程池类一个实例,如果什么都不配置,那也就是在 when 的情况下,全局一个线程池。

其实也应征了上述分析。

目前liteflow设计里when线程池,如果你不单独设置自定义线程池,那么就会用默认的线程池。而这个线程池,是所有的when共同一个。

LiteFlow从2.11.1开始,提供一个liteflow.when-thread-pool-isolate参数,默认为false,如果设为true,则会开启WHEN的线程池隔离机制,这意味着每一个when都会有单独的线程池。这个特性对于运行复杂的嵌套when时是可以提升运行速度的且规避掉一些锁的问题。

可以如下配置来开启:

1
liteflow.when-thread-pool-isolate=true

不过当前使用Liteflow 版本 还有线程池泄漏的bug,翻看源码:

在 并行 编排 时, 每个 WHEN 条件去获取 Executor 有bug。
当有 condition 时,这个bug就会被触发。

排查

怎么定位这种问题

1、在 grafana 监控面板看查看 thread 数量,是否在增长,一直增长那可能是线程池泄漏,有时会以 OOM 形式暴露

2、统计线程状态,查看大量线程处于BLOCKED/WAITING,确认线程耗尽

比如上面卡住的情况,通过线程状态,看到大量的 WAITING 线程

系统中有部分业务是使用C++实现,然后Java 通过 JNI 调用

但 C++ 只构建了 Windows 的 xxx.dll 和 Linux 的 libxxx.so 却没有 macOS 的 libxxx.dylib`

作为 Mac 用户,就麻烦了,一直在想个解决的办法,不然就得切换到Windows ,痛苦。

第一步解决本地运行问题
在本地开发完程序,可以发布到 docker 中,再进行功能性调试,解决本地不能运行问题。

在不涉及具体模块功能开发时,这算是解决问题了

但要经常进行代码开发这部分相关功能,就很麻烦。
不仅仅运行验证一下程序结果,还得开发调试,验证过程数据,总不能每一步都要加日志再发布 docker 调试,整体效率太低了。

最近找到了一种更好的方式:
Mac 上调试 JNI = 在 Linux 容器里跑 native + Mac IDEA 远程调试接进去。
Docker Desktop on Mac 跑的就是 Linux 容器,.so 能在里面正常加载。

实现方案

整体基于 JPDA(Java Platform Debugger Architecture)架构,通过 JDWP(Java Debug Wire Protocol)协议让外部调试器(如 IntelliJ IDEA)连接到正在运行的 JVM 上,实现远程断点调试、变量查看、单步执行等功能。

服务端增加 agent 参数

1、在发布 docker 时,容器 JVM 上加的 agent 参数就是开启 JDWP:

1
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005

下面逐段解释每个参数的作用:

-agentlib:jdwp

  • 指定加载 Java 调试代理库(Java Debug Wire Protocol Agent)。
  • 这是 JVM 内置的调试支持模块,用于与外部调试器通信。

transport=dt_socket

  • 指定调试器与 JVM 之间的通信方式为 Socket 网络传输。
  • 即通过 TCP/IP 协议进行连接,支持本地或远程调试。

server=y

  • 表示当前 JVM 作为 调试服务端,监听指定端口,等待调试器(客户端)来连接。
  • 如果设为 server=n,则 JVM 会主动去连接调试器(较少用)。

suspend=n

  • 表示 JVM 启动时 不暂停,应用程序会立即开始执行。
  • 如果设为 suspend=y,JVM 会挂起,直到调试器连接后才继续执行(常用于调试启动阶段的问题)。

address=*:5005

  • 指定调试器监听的 端口号为 5005。
  • *: 表示监听 所有网络接口(即允许来自任意 IP 的连接),这是 JDK 9 及以上版本的写法。
    • 在 JDK 8 及以下版本中,通常写为 address=5005(只监听本地)。

原理:JVM 内置的 JDWP agent 暴露一个端口,IDEA 的 “Remote JVM Debug” 连上后,按 JDWP 协议下发”设断点 / 读变量 / 单步 / 取栈帧”等命令,JVM 在断点处挂起并把线程状态回传给 IDEA,于是能在 Mac 的 IDE 里像本地一样断点调试容器里的 Java 代码。

IntelliJ IDEA 接入

找到 “Remote JVM Debug” 配置入口

JPDA

JPDA(Java Platform Debugger Architecture) 是 Java 平台调试架构的缩写,通过 JPDA 提供的 API,开发人员可以方便灵活的搭建 Java 调试应用程序。

组成模块

JPDA 主要由三个部分组成:Java 虚拟机工具接口(JVMTI),Java 调试线协议(JDWP),以及 Java 调试接口(JDI)

这三个模块把调试过程分解成几个很自然的概念:调试者(debugger)和被调试者(debuggee),以及他们中间的通信器。

被调试者运行于我们想调试的 Java 虚拟机之上,它可以通过 JVMTI 这个标准接口,监控当前虚拟机的信息;
调试者定义了用户可使用的调试接口,通过这些接口,用户可以对被调试虚拟机发送调试命令,同时调试者接受并显示调试结果。
在调试者和被调试着之间,调试命令和调试结果,都是通过 JDWP 的通讯协议传输的。

所有的命令被封装成 JDWP 命令包,通过传输层发送给被调试者,被调试者接收到 JDWP 命令包后,解析这个命令并转化为 JVMTI 的调用,在被调试者上运行。类似的,JVMTI 的运行结果,被格式化成 JDWP 数据包,发送给调试者并返回给 JDI 调用。而调试器开发人员就是通过 JDI 得到数据,发出指令

JVMTI

JVMTI(Java Virtual Machine Tool Interface)即指 Java 虚拟机工具接口,它是一套由虚拟机直接提供的 native 接口,它处于整个 JPDA 体系的最底层,所有调试功能本质上都需要通过 JVMTI 来提供。通过这些接口,开发人员不仅调试在该虚拟机上运行的 Java 程序,还能查看它们运行的状态,设置回调函数,控制某些环境变量,从而优化程序性能

JDWP

JDWP = Java Debug Wire Protocol,Java 调试线协议。它是调试器和被调试 JVM 之间通信的协议规范(由 JVM TI / JVMDI 定义)。

JDI

JDI(Java Debug Interface)是三个模块中最高层的接口,在多数的 JDK 中,它是由 Java 语言实现的。 JDI 由针对前端定义的接口组成,通过它,调试工具开发人员就能通过前端虚拟机上的调试器来远程操控后端虚拟机上被调试程序的运行

在《[[SaaS部署标准化与定制化]]一》中,落地实践中总体原则:构建时用 Maven 控制依赖是否打进包;运行时用 Spring 条件装配隔离缺失的类

看似很简单的措施,其实底层映射出不少经典设计思想:面向接口编程、SOLID中的DIP原则、DDD供应商模式

下面再谈另一种现实场景:微服务整合部署

标准化

在公有云上,我们部署的是标准化SaaS,按客户价值分类,这些服务客户是我们的 “现金奶牛“。

这类客户应该覆盖大部分的用户群体,比如 GitHub 免费用户,或是 Salesforce 标准版这样的用户。对于这类客户,目标就是降低成运营成本。比如通过自动化脚本实现低成本运维。再比如,通过自助式服务来解决服务开通等问题。

这类客户没有特殊需求,都是产品标准功能。标准化能把高昂的研发成本摊薄到海量用户上,就可以降低边际成本,实现规模化盈利。

此时,我们需要的客户数量越多越好,而从架构角度,稳定性就是关键因素。至少保证“4个9的高可用性”。

为了主业务的高可用、高性能。我们通常使用拆分、隔离原则。
在DDD拆分域中,通常分为:核心域、支撑域、通用域。

对应组织层面,就会有各个业务团队,各个中台团队,基础架构团队。把一个服务,拆分成上百个微服务来支撑整个系统的良好运行。

定制化

标准客户是”现金奶牛“,定制化就是服务于高价值客户。

如果不是高价值客户,也没必要折腾定制化。高价值也并不单单是价格,也可能是市场标杆价值

标准化部署时,为了高可用,核心业务与非核心业务要隔离,每个服务都至少需要2个pod,
为了应对突发高峰,还有冗余资源

形象一点,米国入侵战争时,别看深入敌后的就一支海豹突击队,其实后面可能有一个航母编队在支撑。

这些成本通过规模化都能拉平。而在定制化部署时,云资源是客户提供的,每次部署前,都要提前申请,而且申请是可能会被拒绝的,并且在招标时,这也是一个中标重要因素。

在公有云,如果有100个微服务,至少需要200个pod,1个pod如果至少需要1核 CPU,那至少需要200核。

这时应对方案
1、压缩单个pod资源,比如低流量服务只配置0.5核
2、减少pod数量,比如只要50个微服务,把为了灵活性,运维性的服务减掉,像配置中心服务

而业务微服务,把多个微服务整合到一个微服务中,使用本地调用平替掉远程调用,达到减少微服务部署的要求

在 Spring Boot 中实现本地调用与 Feign 远程调用的无缝切换,核心思路是面向接口编程,并结合 Spring 的条件装配机制(Conditional)。通过读取配置文件中的属性,让 Spring 容器在启动时自动决定注入本地实现还是远程代理。

我们假设项目的 GroupId 为 com.example,整体分为三个核心模块:product-api、product-core 和 order-service(消费方)。


1. 模块一:product-api(轻量级契约模块)

职责:仅存放对外暴露的接口定义、DTO/POJO 实体类、Feign 客户端接口。
依赖:极轻量,只依赖 Web 和 OpenFeign。

1
2
3
4
5
6
7
8
9
10
11

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 纯契约接口(不带 @FeignClient 注解)
public interface ProductApi {
@GetMapping("/product/{id}")
ProductInfo getProductById(@PathVariable("id") Integer id);
}

// 2. Feign 客户端(继承契约接口)
@FeignClient(name = "product-service")
public interface ProductFeignClient extends ProductApi {
// 无需重复写 @GetMapping,直接继承自 ProductApi
}

// 3. DTO 实体类
@Data
public class ProductInfo {
private Integer id;
private String name;
}

2. 模块二:product-core(核心业务模块)

职责:存放真正的业务逻辑、数据访问层。
依赖:依赖 product-api、MyBatis、数据库驱动等。

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

<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>product-api</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
</dependency>

</dependencies>

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. 核心业务服务(实现契约接口)
@Service
public class ProductCoreServiceImpl implements ProductApi {
@Autowired
private ProductMapper productMapper;

@Override
public ProductInfo getProductById(Integer id) {
// 真正的业务逻辑只在这里写一次
return productMapper.selectById(id);
}
}

// 2. 数据访问层
@Mapper
public interface ProductMapper {
@Select("SELECT * FROM product WHERE id = #{id}")
ProductInfo selectById(Integer id);
}

3. 服务提供方:product-service(独立部署的微服务)

职责:作为独立服务运行,对外暴露 HTTP 接口。
依赖:依赖 product-core、Spring Boot Web。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. Controller 仅作为协议适配层,实现契约接口
@RestController
public class ProductController implements ProductApi {
@Autowired
private ProductCoreServiceImpl productCoreService;

@Override
public ProductInfo getProductById(Integer id) {
// 参数和路径已经在 ProductApi 中定义好了,这里直接委托给 Core
return productCoreService.getProductById(id);
}
}

// 2. 启动类(记得配置 MapperScan)
@SpringBootApplication
@MapperScan("com.example.product.core.mapper")
public class ProductServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ProductServiceApplication.class, args);
}
}

4. 消费方:order-service(核心亮点:无缝切换)

职责:调用商品服务,支持通过配置一键切换本地/远程调用。
依赖:

  • 始终依赖 product-api(为了获取 ProductApi 接口和 ProductFeignClient)。
  • 按需依赖 product-core(如果配置为 local,则必须引入;如果仅用 remote,可不引入)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14

<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>product-api</artifactId>
<version>1.0.0</version>
</dependency>

<dependency>
<groupId>com.example</groupId>
<artifactId>product-core</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>

核心代码(条件装配配置类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 消费方的配置类,负责决定注入哪个实现
@Configuration
@EnableFeignClients(basePackages = "com.example.product.api") // 扫描 Feign 客户端
public class ProductClientConfig {

// 1. 远程模式:注入 Feign 代理(Feign 本身也是 ProductApi 的实现类)
@Bean
@ConditionalOnProperty(name = "service.product.mode", havingValue = "remote")
public ProductApi remoteProductApi(ProductFeignClient feignClient) {
return feignClient;
}

// 2. 本地模式:直接注入 CoreService(跳过 Web 层,性能最高,零重复代码)
@Bean
@ConditionalOnProperty(name = "service.product.mode", havingValue = "local", matchIfMissing = true)
public ProductApi localProductApi(ProductCoreServiceImpl coreService) {
return coreService;
}
}

消费方业务代码(完全解耦):

1
2
3
4
5
6
7
8
9
10
11
// 订单服务,只认 ProductApi 接口
@Service
public class OrderService {
@Autowired
private ProductApi productApi; // Spring 会根据配置自动注入本地或远程实现

public void createOrder(Integer productId) {
ProductInfo product = productApi.getProductById(productId);
System.out.println("订单创建成功,商品名称:" + product.getName());
}
}

消费方配置文件:

1
2
3
4
# application.yml
service:
product:
mode: local # 切换为 remote 时,Spring 会自动将 Feign 代理注入到 OrderService 中

这套架构是目前微服务演进中最标准的“SDK 抽取模式”,既保留了微服务的灵活性,又兼顾了单体的高性能。

一个SaaS,但凡涉及客户现场部署场景,打包必然就会面临标准化与定制化两种路径

总有各种各样的原因来强迫架构的演化,不以架构师的意志转移

标准化

首先不管是架构师还是公司,都希望只有一个标准化。

标准化能把高昂的研发成本摊薄到海量用户上,就可以降低边际成本,实现规模化盈利。

开发团队会预埋大量的if-else或策略模式,把所有可能的变化都抽象成配置文件(如YAML、JSON)。用户通过修改配置来“定制”,但这些配置不改变程序本质,只是给同一套代码换了不同的“皮肤”或“流程顺序”

定制化

定制化的核心技术是 “模块化”与“插件化”。
开发者会设计出清晰的“内核”和“外挂”机制。基础功能标准化,但关键的业务逻辑做成可替换的“插件”或“微服务”。定制时,只需重写或新增一个插件模块,而不用动整个底层引擎。

其中插件化,类似DDD中供应商模式。

model层有接口,而具体实现在各个“供应商”

比如发消息,有微信、钉钉、飞书。

总体原则:构建时用 Maven 控制依赖是否打进包;运行时用 Spring 条件装配隔离缺失的类

Maven 层面控制依赖打包

1、把功能依赖声明为 optional

在 common/pom.xml 中,把仅部分客户使用的依赖声明为 optional:

1
2
3
4
5
<dependency>  
<groupId>com.larksuite.oapi</groupId>
<artifactId>oapi-sdk</artifactId>
<optional>true</optional>
</dependency>

作用:common 模块本身可以编译,但依赖不会传递到 main 模块

<optional>true</optional> 控制的是依赖传递性,不是“当前模块自己不打这个 jar”

含义:common 模块自己可以使用这个依赖来编译和运行,但不会把这个依赖传递给依赖 common 的其他模块

假设模块结构:

1
main  -->  depends on  -->  common  -->  javacv-platform (optional=true)
情况 不加 <optional> 加了 <optional>true</optional>
common 编译时 能使用 javacv-platform 能使用 javacv-platform
main 是否会自动继承 javacv-platform 会 不会
如果 start 想要这个依赖 什么都不用做 必须在 main/pom.xml 里显式声明

main 作为 Spring Boot 模块:默认会把所有非 optional 的传递依赖打进 fat jar。如果 common 里的 javacv-platform 是 <optional>true</optional>,main 不会自动继承它,所以最终 fat jar 里不会包含 javacv-platform,除非 start 显式引入。

在 main/pom.xml 按 profile 引入依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<profiles>
<profile>
<id>customer-a</id>
<dependencies>

<dependency>
<groupId>org.bytedeco</groupId>
<artifactId>javacv-platform</artifactId>
</dependency>
</dependencies>
</profile>

<profile>
<id>customer-b</id>

</profile>
</profiles>

<optional> 和 <scope>provided</scope> 都能“不传递给下游”,但它们对打包的影响完全不同。关键在于:

  • <optional>true</optional> 只是一个传递性开关,不改变依赖本身的 scope(默认仍是 compile)。它影响的是“下游模块会不会自动继承这个依赖”,不影响“声明模块自己运行时/打包时带不带它”。
  • <scope>provided</scope> 改变的是依赖的生命周期范围:编译/测试可见,运行时不可见,普通 jar/war 打包时不带它。但 Spring Boot 的 spring-boot-maven-plugin 默认会把 provided 也打进 fat jar(它只默认排除 system scope),所以在本项目的 Spring Boot fat jar 里,provided 并不能自动把依赖排除掉。

Spring 官方文档明确说明:

By default, both the repackage and run goals will include any provided dependencies that are defined in the project. A Spring Boot project should consider provided dependencies as container dependencies that are required to run the application.

即 spring-boot-maven-plugin 的 repackage 和 run 目标默认就会包含所有 provided 依赖,官方将其视为“运行所需的容器依赖”。

正确排除方式

若需排除特定 provided 依赖,需在插件中显式配置:

1
2
3
4
5
6
7
8
9
10
11
12
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>com.aliyun.odps</groupId>
<artifactId>odps-sdk-core</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>

Spring 层面隔离缺失依赖

1
2
3
4
5
6
7
8
9
@Service
@ConditionalOnClass(FFmpegFrameRecorder.class)
public class JavaCvVideoEncoderService implements VideoEncoderService {

@Override
public Map<String, Integer> encodeToMp4(String imageDir, String outputPath, double fps, int bitrate) {

}
}

关键点:

  • @ConditionalOnClass(FFmpegFrameRecorder.class):classpath 中没有 javacv 类时,该 Bean 不会被创建,也不会触发类加载。

调用方注入 Optional

1
2
3
4
5
6
7
8
@RestController
public class ReviseDataController {

private final Optional<VideoEncoderService> videoEncoder;

public ReviseDataController(Optional<VideoEncoderService> videoEncoder) {
this.videoEncoder = videoEncoder;
}

论起大运法
凡起大运,俱从所生之日,阳男阴女顺行,数至未来节。阳女阴男逆行,数已过去节,但折除三日为一岁。

阳男阴女顺运,

假如甲子年,甲己之年丙作首,正月建丙寅,初一日立春后一日生男,顺数至二月节惊蛰,且得三十日,起十岁运,顺行丁卯。

如乙丑年,乙庚之岁成为头,正月起戊寅,初一立春,十八日生女,顺数至二月惊蛰节止,得四三十二日,起四岁运,顺行己卯。余皆仿此。阴男阳女逆运。

假如乙丑年,乙庚之岁戊为头,正月起戊寅,初一日立春后十五日生男。逆数至初一立春节止,得五三十五日,起五岁运,运行丁丑。

如甲子年,甲己之年丙作首,正月雨寅,初一立春后十日生女,逆数至初一日立春止,得九日,三三单九日,起三岁运,逆行乙丑。

余皆仿此。若多一日,减一日;少一日,增一日。

【眉批:如初一日午时生,若卯时立春,便作正月;若寅时生,未交春,还上年十二月节推命。如正月初一子时生,上四刻仍作上年十二月夜子时生,下四刻方作正月初一子时推。正谓神仙难断夜子时。】

多一日,减一日;少一日,增一日

示例:

  • 如果到节气有 15 天

  • 15 ÷ 3 = 5

  • 起运年龄 = 5 岁

  • 如果到节气有 17 天

  • 17 余 2,少一日,增一日 → 18 天

  • 18 ÷ 3 = 6

  • 起运年龄 = 6 岁

  • 如果到节气有 16 天

  • 16 余 1,多一日,减一日 → 15 天

  • 15 ÷ 3 = 5

  • 起运年龄 = 5 岁

经典示例:

甲子年,甲己之年丙作首,正月丙寅,初一立春后十日生女,逆数至初一日立春止,得九日,三三单九日,起三岁运,逆行乙丑。

  • 年柱:甲子(阳年)
  • 性别:女 → 阴男阳女逆行
  • 月柱:丙寅
  • 出生日距上一个节(立春)共 9 天
  • 9 ÷ 3 = 3
  • 第一步大运:从月柱丙寅逆行一步 → 乙丑
  • 故:3 岁起运,逆行乙丑

距离关心两个向量“隔多远”,余弦相似度关心两个向量“方向像不像”。
它们让模型能做搜索、推荐、分类和聚类

欧氏距离

欧氏距离(Euclidean Distance)其实就是我们日常生活中最熟悉的 “直线距离”。它衡量的是两点之间的最短路径长度。

余弦相似度

余弦相似度通过计算两个向量之间的夹角余弦值来衡量它们的相似性。

余弦值越接近1,夹角越小,说明两个向量越相似;
余弦值越接近-1,夹角越大,说明两个向量越不相似;
余弦值为0时,两个向量正交,表示它们之间没有相关性。

在计算余弦相似度时,我们只关注向量之间的夹角,而不考虑它们的长度。
因此,即使两个向量的长度不同,只要它们同向,余弦相似度依旧能够达到 1。

余弦公式

  • A⋅B 表示向量 A 和向量 B 的点积。
  • ||A|| 代表向量 A 的长度(模)。
  • ||B|| 代表向量 B 的长度(模)。
  • θ 是两个向量之间的夹角

为什么公式中有模

上面提到向量的夹角,与向量长度无关,但公式里面为什么会出现模呢?还得去计算模?

为什么不能直接“不看模长”?

向量的“模”(长度)和“方向”是捆绑存储在一个坐标里的。比如向量 A = (3, 4):

  • 它的模长是 5 (3平方+4平方​)再开根
  • 它的方向是 53.1°

在计算机眼里,它就是一个数组 [3, 4],模和方向是混在一起的二进制数字。
你没法写一行代码说“把模去掉,只留方向”,因为数字 3 既贡献了方向,也贡献了长度。

如果你直接拿 [3, 4] 去算点积,结果里必然混着长度的影响。所以,我们必须通过数学运算把“长度”单独提取出来。

为什么公式里要“先乘起来,再除掉”?

第一步(分子:计算点积):先让两个向量发生完整的交互。此时,A的长度、B的长度、以及它们的夹角,三者全部被混在一起,揉成了一个数值(点积)。
第二步(分母:除以模长的乘积):既然点积里混杂了“A的长度 × B的长度 × 夹角”,那我就在外面强行除以“A的长度 × B的长度”。

相当于:

(∣∣A∣∣×∣∣B∣∣×cosθ)​ / ∣∣A∣∣×∣∣B∣∣ = cosθ

为什么必须“计算模长”,而不是“忽略模长”?

因为你无法凭空忽略模长,你只能用模长来抵消自身。

  • 如果你不计算分母 ∣∣A∣∣×∣∣B∣∣,你得到的就是带长度的点积,大向量永远欺负小向量。
  • 你计算分母,不是为了“看重”模长,恰恰是为了 “利用”模长去“消除”模长。

为什么不使用Math.cos(θ )

在编程中不调用自带的 Math.cos()函数,实在令人惊讶。
这是因为该函数是直接计算出夹角的余弦值,而无需先求出夹角本身。
如果使用 Math.cos(),我们就需要先用诸如 Math.acos()之类的反三角函数来求出角度,这不仅没有必要,而且计算成本更高。

为什么余弦相似度与词义有相关性

早期的实践归纳(启发式):
在早期的信息检索(如 TF-IDF、LSA 潜在语义分析)中,工程师们发现:如果把文档变成词频向量,用余弦相似度来匹配,效果出奇的好。这完全是实践归纳出来的经验法则。人们发现高维空间中的“几何邻近性”恰好对应了人类的“语义相关性”。

后期的理论证明(统计学习理论):
到了 Word2Vec 和深度学习时代,这不再仅仅是经验。学者们(如 Mikolov, Bengio 等)通过分布式假设(Distributional Hypothesis)——“上下文相似的词,语义相似”——给出了理论解释。

随后,大量的数学证明表明:通过最大化预测上下文的概率(如 Skip-gram 的负采样目标函数),模型实际上是在隐式地分解词共现矩阵(如 PMI 矩阵)。

这就从理论上证明了:为什么优化这个损失函数,最终会让相似的词在向量空间中靠近。

Reference

How does cosine similarity work?

在AI里面,常有 “把高维向量用于AI的搜索、推荐和语义理解” 的表达

这儿的高维向量是什么意思?

低维是我们生活的世界,直观但容量有限;
高维是 AI 的“大脑”,反直觉但拥有极其庞大的信息存储和分类能力。

AI 的本质,其实就是用低维的输入,在内部构建高维的向量空间,最后再输出低维的结果(比如一个分类标签或一句回答)。

而且“高维”和“低维”其实是一个相对的概念,并没有一条绝对的界线。在数学和机器学习中,它们有着不同的特性和应用场景。

什么是低维向量?(通常指 1~3 维)

低维向量就是我们日常生活中最熟悉的几何空间:

  • 1维(标量/直线):比如你的体重(70kg),或者温度计上的刻度。
  • 2维(平面):比如地图上的经纬度 (x,y)(x,y) ,或者一张黑白照片的像素矩阵。
  • 3维(立体空间):比如你所在房间的长宽高 (x,y,z)(x,y,z) ,或者一个 3D 游戏模型。

特点:在低维空间中,人类的直觉是完全适用的。两点之间直线最短,空间看起来很“空旷”,距离和角度的计算非常直观。

什么是高维向量?(通常指几十、几百到上万维)

当向量的维度 dd 变得很大时(比如 d≥100d≥100 ),我们就进入了高维空间。

  • AI 中的 Embedding:比如 OpenAI 的文本向量通常是 1536 维,图像模型(如 CLIP)的向量可能是 768 维。
  • 推荐系统:如果用 1000 个标签来描述用户偏好,这就是一个 1000 维向量。
  • 原始数据:一张 1000x1000 的彩色图片,如果直接拉平,就是一个 3,000,000 维的向量。

特点:高维空间完全反人类直觉,这也是为什么它听起来很神秘的原因。

高维空间有哪些“反直觉”的神奇现象?

高维向量之所以强大,正是因为低维做不到的事情,高维能做到:

  • 维度灾难(Curse of Dimensionality):
    在 3D 空间里,数据很容易挤在一起。但在 1000 维空间里,空间大到难以想象,所有的数据点都变得极其稀疏。这导致在高维空间中,所有点之间的距离都变得差不多远。这就是为什么高维搜索不能直接算距离,必须用特殊的算法(如 HNSW、FAISS)。
  • 空间变得像“刺猬”:
    在低维空间,两个随机向量的夹角可能是各种度数;但在高维空间,两个随机向量的夹角几乎总是接近 90 度(正交)。这意味着高维空间里有无数个互相垂直的方向,模型可以把“苹果”和“香蕉”放在完全正交的方向上,互不干扰地存储海量信息。
  • 更容易被“切开”(线性可分):
    在 2D 平面上,如果红点和蓝点像甜甜圈一样嵌套在一起,你画一条直线是切不开它们的。但在 3D 空间中,你可以用一个平面把它们切开。维度越高,数据越容易被一条直线/一个超平面分开。这就是为什么 AI 分类任务在高维向量上做得特别好。

高维和低维的相互转换(降维)

既然高维这么好,为什么我们还要关心低维?因为高维太占内存,且人类看不懂。
所以,AI 领域有一个极其重要的分支叫降维(Dimensionality Reduction),比如 PCA(主成分分析)或 t-SNE。

  • 目的:把 1000 维的向量,压缩成 2 维或 3 维。
  • 代价:压缩必然丢失信息,但好的算法能保留最核心的“结构”(比如聚类关系)。
  • 应用:我们在论文里看到的那些非常漂亮的、五颜六色的散点图(展示 AI 把猫和狗分开了),其实都是把高维向量降维到 2D 平面后画出来的。

标量

标量就是一个数字

只有大小概念,没有方向的概念。通过一个具体的数值就能表达完整。比如:重量、温度、长度、提及、时间、热量等都数据标量

向量

向量是数学和物理学中的一个基本概念,表示既有大小又有方向的量。

与标量(只有大小没有方向)不同

向量主要有2个维度:大小、方向

在数学里,向量可以用一串数字表示,比如 [3, 4]。这串数字可以代表空间中的一个点或者一个方向

矩阵

矩阵是二维的数字表格

矩阵可以看作是”多个向量的堆叠”:

3×4 的矩阵 = 3 个长度为 4 的行向量
或者 = 4 个长度为 3 的列向量

矩阵乘法

可乘条件‌:设 A 为 m×n 矩阵,B 为 n×p 矩阵,只有 A 的列数 (n) 等于 B 的行数 (n) 才能相乘
结果维度‌:乘积 C=AB 是 m×p 矩阵,保留第一个矩阵的行数和第二个矩阵的列数 。
‌元素计算‌:C 的第 i 行第 j 列元素等于 A 的第 i 行与 B 的第 j 列对应元素乘积之和。【点积(Dot Product)】

矩阵乘法的维度规则:

1
[A, B] × [B, C] = [A, C]

中间的维度必须相同(都是 B),结果取两边的维度。

3行2列矩阵与2行3列矩阵的相乘

举例:

矩阵加法

只有同行数同列数的矩阵才可以相加,结果也是同样尺寸的矩阵,其中的每个元素都是相加矩阵对应位置的元素之和

矩阵加法的一个例子,运算法则比较简单

Transformer中

标量:学习率、温度参数
向量:一个 token 的 embedding
矩阵:一批 token 的 embedding、权重矩阵

现在玩股票有个坏习惯,每次不是自己看盘复盘得出结论,而是看微博,去看别人的观点做结论

表多了时间就不准

而且关注人多,观点也是乱七八糟,脑子就乱了

每个人都说自己预测得多准确

想着来写个统计数据吧

核心思想:根据发微博的时间,交叉验证一下,后面几天博主所阐述的观点的正确率

只统计了短期的,1日 + 5日 + 10日

所以会有偏差,比如某人说未来一个月都是涨势,但可能前10日都在跌,后20日都在涨,那这个统计就不准确了

创建一个Skill,来实现这个需求

博主预测股市正确率看板(1日 + 5日 + 10日后验)


name: weibo-stock-monitor
description: |
微博股票观点监控技能。从指定微博分组抓取财经博主帖子,AI提取股票观点(做多/做空/中性),并用真实行情数据交叉验证博主观点与市场趋势的一致性。输出 Markdown 日报 + HTML 暗色主题看板。

触发场景:用户要求监控微博财经博主观点、查看某分组股票讨论、做微博观点日报等。
示例输入:「监控微博股票观点」「财经分组日报」「微博博主多空分析」「抓取股票分组帖子」

前一段时间,Java服务端经常崩溃,每天基本需要重启两次

之前出现过内存泄漏,但这次通过grafana查看,各项指标看着也正常,内存没有大幅上升

不过在查看整个宿主机时,发现整台机器内存一直在高位运行

所以依然怀疑是内存因素

措施一:把Java应用配置上JVM参数,-Xmx,限制一下整个应用内存使用,Java部署在Docker里面,Docker没有做内存限制
默认使用的内存似乎是32G,不管在128G内存的宿主机上,还是1024G的宿主机上,都差不多是这个数字

当参加了JVM参数,-Xms25G 时,重启次数会减少,三天重启一次

说明,内存因素的确是存在的,但根本原因是什么?

在服务器上发现了一份文件:hs_err_pid781.log

日志是怎么产生的

hs_err_pid<进程号>.log 是 JVM 的默认安全机制——当 JVM 遇到无法恢复的致命错误时,会在进程崩溃前自动生成此文件,相当于 JVM 的”临终遗言”。

触发条件:

错误类型 是否生成 hs_err log 说明
SIGSEGV / SIGBUS(native 内存越界) ✅ 是 JVM 内部访问了非法内存地址
Native OOM(malloc 失败) ✅ 是 malloc 失败
JVM 内部断言失败 ✅ 是 JVM 自身逻辑检测到不一致
Java 堆 OOM(OutOfMemoryError) ❌ 否 这是可恢复的 Java 异常,应用可 catch
StackOverflowError ❌ 否 可恢复

不生成的情况: 可恢复的 Java 层异常(堆 OOM、StackOverflow)不会触发此文件,因为 JVM 还能继续运行。只有 JVM 自身内部崩溃(native 层无法继续)才会生成。

1
2
3
# There is insufficient memory for the Java Runtime Environment to continue.
# Native memory allocation (malloc) failed to allocate 32744 bytes for ChunkPool::allocate
# Out of Memory Error (arena.cpp:81), pid=1, tid=37

JVM 在执行 BulkRevokeBias(批量撤销偏向锁)操作时,需要通过 Arena 内存池分配 32KB 的原生内存,malloc 调用失败,JVM 无法继续运行,直接崩溃。

日志结构

通过分析,文本的整体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
┌─────────────────────────────────────────────┐
│ 1. 头部报错信息 (~27 行, 0.02%) │ ← 崩溃类型与建议
│ 2. SUMMARY (~7 行, 0.01%) │ ← 命令行/主机/时间
│ 3. THREAD (崩溃线程) (~28 行, 0.02%) │ ← 崩溃线程调用栈
│ 4. PROCESS (线程列表) (~40050行, 36.6%) │ ← 31952个线程详情 ⚠️最大段
│ 5. VM state / Mutex (~5 行, <0.01%) │ ← safepoint状态
│ 6. Heap (堆内存详情) (~3400行, 3.1%) │ ← G1 region逐个列出
│ 7. 运行时事件记录 (~70 行, 0.06%) │ ← 各种事件最近20条
│ 8. Dynamic libraries (~65734行,59.9%) │ ← native库内存映射 ⚠️最大段
│ 9. VM Arguments (~50 行, 0.04%) │ ← JVM启动参数
│ 10. SYSTEM (~180 行, 0.16%) │ ← OS/CPU/内存信息
│ 11. vm_info (~3 行, <0.01%) │ ← JVM版本
└─────────────────────────────────────────────┘

最终可以发现

项目 结论
崩溃类型 Native OOM(malloc 失败),不是 Java 堆 OOM
直接触发 BulkRevokeBias 操作时 Arena 内存分配失败
根本原因 线程泄漏——31952个线程消耗了约31GB原生内存
加剧因素 CompressedOops Zero Based 模式限制了原生内存地址空间
堆状态 使用率仅20.6%,堆完全正常,问题不在堆
系统状态 物理内存仅剩592MB,Swap已用9GB

一句话总结:线程泄漏导致原生内存耗尽,Java堆监控看不到这个问题,最终在偏向锁撤销操作时malloc失败导致JVM崩溃。

在日志的线程列表中,的确发现了大量的线程:

1
2
3
4
5
6
7
0x00007efdc0a00000,0x00007efdc0b00000)]
0x00007effeca46000 JavaThread "pool-3068-thread-3" [_thread_blocked, id=4218, stack(0x00007efdb9400000,0x00007efdb9500000)]
0x00007effeca47800 JavaThread "pool-3068-thread-4" [_thread_blocked, id=4219, stack(0x00007efdbbc00000,0x00007efdbbd00000)]
0x00007effec024800 JavaThread "pool-3068-thread-5" [_thread_blocked, id=4220, stack(0x00007efdb8e00000,0x00007efdb8f00000)]
0x00007effec026000 JavaThread "pool-3068-thread-6" [_thread_blocked, id=4221, stack(0x00007efdc0700000,0x00007efdc0800000)]
0x00007effec027000 JavaThread "pool-3068-thread-7" [_thread_blocked, id=4222, stack(0x00007eff30800000,0x00007eff30900000)]
0x00007effec028800 JavaThread "pool-3068-thread-8" [_thread_blocked, id=4223, stack(0x00007efdb8700000,0x00007efdb8800000)]

但这些线程是哪个线程池创建的,还得去排查代码。

这其实再一次印证了使用线程池规范:

关于线程池命名,阿里巴巴Java开发手册中有一条**【强制】**规约,原文表述如下:

【强制】创建线程或线程池时请指定有意义的线程名称,方便出错时回溯。

手册将这条规约与“禁止用Executors创建线程池”放在同等重要的位置,均为强制要求。这足以说明命名规范在生产环境中的重要性。

遵循规范后,当程序出现问题(如死锁、CPU飙升)时,我们通常会使用jstack命令导出当前线程快照进行分析。一个有明确业务含义的线程名,能让开发者在成百上千条线程堆栈信息中,一眼定位到问题线程所属的业务模块,从而大大缩短故障排查时间。

这些规范都是前人的血泪史!