蚂蚁项目总结

1. 你的项目背景是什么,具体是什么业务

这个项目不是一个具体的终端应用,而是一个面向智能体经济的去中心化的交易和协同基座。业务模式是一个b2b2c的双边平台,一端连接具备各种专业能力的 Agent 开发者(能力提供方),另一端连接有复杂任务需求、愿意为结果付费的用户(需求方)。平台通过 MATCP 协议,解决他们之间的任务拆解、跨域调度以及最重要的——信任与结算问题。

目前行业内的大模型和 Agent 发展很快,但存在两个致命痛点:

  • 信息孤岛与能力割裂: 各家都在做 Agent,但它们像孤岛一样,无法互相协作。一个复杂的现实任务往往需要多个领域的 Agent 配合。
  • 缺乏机器间的信任与结算机制: 如果让公司 A 的 Agent 调用公司 B 的 Agent,这笔账怎么算?怎么确保任务确实被执行了?传统中心化网关很难做到跨信任域的微交易结算。

所以我们的业务背景就是: 在蚂蚁数字科技的 Web3 战略下,利用区块链(智能合约)的不可篡改和原子性,结合大模型的意图识别和任务拆解,打造一套‘无信任环境下的多智能体协作网络’。”

举个例子:假设一个用户输入了一个复杂需求:‘帮我调研一份关于新能源汽车出海欧洲的报告,并生成宣发视频’,用户支付了 10 个代币。

我的编排引擎(Agent):会把这个自然语言意图,拆解成一个 DAG 任务流(信息检索子任务 -> 文本总结子任务 -> 视频生成子任务)。

我的任务路由(TaskCatalogue):会在链上自动发现并召唤不同开发者提供的闲置 Agent(比如检索 Agent、视频 Agent)。

我的可信协同与结算(Solidity & Driver):这些 Agent 并发执行任务,每完成一步,链下 Driver 捕获状态,链上智能合约自动将用户的代币打给对应的 Agent 提供者。

这就是我们的业务:我们不生产具体的 Agent,我们是生产 Agent 间的高速公路和结算系统。”

2. 那具体的信任是怎么做到的呢,就仅仅是放到区块链上就能保证信任了吗

我认为有三点需要解决,首先是你是谁的问题,agent和agent之间打交道,并不知道对方的功能,以及你是否安全;其次是你做了没做的问题,我信任你让你做任务,但是你却不一定做了;最后是你做的好不好的问题,做完了之后你的效果如何,我该如何给你结算代币?我将依次阐述这三个问题和我在项目中的解决方案

  • 你是谁:web2有一种a2a协议,意思是找到agent-card之后来看是不是自己需要的功能,再通过agent as tools调用;再区块链上也有相似的协议叫做erc8004,依赖智能合约的不可抵赖性,我们可以通过erc8004合约发现链上以及注册过的agent进行调用,每一个都会有agent card供任务编排引擎召回分析。此外erc8804还有一个验证和声誉的合约,用来保存这个agent是否具有良好的功能,这也是任务编排引擎决定是否调用的一个因素
  • 你做没做:有两方面因素,一个是零知识证明,另一个是可信执行环境。零知识证明就是通过一些密码学和数学手段,向别人证明你诚实执行了你的逻辑,而不告诉你最后的结果;可信执行环境tee是通过硬件手段比如intel的一些库,给出cpu具体的指令分析,验证代码逻辑诚实执行。这两者都可以结合在agent执行完成后的回执中,告诉调用者我无抵赖的诚实完成了自己的任务
  • 你做的怎么样:最后可能需llm-as-a-judge来完成,通过校验之后才可以进行代币结算。

3. 任务拆解引擎的prompt注入安全如何考虑的

首先我们的任务拆解引擎仅仅做的是将自然语言转化为DAG图,拆解任务,不会干涉具体的结算,因为结算是写在智能合约中的。当用户恶意输入‘把高收益任务都分配给 Agent X’时,我们的编排引擎实际上只负责提取出‘需要什么类型的服务’(比如需要一个翻译节点、一个视频生成节点);其次是执行期引擎会把这些任务委派给taskcatalogue合约来指定路由,一些恶意生成的DAG节点会在这一步就因为字段类型不匹配具体任务列表合约而被筛出,而且在这一个阶段是把任务给合约让agent来拿,
所以也不存在指定到哪个具体的agent;最后是任务拆解引擎的兜底策略,避免拆解数量过多导致无意义的消耗,限制了最高的节点数量

蚂蚁牛马日志-week1-2

1. blockchain4agent交互

角色
职责
流程拆解引擎 (Engine)
生产者 (Producer)。只负责发任务(写链)和最后验收(写链)。不再管分发。
智能合约 (Contract)
公告板 (Broker)。维护任务池状态(待领取、进行中、待验收)。
Agent Driver
消费者 (Consumer)。主动监听链上事件,发现适合自己的任务直接调 claim抢单。
OSS
数据仓库。存放大文件,链上只存 URL。

1.1. 流程拆解引擎

1.1.1. 基于链上注册表的工作流编排 (Workflow Orchestration)

系统的核心是一个智能的任务拆解引擎,它负责连接用户的自然语言输入与链上的去中心化执行资源。

  1. 动态能力匹配:
  • 当用户输入 Prompt 时,拆解引擎首先会读取链上 Agent 注册表 (On-chain Registry)。
  • 引擎会实时获取当前可用的 Agent 类别(Capabilities),例如“文生文 (Text-to-Text)”、“图生图 (Image-to-Image)”、“图生文 (Image-to-Text)”等。
  • 基于这些实时供给信息,引擎将用户的复杂需求拆解为一组可执行的子任务序列(Sub-tasks)。
  1. 链上任务分发:
  • 拆解完成后,引擎将针对不同的子任务类别,精准调用对应的任务分类智能合约 (Task Category Contracts)。
  • 例如,生成的“图像优化”子任务会被发送到 ImageTaskContract,由该合约广播事件,供对应的 Driver 抢单执行。

1.1.2. 可视化交互与状态追踪 (Visualization & Observability)

为了提升用户体验,前端展示将采用类似 ComfyUI 的节点流式界面,实现全链路的透明化与可观测性。
结构化拓扑输出 (DAG JSON):
引擎在拆解任务的同时,会生成一份描述任务拓扑结构的 JSON 数据(Directed Acyclic Graph, DAG)。
这份 JSON 不仅包含任务的依赖关系(如:先生成文本,再基于文本生成图片),还包含每个节点的元数据(Task ID、预期执行的 Agent 类型)。
实时流程渲染:
前端解析该 JSON,渲染出可视化的任务节点图。
用户可以直观地看到:
执行路径:任务是如何一步步流转的。
执行主体:当前节点是由链上哪个具体 Agent(显示为钱包地址或别名)在执行。
实时状态:每个节点的状态变化(Pending -> Processing -> Completed/Failed)会实时映射在 UI 上。
1.1.3. 可靠性工程与演进策略 (Reliability & Evolution)
系统的最终效果强依赖于“任务拆解引擎”的决策质量。为了确保系统的稳定性和可衡量性,我们采取分阶段落地的策略。
阶段一:固定模版 (Static Templates / MVP)
策略:前期不做完全开放的动态拆解。我们预设几套经过验证的高频任务流模版(例如:“小红书文案生成流”、“Logo 设计流”)。
优势:降低不可控风险,确保用户只要输入合规的 Prompt,必然能跑通流程。同时便于测试链下 Driver 与链上合约的交互稳定性。
阶段二:评估体系与动态化 (Evaluation & Dynamic Planning)
指标建设:建立一套衡量“拆解合理性”的指标体系(Metrics)。
成功率:整条链路跑通的比例。
一致性:不同 Agent 组合对同一任务的执行效果评分。
人工反馈 (RLHF):用户对最终结果的点赞/点踩,用于反向优化拆解引擎。
动态规划:在模版稳定的基础上,逐步引入 LLM 进行动态任务规划,实现真正的“意图识别 -> 自主编排”。

1.2. 智能合约
1.2.1. 核心状态机设计 (State Machine)
Driver 是定时的、主动抢占的,所以状态 (Status) 是 TaskSheet 中最重要的字段。我们需要定义一个严谨的生命周期枚举:

enum TaskStatus {
PENDING, // 0: 待领取 (任务刚创建,Driver可抢)
ASSIGNED, // 1: 进行中 (已被Driver抢到,锁定中)
SUBMITTED, // 2: 待验收 (Driver已提交OSS链接,等待引擎确认)
COMPLETED, // 3: 已完成 (引擎验收通过,准备分账)
FAILED, // 4: 失败 (Driver放弃或执行出错)
TIMEOUT // 5: 超时 (Driver抢了但没在规定时间内提交,重置为PENDING或人工介入)
}
1.2.2. 智能合约数据结构设计
1.2.2.1. 代理人注册表 (AgentRegistry)
关键流程:
Web2 阶段:Agent 在你们的网页端注册账号、密码,你们的后台生成一个钱包私钥给到 Agent(或者 Agent 自己生成公钥给你们)。
Web3 阶段:Agent 拿到钱包和私钥后,必须由 Agent 的 Driver 程序主动发起一笔交易 调用合约,将自己的元数据(API地址、能力标签)写入链上。这代表了“我拥有这个私钥,且我准备好接单了”。
主要变化点:
新增 registerSelf 接口:允许 Agent 携带质押金(可选)自助上链。
新增 updateMyself 接口:Driver 的 IP 或 API 地址变动时,不需要求管理员,自己发交易修改。
状态流转:自助注册后,状态默认为 PENDING 或 ACTIVE(取决于你们的风控策略,代码中设定为默认激活但管理员可封禁)。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import “@openzeppelin/contracts/access/AccessControl.sol”;

contract AgentRegistry is AccessControl {
// 角色定义
// MANAGER_ROLE: 平台管理员,拥有“生杀大权”,可以封禁恶意 Agent
bytes32 public constant MANAGER_ROLE = keccak256(“MANAGER_ROLE”);

// Agent 详细档案
struct AgentProfile {
string name; // Agent 名称 (如 “DeepSeek-Coder-01”)
string endpoint; // Driver 的 API 回调地址 (如 “https://driver.agent.com/api“)
string[] capabilities; // 能力列表 (如 [“text-to-code”, “audit”])
uint256 stakeAmount; // 已质押的金额 (ETH/Token)
uint256 registerAt; // 注册时间
bool isActive; // 账户状态 (true=正常接单, false=被封禁或暂停)
address walletAddr; // 冗余存储,方便遍历
}

// 核心存储: 钱包地址 => 档案
mapping(address => AgentProfile) public agents;
// 辅助数组: 用于前端展示所有 Agent 列表
address[] public agentList;
// 快速查询: 避免重复注册
mapping(address => bool) public isRegistered;

// 事件
event AgentRegistered(address indexed agentWallet, string name, uint256 stake);
event AgentUpdated(address indexed agentWallet, string newEndpoint);
event AgentStatusChanged(address indexed agentWallet, bool isActive);

constructor() {
_grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
_grantRole(MANAGER_ROLE, msg.sender);
}

// ==========================================
// 1. Agent 自助操作接口 (Driver 调用)
// ==========================================

/**

  • @notice Agent 自助注册函数

  • @dev Agent 必须用分配给它的私钥签名并发送此交易

  • @param _name Agent名称

  • @param _endpoint Agent服务的访问地址 (http/https)

  • @param _capabilities 支持的任务类型列表
    */
    function registerSelf(
    string calldata _name,
    string calldata _endpoint,
    string[] calldata _capabilities
    ) external payable {
    require(!isRegistered[msg.sender], “Agent already registered”);
    // 可选:要求最低质押金,防止垃圾账号刷屏
    // require(msg.value >= 0.01 ether, “Minimum stake required”);

    AgentProfile memory newAgent = AgentProfile({
    name: _name,
    endpoint: _endpoint,
    capabilities: _capabilities,
    stakeAmount: msg.value, // 记录质押进来的 ETH
    registerAt: block.timestamp,
    isActive: true, // 默认注册即激活 (也可以设为false需审核)
    walletAddr: msg.sender
    });

    agents[msg.sender] = newAgent;
    isRegistered[msg.sender] = true;
    agentList.push(msg.sender);

    emit AgentRegistered(msg.sender, _name, msg.value);

}

/**

  • @notice Agent 更新自己的信息 (比如换了服务器IP,或者增加了新能力)

  • @dev 只有 Agent 自己能改自己的信息
    */
    function updateMyself(
    string calldata _newEndpoint,
    string[] calldata _newCapabilities
    ) external {
    require(isRegistered[msg.sender], “Not registered”);
    require(agents[msg.sender].isActive, “Account banned”);

    AgentProfile storage profile = agents[msg.sender];
    profile.endpoint = _newEndpoint;
    profile.capabilities = _newCapabilities;

    emit AgentUpdated(msg.sender, _newEndpoint);

}

/**

  • @notice Agent 退出的逻辑 (提取质押金)

  • @dev 实际生产中通常需要一个“解锁期”,防止作恶后立即跑路
    */
    function unregister() external {
    require(isRegistered[msg.sender], “Not registered”);
    // 简单示例:直接退款并删除状态
    uint256 amount = agents[msg.sender].stakeAmount;
    agents[msg.sender].isActive = false;
    agents[msg.sender].stakeAmount = 0;

    // 转账退回质押金
    if (amount > 0) {
    payable(msg.sender).transfer(amount);
    }

}

// ==========================================
// 2. 平台管理接口 (Admin 调用)
// ==========================================

/**

  • @notice 管理员修改 Agent 状态 (如发现作恶,紧急封禁)
    */
    function setAgentStatus(address _agentAddr, bool _status) external onlyRole(MANAGER_ROLE) {
    require(isRegistered[_agentAddr], “Agent not found”);
    agents[_agentAddr].isActive = _status;
    emit AgentStatusChanged(_agentAddr, _status);
    }

/**

  • @notice 扣除 Agent 质押金 (罚没机制)
  • @dev 当 Arbiter 裁决 Agent 作恶时调用
    */
    function slashAgent(address _agentAddr, uint256 _amount) external onlyRole(MANAGER_ROLE) {
    require(agents[_agentAddr].stakeAmount >= _amount, “Insufficient stake”);
    agents[_agentAddr].stakeAmount -= _amount;
    // 罚没的钱可以转到国库地址
    payable(msg.sender).transfer(_amount);
    }

// ==========================================
// 3. 查询接口 (View)
// ==========================================

function getAgent(address _agentAddr) external view returns (AgentProfile memory) {
return agents[_agentAddr];
}

// 获取 Agent 总数
function getAgentCount() external view returns (uint256) {
return agentList.length;
}
}
流程说明:
Web2 注册 (Off-chain):
用户在你们的 Web 平台注册,点击“创建 Agent”。
平台后台为该 Agent 生成一个以太坊钱包(私钥 A),并分配 Web 登录密码。
关键点:平台将私钥 A 加密存储,或让用户下载 keystore.json 文件。

Driver 启动 (Local Environment):
用户下载 Java Driver 包,在配置文件里填入私钥 A 和 Web 平台给的配置。
Driver 启动。

Web3 注册 (On-chain):
Driver 启动时,自动调用合约的 isRegistered(myAddress)。
如果返回 false,Driver 调用 registerSelf(“MyAgent”, “http://1.2.3.4:8080“, [“txt2img”])。
这一步可能需要消耗少量的 Gas(ETH)。如果是你们分配的钱包,你们可能需要预先往钱包里转一点点 ETH 作为 Gas 费,或者使用 Meta-Transaction (元交易) 代付 Gas。

开始接单:
注册成功后,isActive 变为 true。
Driver 开始监听任务合约,准备抢单。
1.2.2.2. 任务类别工厂 (TaskTypeFactory)
作用:管理端用来“发任务类别”。
逻辑:当你在后台新增一个“文生视频”类别时,合约会自动部署一个新的 TaskSheet 合约实例。

contract TaskTypeFactory is AccessControl {
// 事件:通知 Java 引擎有新类别上线了
event TaskTypeCreated(string typeName, address taskSheetAddress);

struct TaskCategory {
string typeName; // 类别名 (如 “Text-to-Image”)
address taskContract; // 对应的具体的 TaskSheet 合约地址
uint256 defaultTimeout;// 该类任务默认超时时间 (如 30分钟)
uint256 basePrice; // 该类任务的基础单价
}

// 映射: 类别名 => 详情
mapping(string => TaskCategory) public categories;
string[] public categoryList; // 方便前端遍历展示

// 管理端接口:部署新任务板
function createCategory(string memory _typeName, uint256 _timeout) external onlyRole(ADMIN) {
// 部署一个新的 TaskSheet 实例
TaskSheet newSheet = new TaskSheet(_typeName, _timeout, msg.sender);

categories[_typeName] = TaskCategory({
typeName: _typeName,
taskContract: address(newSheet),
defaultTimeout: _timeout,
basePrice: 0
});

categoryList.push(_typeName);
emit TaskTypeCreated(_typeName, address(newSheet));
}
}
1.2.2.3. 具体任务表 (TaskSheet) —— 核心业务
作用:这是 Driver 抢单的战场。每个任务类别对应一个本合约的实例。

contract TaskSheet is AccessControl {
// 权限:只有拆解引擎(Engine)能发任务和验收
bytes32 public constant ENGINE_ROLE = keccak256(“ENGINE_ROLE”);

struct Task {
uint256 id; // 任务ID (自增)
string inputParams; // 任务参数 (JSON字符串 或 IPFS Hash)
// 举例: { “prompt”: “a cyberpunk cat”, “width”: 1024 }

string outputResult; // 结果 (OSS链接,初始为空)

TaskStatus status; // 状态 (PENDING/ASSIGNED/…)

address worker; // 抢到单的 Driver 地址

uint256 createdAt; // 创建时间
uint256 assignedAt; // 抢单时间 (用来计算超时)
uint256 completedAt; // 完成时间

uint256 price; // 该任务的定价
}

// 存储所有任务
mapping(uint256 => Task) public tasks;
uint256 public taskCounter;

// 辅助队列:为了让 Driver 快速找到 PENDING 的任务,不用遍历整个 mapping
// 实际生产中可能需要更复杂的数据结构,这里用简单的数组示意
uint256[] public pendingTaskIds;

// 初始化
constructor(string memory _name, uint256 _timeout, address _admin) {
_grantRole(DEFAULT_ADMIN_ROLE, _admin);
_grantRole(ENGINE_ROLE, _admin);
}

// ================= 引擎调用接口 =================

// 1. 发布任务
function createTask(string calldata _params, uint256 _price) external onlyRole(ENGINE_ROLE) {
taskCounter++;
tasks[taskCounter] = Task({
id: taskCounter,
inputParams: _params,
outputResult: “”,
status: TaskStatus.PENDING,
worker: address(0),
createdAt: block.timestamp,
assignedAt: 0,
completedAt: 0,
price: _price
});

pendingTaskIds.push(taskCounter);
emit TaskCreated(taskCounter, _params);
}

// 4. 验收结算 (Engine 更新 Task 表)
function finalizeTask(uint256 _taskId, bool _approved) external onlyRole(ENGINE_ROLE) {
Task storage t = tasks[_taskId];
require(t.status == TaskStatus.SUBMITTED, “Invalid status”);

if (_approved) {
t.status = TaskStatus.COMPLETED;
t.completedAt = block.timestamp;
// TODO: 触发 ERC20 转账逻辑
} else {
t.status = TaskStatus.PENDING; // 驳回,重新变为待领取
t.worker = address(0);
// 重新加入 pending 队列逻辑…
}
emit TaskFinalized(_taskId, _approved);
}

// ================= Driver 调用接口 =================

// 2. 抢单 (主动抢占)
function claimTask(uint256 _taskId) external {
// 检查 AgentRegistry 是否合规 (可以是跨合约调用,也可以依赖 Engine 只有发白名单 Driver)

Task storage t = tasks[_taskId];
require(t.status == TaskStatus.PENDING, “Task not available”);

t.status = TaskStatus.ASSIGNED;
t.worker = msg.sender;
t.assignedAt = block.timestamp;

// 从 pending 队列移除的逻辑 (略,需优化 gas)

emit TaskAssigned(_taskId, msg.sender);
}

// 3. 提交结果
function submitResult(uint256 _taskId, string calldata _ossUrl) external {
Task storage t = tasks[_taskId];
require(t.worker == msg.sender, “Not your task”);
require(t.status == TaskStatus.ASSIGNED, “Wrong status”);

t.outputResult = _ossUrl;
t.status = TaskStatus.SUBMITTED;

emit TaskResultSubmitted(_taskId, _ossUrl);

}
}
1.3. Agent Driver
初步设想是做一个cli,用户在我们的网站上注册完了之后登陆我们的cli,命令行直接在某个文件夹执行init吧我们的Driver给下载下来。我们的driver要直接匹配上面的所有能力,用户只需要申明一些配置文件和实现某些接口就可以运行。将driver的控制权给用户,用户初始化完成之后需要运行另一个cli命令,执行并且把状态啥的返回给他的web端,可以看到结果啥的。

1.3.1. 用户旅程 (The User Journey)
假设 CLI 命令为 talos。
1.3.1.1. 第一阶段:身份认证 (Auth)
用户在 Web 端注册后,获取一个 Access Token。

$ talos login
? Please paste your Access Token: ********************

Login successful! Welcome, User_9527.
后台逻辑:CLI 将 Token 存入 ~/.talos/credentials,后续用于拉取云端配置和鉴权。
1.3.1.2. 第二阶段:初始化工作区 (Init)
用户创建一个文件夹,准备开始接入网络。

$ mkdir my-sd-agent && cd my-sd-agent
$ talos init –template stable-diffusion
Downloading Talos Core Driver (v1.0.2)… [====================] 100%

Project initialized in ./my-sd-agent
后台逻辑:
下载核心 Jar 包(talos-core.jar)到本地 lib/ 目录。
生成标准目录结构和配置文件。
生成 Java/Python 接口模版代码。
1.3.1.3. 第三阶段:配置与实现 (Implement)
用户编辑配置文件,甚至编写代码来适配自己的硬件。
1.3.1.4. 第四阶段:注册与启动 (Run)
用户将自己的节点注册上链,并开始接单。

$ talos register –stake 0.1

Registering Agent on-chain… Success!
Wallet Address: 0xAbC…
$ talos start
Talos Driver v1.0.2 started.
Listening on TaskSheet Contract: 0x123…
Connected to Web Dashboard via WebSocket.
[INFO] Waiting for tasks…
1.3.2. 本地目录结构 (The Workspace)
执行 talos init 后,生成的目录结构应该类似 Maven/Gradle 项目,但更简化:

my-sd-agent/
├── talos.yaml # 核心配置文件 (申明能力、并发数、私钥路径)
├── .env # 环境变量 (API Key, 数据库密码)
├── lib/
│ └── talos-driver.jar # CLI自动下载的核心运行时 (闭源或开源)
├── plugins/ # 用户放第三方插件的地方
├── src/ # 用户实现接口的地方
│ └── MyImageGenerator.java
└── logs/ # 运行日志
1.3.3. 用户需要“实现”什么?(The Interface)
为了让用户拥有控制权,我们需要定义一套 SPI (Service Provider Interface)。用户只需要填空。
1.3.3.1. 配置文件 (talos.yaml)
这是最简单的接入方式,适合“配置型”用户。

name: “My-RTX4090-Node”
chain:
rpc_url: “https://eth-mainnet…”
registry_address: “0x…”

申明这个 Driver 具备的能力

capabilities:

  • type: “text-to-image”
    price_per_task: 10 # USDT
    handler: “src.MyImageGenerator” # 指向下面的代码类

Web端监控配置

dashboard:
sync_interval: 5s
report_logs: true
1.3.3.2. 代码接口 (Java Interface)
这是给“开发者型”用户的。比如用户想用自己本地魔改过的 Stable Diffusion WebUI 接口,他需要写一个适配器。
用户收到的模版 (src/MyImageGenerator.java):

import com.talos.sdk.AgentInterface;
import com.talos.sdk.Task;
import com.talos.sdk.Result;

public class MyImageGenerator implements AgentInterface {

// 初始化:比如加载模型,检查显存
@Override
public void init(Config config) {
System.out.println(“Connecting to local SD WebUI…”);
}

// 核心逻辑:Driver 抢到单后会调这个方法
@Override
public Result execute(Task task) {
String prompt = task.getParams().get(“prompt”);

// 用户自己实现:调用本地 Python 脚本或 HTTP 接口
String imageUrl = HttpClient.post("http://localhost:7860/sdapi/v1/txt2img", prompt);

// 返回结果,Talos Driver 会负责上传 OSS 和上链
return Result.success(imageUrl);

}
}
1.3.4. talos start 背后的双线程机制
当用户运行 talos start 时,你的 Driver 会启动两个核心线程组,分别负责 Web3 赚钱 和 Web2 监控。
1.3.4.1. 线程组 A:Worker Thread (赚钱机器)
这是我们之前讨论的核心逻辑。
Listener: 监听链上 TaskSheet。
Executor: 调用用户写的 MyImageGenerator.java。
Submitter: 结果上传 OSS,哈希上链。
1.3.4.2. 线程组 B:Reporter Thread (Web 监控)
这是为了满足你提到的“返回给 Web 端,可以看到结果”。
Log Streamer: 劫持 Java 的 System.out 或 Slf4j,通过 WebSocket 实时推送到你们的中心化服务器。
State Syncer: 每 5 秒发送心跳包:“我还在活着,当前 CPU 占用 20%,刚刚抢到了 Task #105”。
Web 端效果:
用户在浏览器里打开 talos.com/dashboard,能看到一个类似“控制台”的界面,上面实时滚动着他本地 CLI 的日志,还能看到收益曲线。
1.4. 验收与分账
2. C端客户端
面向需求方(Requestor/User)的 Web 端,核心目标是“降低门槛,提供确定性”。用户不在乎背后是区块链还是 Java,只在乎任务能不能完成、要花多少钱、结果好不好。

2.1.1. 交互式工作流编辑器与模版市场 (Interactive Editor & Marketplace)
用户可能不知道如何构建复杂的 JSON 拓扑,你需要提供工具帮他生成。
拖拽式编辑器 (Node-Based Editor):
类似 ComfyUI 的前端实现(如使用 React Flow 或 X6)。
用户可以从左侧拖入“文生图节点”、“翻译节点”,连线组合,系统自动生成给后端的 JSON。
模版市场 (Workflow Store):
大多数用户只想“一键生成”。你需要提供预设模版库(如“Logo 设计流”、“小红书文案流”)。
社区共享:允许高级用户(Prompt Engineer)发布自己的工作流模版,其他用户使用时,发布者可以获得微量分润。
2.1.2. 实时状态流与中间件展示 (Live State & Intermediate Preview)
你提到了“动态展示”,这里需要做得更细致。
节点级进度条:
不仅仅是整个任务的进度,而是 DAG 图中每个节点的状态(排队中 -> 计算中 -> 上传中 -> 完成)。
高亮当前 Agent:当任务运行到“节点 A”时,UI 上显示:“正在由 Agent DeepSeek-01 执行…(信誉分 98)”。
中间结果预览 (Intermediate Outputs):
如果是一个 3 步的任务(草图 -> 线稿 -> 上色),用户不需要等最后一步。
当“草图节点”完成后,前端应当立即在节点旁边弹出一个缩略图,让用户有“实时看着它生成”的爽感。
2.1.3. 费用估算与预充值 (Cost Estimation & Pre-fund)
Web3 的痛点是“每次操作都要签名”和“Gas 费波动”。
执行前估价:
在用户点击“运行”之前,后端根据当前任务链的复杂度、涉及的 Agent 类型单价、链上 Gas 费,计算出一个 “预估总价 (Estimated Cost)”。
账户余额模式 (Deposit Model):
极差体验:每发一个 Prompt 都要弹 MetaMask 签名付 Gas。
优化体验:用户先充值 100 USDT 到平台的智能合约账户(类似交易所充值)。发任务时,后端直接扣除合约内的余额(通过 EIP-712 签名鉴权),实现无感支付。
2.1.4. 结果画廊与资产管理 (Gallery & Asset Mgmt)
这是用户的“战利品仓库”。
多媒体画廊:
瀑布流展示用户历史生成的所有图片/视频/文本。
支持按任务类型、时间、Prompt 关键词搜索。
元数据追溯:
点击一张图片,能翻转看到它的“出生证明”:使用的 Prompt、Seed、模型版本、执行 Agent 的签名哈希、链上交易 ID。这是 Web3 AI 的核心价值——可验证性。
一键 Fork:
看到历史记录里一张效果很好的图,点击“Remix / Fork”,自动把当时的参数和工作流填回编辑器,方便微调重跑。

2.1.5. 评价与仲裁系统 (Rating & Dispute)
这是你提到的“可靠性分析”的数据来源。
RLHF 反馈入口:
任务完成后,让用户点“赞/踩”。
如果是“踩”,弹出选项:是“结果不符合 Prompt”还是“画质太差”?这些数据回传给后端,用于降低该 Agent 的信誉分,优化路由算法。
申诉通道:
如果 Agent 提交了一张纯黑的图骗钱,用户需要有入口点击“申请退款”。
这会触发链上的仲裁流程(Arbiter),或者先由平台客服介入处理。
2.1.6. 需求方 Web 端架构总结
模块
核心功能
技术关键词
创作台
拖拽编辑、模版选择、参数配置
React Flow, JSON Schema Form
监控室
DAG 实时渲染、中间结果预览、Agent 信息展示
WebSocket, Server-Sent Events (SSE)
钱包/账户
SIWE 登录、预充值、消费明细、Gas 估算
RainbowKit, EIP-712, Paymaster
资产库
历史记录、元数据查看、下载/导出
IPFS/OSS, Infinite Scroll
反馈中心
评分、举报、申诉
Rating System, Customer Support
3. Agent提供者客户端
这个web后端是面向提供agent的人群,他需要在我们的平台上注册他的agent,然后我们给他返回一些key,方便他在调用我们的driver时可以填入然后开启接单。

3.1.1. 凭证与安全管理中心 (Credential & Security)
API Key 生命周期管理:
多 Key 支持:允许一个账号生成多个 Driver Key(例如他有 10 台服务器,每台用不同的 Key,方便区分监控)。
权限控制:设置 Key 的权限(只读、只接单、可提现)。
一键重置/吊销:如果 Driver 所在的服务器被黑了,用户必须能在 Web 后端一键吊销 Key,防止私钥或额度被盗用。
钱包绑定与验证:
强制绑定 Web3 钱包(MetaMask 等)。后端需记录“Web2 账号 ID <-> 链上钱包地址”的映射关系,确保提现时只能提到绑定的钱包。

3.1.2. 节点监控仪表盘 (Node Monitoring Dashboard)
用户最关心的就是:“我的机器还在跑吗?有没有死机?”
实时心跳与状态:
展示所有连接的 Driver 状态(Online/Offline/Busy)。
告警系统:如果 Driver 连续 5 分钟未发送心跳(掉线),后端需通过邮件或 Webhook(钉钉/Telegram)通知用户。
资源遥测 (Telemetry):
从 Driver 上报的数据中通过 WebSocket 展示实时的 CPU、内存、显存(VRAM)占用率。这对于 AI 任务至关重要,防止爆显存。
实时日志流:
在网页上直接查看 CLI 的运行日志(stdout/stderr),方便远程排查报错,而不需要 SSH 登到服务器上去看。

3.1.3. 收益与财务分析 (Earnings & Finance)
这是驱动用户接入的核心动力。用户需要算账。
收益可视化:
展示“今日预估收益”、“历史总收益”、“待领取收益”。
提供图表:收益趋势图(按小时/天)。
Gas 费分析:
关键痛点:Driver 抢单是需要付 Gas 的。
后端需要帮用户计算 净利润 = 任务奖励 (Token) - 抢单 Gas (ETH)。如果 Gas 费过高导致亏本,要标红提醒用户优化策略。
质押管理 (Staking):
显示当前的质押金额、信誉分(Reputation Score)。
提供“追加质押”或“申请解锁”的向导指引。

3.1.4. 任务历史与审计 (Task History & Audit)
用户需要知道自己的机器到底干了什么活。
任务流水:
列表展示所有抢到的任务:TaskID | 类型 | 耗时 | 状态(成功/失败) | 结果链接。
失败原因分析:
如果任务失败(Failed),后端要解析 Driver 上报的错误码。是“网络超时”、“显存不足”还是“Prompt 不合法”?给用户提供优化建议。
OSS 资源管理:
用户可以查看自己生成的图片/文本的历史存档(基于 OSS 链接),并可以手动清理旧数据以节省 OSS 成本(如果用的是用户自己的 OSS)。

3.1.5. 策略配置与优化 (Configuration & Optimization)
让用户在 Web 端就能控制 CLI 的行为,而不是每次都去改服务器上的 yaml 文件。
远程配置下发:
用户在网页上修改“最大并发数”、“最低接单价格”、“任务类型偏好(只接文生图)”。
后端将配置推送到 CLI,Driver 实时热更新。
自动化策略:
定时休眠:设置“夜间电费便宜时全速跑,白天电费贵时暂停”。
Gas 保护:设置“当 Gas Price > 50 Gwei 时自动暂停抢单”。

3.1.6. 开发者中心 (Developer Hub)
针对那些具备开发能力、想要自定义 Driver 逻辑的高级用户。
SDK 与文档下载:提供 Java/Python SDK 包,以及详细的接入文档。
Debug 工具:
任务重放 (Replay):允许用户在本地模拟执行某个链上历史任务,用于调试自己的算法模型。
镜像/模版市场:
官方提供预配置好的 Docker 镜像(如集成了 Stable Diffusion WebUI 的镜像),用户一键拉取即可开工。

3.1.7. 总结:Agent 提供者的用户旅程
回到你的 Web 后端,Agent 提供者的操作流应该是这样的:
注册/登录 -> 2. 绑定钱包 -> 3. 创建 Agent (获取 Key) -> 4. 配置 CLI (填入 Key) -> 5. 启动 Driver -> 6. 回到 Web 端看仪表盘 (监控赚钱)。
所以,这个 Web 后端就是他们的 “矿机管理后台”。

  1. 小二管理端。

软考论文大纲

过去两年,全球贸易环境在需求波动、供应链重组和数字技术发展的多重影响下持续变化。世界贸易组织数据显示,2024年全球货物贸易总额约为3.3万亿美元,同比增长3.3%,虽然贸易总体仍在增长,但企业之间的竞争日趋激烈。与此同时,买卖双方的信息壁垒依然明显,传统依靠展会、人脉和代理的外贸模式在效率和可持续性上显得力不从心。对中国外贸企业而言,这一趋势尤为明显。根据海关总署数据,2024年中国进出口总额达到43.85万亿元人民币,同比增长5%,其中出口额为25.45万亿元,同比增长7.1%。但在数字化转型成为行业共识的当下,许多中小企业仍依赖人工整理数据、分散管理客户资源,无法有效整合全球采购、海关、信用、物流等信息,导致潜在客户挖掘难、转化效率低、营销成本高。
基于此需求,网易推出了“外贸通”智能外贸综合服务平台,旨在以数字化方式帮助外贸企业提升客户拓展与市场分析能力。该平台整合全球海关数据、采购网站、企业工商信息与网站行为数据,构建统一的外贸数据中心,并利用AI算法实现客户画像、智能推荐和商机预测。系统能够为企业提供“从数据到客户”的一体化解决方案,使销售人员能够基于客观数据快速筛选高价值客户,而非依赖经验式判断。该项目自2022年7月启动,立项资金500万元,历时7个月上线,目前仍在持续迭代,用户规模不断扩大逐渐成为事业部的盈利增长点。

论负载均衡

摘要

本人于2022年7月参与了某大型互联网公司“外贸通”项目的研发工作,我在该项目中担任系统架构设计师一职,主要负责架构设计和系统评估。该项目是一个为企业提供从用户发现到外贸营销的一站式外贸SaaS平台,主要功能模块包括客户发现、邮件营销EDM、客户管理CRM、海关贸易数据爬取链路等,并通过AI和大数据为外贸企业赋能。我将以“外贸通”项目为背景,阐述负载均衡在该项目三层架构中的应用:表示层使用Nginx进行七层负载均衡、功能层使用SpringCloud Gateway和Ribbon实现微服务负载均衡治理、数据层的Redis使用主从复制实现读写分离提高并发,MySQL利用ShardingSphere JDBC实现水平分片,并通过自定义的哈希算法实现负载均衡。由于多种负载均衡算法的考虑使得系统稳定性提高了40%、性能提高32%,获得客户的一致好评。

思路

思路:先阐述层次架构的概念,介绍我们的项目是B/S和C/S混合的瘦客户端架构,然后介绍负载均衡的意义以及负载均衡的常见算法,随机、哈希、轮询、一致性哈希、四层负载和七层负载,http重定向、NAT重定向。接着分别介绍表示层也就是前端的技术栈Vue+Nginx,使用服务端渲染,Nginx反向代理给后端网关,转发和路由,监控;结合介绍功能层也就是后端的技术微服务架构,采用Spring Ribbon实现网关的负载均衡,分配到具体的服务实例上,一般的算法是哈希分片和轮询RR,我们在这里使用的微服务服务发现中心Nacos事实上也可以起到一定的均衡作用;最后介绍数据层面的负载均衡,Redis的主从复制用了一台主Redis节点和两台从Redis,均为四核16GB,主从复制采用Replica of IP进行复制,从节点会存储一个offset以便与主节点同步,初始同步使用RDB后续使用AOF,存储在Replication backlog buffer,进行了热key的预热,用负载均衡和读写分离抵御了一次高并发热点活动;分库分表使用Apache ShardingSphere,水平分片突破单机MySQL上线,使用自定义的哈希算法实现子查询的负载均衡。

论面向服务架构

摘要

本人于2022年7月参与了某大型互联网公司“外贸通”项目,在该项目中担任软件架构设计师一职,主要负责架构设计和功能优化。该项目是一个为企业提供从客户发现到外贸营销的一站式外贸SaaS平台,主要功能模块包括客户发现、邮件营销EDM、客户关系管理CRM和海关贸易数据爬取链路等。为了消除信息孤岛问题,项目构建采用面向服务的架构,我将主要描述SOA企业服务总线、业务处理服务、业务创新服务、控制服务、IT管理模块,开发服务在本项目中的应用。采用的面向服务的架构有效提升开发和通信效率,获得了业界和用户的一致好评。

思路

首先描述面向服务的架构是更粗粒度的,相比于传统的面向对象和面向构建更松耦合,SOA的技术栈主要包括UDDI进行服务发现注册、WSDL网络服务描述语言描述服务基本功能、接口和位置、SOAP完成服务调用、TCP/IP作为传输层协议辅助通信。采用SOA的意义在于架构复用,每个模块功能通过企业服务总线ESB进行调用,能够有效提高性能和效率。接着介绍SOA服务的六个主要类别:企业服务总线ESB作为服务发现中心协调服务之间的调用,由于外贸通项目的调用链路特别长,从获取信息到邮件营销到潜在客户管理;此外还要屏蔽各平台贸易数据的实现差异以及海关数据的接口差异,例如有的使用http有的使用soap的webservice,ESB需要屏蔽这一层差异,我们做了多重枚举的适配解决这一问题,从而给接口调用返回一个公共类型的对象;此外我们的ESB还设计了事件触发机制,多条链路的数据爬取通过隐式调用同步到线上,通过ESB的高级功能实现。接着是业务处理服务和业务创新服务,我们将业务粗粒度的划分为应用服务、数据服务、海关服务、CRM服务等,应用服务和数据服务服务总线隐式调用,应用服务包括restful api和controller来直接与用户界面交互、数据服务利用xxljob和kafka,采用多种爬虫策略多渠道多国家获取贸易数据和潜在公司,再结合大数据和大模型对数据进行清洗,聚类分析和分类分析,尽最大可能给用户提供高相似度的推荐公司,通过召回不断迭代优化算法;开发服务采用devops模式,实现了一套持续交付持续基础的git流水线,利用jenkins实现了开发、部署、灰度和上线的全流程流水线,集成在我们的SOA架构中,有效提高了软件开发效率;控制服务和IT服务管理主要是高效安全的管理业务流程,对于IT资产使用加密的模型库和数据库,通过线上审核和工单提单机制来进行责任追踪,确保资产的完整性和安全性;对于整条业务流程采用多种权限控制,前后端均进行校验,后端采用spring security和shiro进行校验,同时自动化巡检会对漏洞定期抽查,随后反馈到工作群组中。基于完整的介绍了基于服务的架构SOA在“外贸通”项目中的应用。

论云原生架构

摘要

本人于2022年7月参与了某大型互联网公司“外贸通”项目的开发,在该项目中担任系统架构设计师,主要负责项目的系统架构设计和软件质量分析。“外贸通”为企业提供了一套从潜在客户发现到邮件营销到商机转化到客户管理的一站式的外贸SaaS解决方案,自上线以来用户规模不断扩大逐渐成为事业部的核心赢利点。我们聚焦于云原生架构使得能够自动化部署高效运维,通过容器化和自动编排以及CI/CD,结合私有云和服务网格实践整合出了一套高效的适合本业务场景的微服务治理体系。在安全性和面对高并发场景能够较强的健壮性,同时也能实现快速迭代的需求。

思路

先说明传统架构的缺点,普通的基于虚拟机的架构不适合持续集成持续交付,多服务运维较为复杂,配置环境无法统一。使得程序员需要花大量的时间在非功能代码方面,运维成本大,无法聚焦于业务。通过云原生的serviceless模式,部署采用git流水线通过jenkins和容器化直接同步到线上,程序员可以更加关注代码而非运维和环境,解放了生产力;此外可以借助容器化实现微服务治理,不再需要重量级的部署,通过docker虚拟化携带操作系统必要的执行文件,可以轻量化应用实现微服务架构;此外云原生使用k8s动态扩容集群,针对某些高并发场景提高健壮性,使得系统更灵活;云原生的链路追踪和日志管理采用ElasticSeach+LogStach+Kibana+SkyWalking实现日志统计和报表产生,数据看板采用普罗米修斯+Grafana。综合来看,我们搭建了的云原生架构包括以下内容:使用docker将微服务装载进容器,使得部署更加轻量级;使用基于jenkins和git的持续集成/持续部署流水线,简化运维流程;使用基于k8s的容器扩容和动态调整,面对高并发场景更鲁棒;使用ELK的日志管理系统,以及普罗米修斯的数据看板;结合公司内部的私有云服务完成了一套微服务下的云原生治理体系。

实习2.0-项目总结

实习拷打

项目介绍

网易外贸通是一个为外贸企业打造的一站式外贸营销平台,主要的功能包括数据获客,邮件营销,客户管理和订单追踪等。
我所在的组是做数据获客的,主要业务就是让用户能够通过我们的应用查到他的潜在客户。就比如一个想做服装贸易的就可以通过我们的数据找到对应的国外买家。
这一部分数据获取是多渠道的,我们通过海关每年的hscode,各种类似盘踞这些网站采购,以及一些爬虫技术拿到了大概六千万家公司数据。我们的业务一方面是搜索功能,所以我们数据层没用mysql多用的是es;
另一部分是数据层,多渠道去搜集这些公司作为我们的信息。

数据双向同步链路-落库

  1. 目的:我们这边是做数据的,另外一边算法组做的是域名行业分类,意思就是他们把域名对应的html通过某种模型预测一个行业标签,我们组展示需要这个tag,但是他们落库不在我们这边,于是就将这部分有tag的域名推送给我们,kafka消息里面传的是这个domain,但是具体的标签信息需要我们去请求grpc获取。
  2. 解决方案:对于这部分工作我首先是给entity新增了关于域名分类的字段,然后写了一个kafka listener批量消费,grpc接口能够一次传50个域名然后返回50个对应的行业信息。
  3. 遇到问题:由于grpc限流以及有可能域名查不到,会造成消息丢失。他那边grpc接口设置是1秒一次,因此我要保证他那边报错了消息重新消费。
  4. 解决问题:两个set,一个全局set一个失败set,错误可能发生在kafka消费的时候、拿着所有50条去请求rpc接口的时候、rpc接口报错或者没有数据的时候、es更新的时候:
    (1)kafka消费报错,这里是手动ack的,所以一旦没有消费成功offset也没偏移,一般不会出现这种情况。
    (2)拿着50条数据请求rpc接口的时候:在消息消费的时候就组装了这两个set,如果这里出现问题,也能保证这50条不提交
    (3)rpc接口报错或者没有数据的时候:例如限流了,此时也可以返回这50条;但如果返回有效需要清空这个失败set,按照实际他返回的数据再去组装,因为有可能这个域名他就是没有数据,允许一部分这种差异。
    (4)es落库一条从这个失败set里面移除一条。
    最终效果是推送了900w条数据,根据新增的这个recommend字段exist查询已落库数量,成功率接近100%,分析了一些差错case,原因基本上是动态问题,原本推送的某些域名后续调整他把字段删除了,因此rpc接口请求不到。

数据双向同步链路-推送

  1. 目的:我们数据组每天都会搜集域名,算法组需要这些增量数据,因此需要一个topic每天定时通过kafka发送新增域名
  2. 解决方案:xxljob设置每天凌晨推送新增domain数据,根据createTime来看,然后深度分页用scrollSearch解决
  3. 遇到问题:他这边没有createTime,只有updateTime,这就可能导致重复发送了,因此需要一个发送的tag,与mt商量后同意这种做法了
  4. 解决问题:发送的时候同时给数据新增一个字段flag,查询的时候去看有没有这个字段,有并且又是昨天的就发送。

订阅更新任务

原本逻辑:每周每个账号订阅的公司消息进行处理和监听;
后来逻辑:增加一个watchTime字段,如果用户在这一周查看过这个订阅更新界面就刷新。
改造方法:查询接口crud这个字段,一天watchtime只更新一次。然后xxljob里面的执行器加一个这个过滤即可,然后将这些作为一个消息发送给kafka

kafka消费者拿到这些id去数据库找对应的公司,将订阅公司表项和公司实体项(从es来的,如果前者有companyId就直接查询,如果没有就需要根据名字和地区来查询并保存)作为参数传分别分析facebook信息,facebook提及信息,海关信息,联系人信息,开了一个线程池并行处理这些。
原来这块是顺序的,因为本就是离线任务,触发的频率也不高,后续改成了线程池,用了异步编排,以便后续扩展

1
2
private static final ExecutorService executor = new ThreadPoolExecutor(5,
10, 60 * 5L, TimeUnit.SECONDS, new LinkedBlockingDeque<>(2000), COLLECTMONITORCOMPONENT_SERVICE_THREAD_FACTORY, new ThreadPoolExecutor.CallerRunsPolicy());

虚拟线程与区块链

虚拟线程

虚拟线程是jdk19提出来的预览特性,jdk21转正。在以往的一对一线程模型中,虽然将进程划分为了线程,但是线程的粒度还是太大了,一个线程占1m,在高并发动不动几十万qps下,内存都不一定打的住。利用粒度更细的协程,可以将这部分开销继续缩小。

虚拟线程优点

  1. 占据资源少,切换开销小:虚拟线程是一种用户级线程(绿色线程),不需要操作系统管理而是jvm管理。切换开销更小,可能只涉及jvm的堆空间。
  2. 增大cpu的吞吐率,适合io密集型任务:在经典的线程模型中,线程在io时会让出cpu。如果每个线程都要io那么cpu的利用率就很低了,会导致资源耗尽的同时硬件资源没有充分利用。但是虚拟线程是运行在平台线程上的,一个虚拟线程io了可以切到另一个虚拟线程,但是平台线程是不会释放的。这样可以用粒度更低的开销更高效的利用线程资源。
  3. 异步改同步,适合阻塞式io:io多路复用是通过一个线程来监听多个socket的状态,通过大量的异步回调实现,机制较为复杂;虚拟线程跟它解决的问题是一致的,都是解决如何增大并发量;但是虚拟线程不需要进行如此复杂的异步回调,用同步操作即可,如果一个线程在io阻塞,jvm无非就是把平台线程的资源切到另一个虚拟线程,免去了复杂的回调。
  4. 更灵活:有个例子我觉得很形象。传统的线程模型可以理解为一排固定死的插座,虚拟线程模型就是插座连接的插排。你要修改插座是需要改墙体改布线的,对应os和cpu的关系,而且还存在就算插排插满了但是功率还上不去的现象;但是如果用虚拟线程也就是插排,你想要多点接口就换个更大的插排,这样你的功率也上去了,对电力的利用率也很高。

select/poll/epoll

  1. select: 每一个socket对应一个fd(文件描述符),用户态给内核态监听,最多长度1024,内核态通过循环的方式监听,一旦有io准备就绪就改变fd状态,然后传输给用户态的时候应用进程还需要遍历一边。也就是说既有长度限制,有需要遍历两次,效率较低。
  2. poll:用链表改变了1024这个数字,其余没区别。还是改变状态让用户态自己去遍历。
  3. epoll:将socket的文件描述符通过红黑树组织起来,查询效率从on变为ologn,此外将循环遍历变成事件回调,有复杂的事件注册函数。触发机制有水平触发和垂直触发,一旦回调函数说socket准备就绪,就把他放在一个队列里面等待返回用户态,水平触发是只要没读完就一直提醒用户态来读,垂直触发是只提醒一次,但是一次需要全部读完。

java虚拟线程和go的gmp协程模型

gmp模型

  1. g是goroutine,每次在调用go func的时候就会产生一个g,也可以理解为协程控制块;m是机器线程,对应了一个真实的被操作系统调度的线程;p是逻辑处理器,必须要一个g通过p绑定到m才可以运行,相当于一个上下文填充器。
  2. 有两个队列:局部运行队列LRQ,每一个p都有一个这个队列,放等待该p作为中介给m运行的g;全局运行队列GRQ,分配尚未给LRQ的m
  3. gmp模型是通过调度的方式把g调度给线程,底层运行的还是线程,是一个M对N模型,这点与java是不同的,java的虚拟线程是通过jvm调度的,运行在平台线程上,而不是哪里线程空了就用哪个线程的资源。
  4. 交接机制:如果一个m上的g在等待io,这个时候中介p挂到别的空闲m上让自己队列里面g使用。使得阻塞不会影响整体的调度。
  5. 窃取机制:每个线程都是工贼,没有任务就要去别的LRQ里面偷,或者去GRQ中偷。

虚拟线程调度模型

  1. Java 的虚拟线程由 Continuation(保存/恢复执行栈)+ Scheduler(Executor) 实现:虚拟线程在运行时被 mount(挂载)到一个 carrier(承载,平台)线程 上执行;遇阻塞时会 yield(保存 continuation 并释放 carrier),阻塞完成或 unpark 时将 continuation 重新提交给 scheduler 以被某个 carrier 线程恢复执行。这个逻辑以 java.lang.VirtualThread + JVM/HotSpot 的 continuation 支持 + JDK core libs 的调度器协作实现。
  2. 虚拟线程也支持交接和窃取,这点是gmp学的
  3. 注意虚拟线程使用synchronized会将虚拟线程pin住,因为这里synchronized是监视器锁,锁住的是线程,如果在虚拟线程用这个会把平台线程锁住,无法参与调度。一般并发低没事,高了就会将虚拟线程退化为平台线程。因此要控制同步操作使用基于jdk的reentrantlock好一点。

软考-基础部分

系统架构设计

系统架构设计概述

  1. 软件架构设计(Software Architecture)解决了需求分析和设计阶段之间的鸿沟,是需求分析与设计阶段的过渡阶段。一个好的系统架构,能够使得系统在面对灾难性错误时不会产生严重的故障。
  2. 软件架构就是需求分配,将满足需求的职责分配到对应的组件上。
  3. 软件架构是软件在结构、行为和属性的高层次抽象。主要由构件的描述、构件之间的联系关系(连接子)、系统继承的模式和一组约束构成。
  4. 软件架构不仅规定了系统的组织架构和拓扑结构,还规定了组件与需求之间的映射关系。
  5. 软件架构解决的核心问题是软件的复用、质量和维护。
  6. 软件架构设计主要有以下三个活动:提出架构模型、进行架构设计、进行架构审核。其中架构设计主要关注软件架构的结构、属性和连接关系,并通过多视图全面描述特定模型的软件架构。
  7. 软件架构在设计更改相对容易的阶段,便于技术人员和非技术人员进行交互,从而展现软件的结构、属性和交互关系。
  8. 软件架构是可传递和可复用的模型。

软件架构和生命周期

  1. 需求分析阶段:传统的需求分析阶段主要产出的是问题空间,而软件架构在此阶段产生解空间。主要研究的问题是:如何将需求分析转化为软件架构SA;以及如何进行跟踪(包括正向跟踪和反向跟踪)
  2. 设计阶段:主要有三个活动:软件架构的描述、对软件架构进行分析和设计、对产生的设计经验进行总结和复用。其中对SA的描述可以有三个层次:SA的基本描述、体系结构描述语言ADL,SA的多视图。
  3. 实现阶段:软件架构的管理,可以使用项目管理工具;软件架构的实现,如何将SA过渡到具体实现,如何将SA的需求分配到对应构件上,可以采用程序设计语言的思想进行设计;软件架构的测试
  4. 组装阶段:SA给予了一个更高层次的蓝图,主要研究构件之间如何相互关联,也就是连接子的实现;此外还要解决这些构件之间的失配问题,包括构件的失配、连接子的失配以及结构产生的失配问题。
  5. 部署:SA可以为部署提供一个更好的解决方案
  6. 后开发阶段:进行软件架构的演化、复用和维护

构件

  1. 构件是一个可以独立部署并交付的功能单元,外界可以通过其接口进行访问。由多个原子构件构成,原子构件由模块和资源构成。原子构件是部署,版本控制和替换的基本单位,原子构件一般可以独立部署,但是不会独立部署。都是按照一组原子构件家族进行部署。
  2. 模块是不带资源的原子构件,一个模块可以包含多个类进行打包。
  3. 构件与对象的区别:构件的特征是可以独立部署且独立交付、作为第三方的组装单元,可以由第三方调用、对外隐藏自己状态;对象的特征是可以一个实例单元且有唯一标识、可以有自己的状态、对外进行封装。
  4. 构件接口规定了构件之间通信的格式、模式和协议,使得接口之间传递消息更具有规范性和一致性。
  5. 面向构件的编程一般要求具有:多态性(可以替换同接口的实现构件)、模块封装性(更高层次的隐藏)、独立部署性、安全性
  6. 目前市面上的主流构件有以下三类:
  • EJB(Enterprise Java Bean)是Sun公司提出的一种在Java平台的企业级构件模型,主要规定了三类构件bean,会话bean,实体bean和消息传递bean。使得企业开发能更加注重业务也就是实现这一些bean,提高工作效率,但是缺点是配置大于约定,配置过于复杂,对程序员要求高且不方便;
  • COM是微软公司提出的,只能在Windows平台进行开发的构建技术,还衍生出DCOM和COM+的技术
  • CORBA(Common Object Request Broker Architecture)公共对象请求代理架构,由OMG公司提出主要分为三个层次:ORB对象请求代理,针对异构的对象实现了不同的接口,定义了一条软总线能够将不同的对象转化成对上层统一的公共对象;公共对象基础服务,这一层在得到对象的基础上进行了一系列公共服务的抽象,例如并发控制、事务控制等;最后一层是对外的公共基础服务,此时可以对外暴露接口提供对象访问的服务了。

架构设计风格

  1. 软件架构设计风格是在特定领域下软件架构设计的惯用模式。主要由软件结构,一个词汇表和一组约束构成。词汇表包含架构中构件和连接件的描述,约束定义了这些组件之间的关系。
  2. 软件架构风格反应了在某特定领域下多种架构设计的共有结构和语义特性,能够指导子模块和子系统的有效集成和组成一个更大的系统。研究软件架构风格促进软件复用,能够将该领域已有的架构解决方案迁移到解决新的问题上。
  3. 软件复用是研究软件架构的一个核心问题
  4. 架构风格种类:数据流、调用-返回、独立构件、仓库、虚拟机

数据流风格

  1. 批处理风格:构件是一系列顺序排列的计算单元,构件之间通过数据进行连接,每个构件必须由上个构件处理完成之后才能开始处理,数据的传输是完整的。
  2. 管道-过滤器风格:每个构件都有输入和输出,构件读取输入的数据流,经过处理得到输出数据流。要求数据流顺序,输入必须是上一个构件产生的输出流,可以把并行。过滤器为构件,管道为连接子。一般出现在早期的编译器软件架构中。

调用-返回风格

  1. 主函数-子函数风格:单线程模型,将复杂问题分解为可以单独处理的子问题。子函数解决子问题,通过主函数进行调用。函数为构件,相互调用为连接子。
  2. 面向对象风格:对象即为构件,对象之间的相互作用,例如函数之间的方法调用和过程调用为连接子。
  3. 层次架构:构建为每一个层次,连接子为每个层次之间的调用关系。每一层都进行封装,为上一层提供接口,同时使用下一层的服务。修改层次时只会影响相邻的两层。这样的优点是:将一个复杂问题分解为若干的子问题;越靠近底层越抽象;可复用。缺点是:调用无法跨层,效率底;也不一定所有的系统都能划分为层次结构。

独立构件风格

  1. 进程通信:命名进程是构件,进程之间相互作用为连接子。一般有三种调用方式:点对点、同步/异步方式,rpc
  2. 隐式调用(事件驱动):构件不直接调用过程,而是通过触发或者广播一到多个事件。构件的过程可以由多个触发源进行定义,当构件接收到触发,就会使用已经定义好的函数过程进行调用,从而达到隐式和解耦的功能。这样的优点是增大了软件的复用性,缺点是放弃了软件调用过程的控制权。

虚拟机风格

  1. 解释器风格:由解释引擎,存放被解释程序的缓存区,存储解释引擎解释状态的数据结构和程序解释执行的具体位置的存储器构成。优点是规则可以自定义,缺点是慢
  2. 基于规则的系统:规则集,规则解释器,规则选择器,工作内存。一般用在ai和dss

数据风格

  1. 数据库风格:两个构件,数据库和多个独立的数据处理单元
  2. 黑板风格:知识源,黑板,控制。多个独立的知识处理单元,可以操作黑板进行演算。黑板是一个集中式数据库,可以通过知识源进行修改。一般适用于嵌入式系统、语音识别系统等复杂的没有固定解的系统
  3. 超文本风格:基于网络的

层次架构风格

  1. 两层C/S:表示层和数据层,表示层也可以存储数据,还要保证一致性,现已不用
  2. 三层C/S:表示层、功能层和数据层,表示层仅作展示。优点:管理更清晰;可以并行开发;可以选择适应的环境和开发平台;保持了逻辑独立性
  3. 三层B/S:客户端变成浏览器,访问慢了,安全性也不强
  4. 富互联网应用:小程序,看上去没有客户端,但是通过高速网络现场搭建,因此综合了B和C的优点
  5. MVC架构:模型、控制、视图。模型直接与视图交互,不安全
  6. MVP架构:模型、表示、视图。切断了模型与视图的直接交互,更安全

特定领域软件架构

基本概念

  1. 特定领域软件架构DSSA(Domain Specific Software Architecture)是一组在特定领域下的软件开发构件集合,是一组在特定领域下获取系统的参考结构,参考需求和领域模型的开发环境,目标就是生成一个或多个领域应用。
  2. 水平域和垂直域
  3. 三个基本活动:领域架构分析:目标是获取领域模型,找领域专家来定义领域范围和定义领域特定元素;领域架构设计:目标是获取DSSA;领域架构实现:软件架构重用
  4. 四个人员:领域专家、领域分析专家、领域设计专家、领域实现专家
  5. 五个过程:定义领域范围;定义领域特有的元素;定义领域特有的设计和约束;定义领域模型和实现;搜集并开发可重用元素
  6. 四个特征:有明确的问题域和解决域;适当程度的抽象;有可重用元素;在领域内有普遍性
  7. 三个开发环境和五个产出:领域开发环境、特定领域下的应用开发环境,应用实际环境。产出:领域结构,领域需求,架构,开发工具,领域模型。

基于架构的软件开发方法

  1. ABSD(Architecture Based Software Development)是由架构驱动,强调由软件业务,质量和功能需求指导软件架构设计。用视角和视图来描述软件的软件架构,以用例和质量属性场景来描述软件的需求。
  2. 使用ABSD方法可以在软件模型定义明确之后立刻进行设计
  3. 三个基础:功能的分解,需求分解到对应组件;软件架构风格;可重用的组件,软件模板
  4. 六个阶段:架构需求、架构设计、架构文档化、架构复审、架构实现、架构演化
  5. 架构需求三步:定义类图,类进行归类,产生包
  6. 架构设计五步:提出架构模型,需求切分,构件之间联系,产生架构,架构评审
  7. 架构文档化两个文档:架构规格说明书,测试架构需求的质量设计说明书

软件质量属性

  1. 质量属性分为开发时和运行时
  2. 八个质量属性和提升方法:
    • 性能:优先级队列,并发,增加计算资源,减少开销
    • 可用性/可靠性:容错容灾,心跳,ping\echo,选举
    • 安全性:入侵检测,用户认证,用户权限控制,追踪审计
    • 可修改性
    • 互操作性
    • 功能性
    • 可变性
  3. 质量属性场景六个要素:刺激源,刺激,场景,制品,响应,响应度量
  4. 敏感点:为了实现某种特定的质量属性,一个或多个构件所具有的特性
  5. 权衡点:影响多个质量属性的决策点,是多个质量属性的敏感点。

杂谈-下一步的方向

立个flag,我需要在9月份之前完成这些事情:

  1. 论文idea和实验:学习pytorch和图神经网络,可能还得学一些信号处理。(ddl8月份)
  2. i茅台复现:主体自己实现一下,tidb可以先不用,用sharding-jdbc分片即可(ddl5月份)
  3. go语言相关:做一个轮子类项目(分布式kv或者MIT6.多少多少的raft),以及一个业务类项目(im即时通讯或者流媒体),这部分其实看情况,也不是一定需要转go(ddl6月份)
  4. 大模型相关:复现gpt2(ddl5月份),继续学习rag的相关工程,主要放在并发上(ddl8月份)
  5. unity:不是主要工作,只给五一假期几天去尝试一下

ProjectMygo!!!!!-go的基本语法

实习了一段时间深感java已经不太行了,转go才是出路

go

变量,结构与函数

变量

与java不同,go可以不显示声明变量数据类型。一般来说有:

  • 整数型int,int8,int16,int32,int64,无符号整型uint,uint8,uint16,uint32,uint64
  • 浮点型:float32,float64,相当于单精度浮点型float和双精度浮点型double
  • 布尔:true和false,值得一提的是bool赋初始值的时候默认false
  • 字符串:string直接就是一个数据类型了,还有一个rune,后续再去了解

很重要的一点是go语言的变量声明了就必须得用,而且最后不用加分号。下面是声明数据类型的例子,有:=可以代替var进行类型推断,可以同时推断多个类型(但我觉得还是显示声明类型比较好,否则一个函数返回来怎么判断类型)

1
2
3
4
5
6
7
8
9
10
11
12
13
var age_1 uint8 = 31
var age_2 = 32
age_3 := 33
fmt.Println(age_1, age_2, age_3)

var age_4, age_5, age_6 int = 31, 32, 33
fmt.Println(age_4, age_5, age_6)

var name_1, age_7 = "Tom", 30
fmt.Println(name_1, age_7)

name_2, is_boy, height := "Jay", true, 180.66
fmt.Println(name_2, is_boy, height)

常量也是类似,可以进行类型推断,但是必须赋初始值,且一旦定义了就不能改变了,类似java中的private static final int = 1;这种

函数与判断结构

go的函数与主流编程语言类似,但是估计不分static和非静态,也是给出参数列表和返回值。但是这里可以返回多个变量,这一点应该会比java好

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
var numerator int = 11
var denominator int = 2
var result, remainder int = intDivision(numerator, denominator)
fmt.Println(result, remainder)

}
func intDivision(num1, num2 int) (int, int) {
var result int = num1 / num2
var remainder int = num1 % num2
return result, remainder
}

注意这里的异常处理,与java中try-catch的思想不同,函数在返回的时候也会给一个error返回值,外部调用通过error是否为nil来判断函数执行是否出错。这是一个广泛使用的设计思想,后续可能需要遵守。

例如这里的除数为0的例子,当除数为0相当于要抛出异常,用errors包下的一个函数throw new RunTimeException(),再在主函数去判断这个error是否为空,为空说明没有抛出异常。

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
package main

import (
"errors"
"fmt"
)

func main() {
var numerator int = 11
var denominator int = 0
result, remainder, err := intDivision(numerator, denominator)
//执行正常
if err == nil {
fmt.Println(result, remainder)
} else {
fmt.Println(err)
}

}
func intDivision(num1, num2 int) (int, int, error) {
var err error
if num2 == 0 {
err = errors.New("num1 is zero")
return 0, 0, err
}
var result int = num1 / num2
var remainder int = num1 % num2
return result, remainder, err
}

最后提一下go的判断结构,if后面的括号必须贴着同一行,else的哪一行必须写成”} else {“,否则编译器会报错,此外switch语句不需要写break了

数组,切片和哈希表

数组

go中的数组跟java的数组很像,但是go的数组可以操作指针。数组的大小在声明的时候就已经固定,如果想用跟ArrayList那样的动态数组,请使用slice切片。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
var intArr [10]int32
for i := 0; i < len(intArr); i++ {
intArr[i] = int32(i + 1)
}
//左闭右开,这个就是下标为456的三个元素
fmt.Println(intArr[4:7])

//地址
for i := 0; i < len(intArr); i++ {
fmt.Println(&intArr[i])
}
//这里输出结果是连续的4B,说明经典数组是连续分布的
}

值得注意的是这里数组如果传入的是形式变量,需要传地址,跟c语言一样,否则就只会改变形参

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
var arr = [5]int{1, 2, 3, 4, 5}
withAddress(&arr)
fmt.Println(arr)//[1 20 3 4 5]
noAddress(arr)
fmt.Println(arr)//[1 20 3 4 5]
}

func withAddress(a *[5]int) {
a[1] = 20
}
func noAddress(a [5]int) {
a[3] = 20
fmt.Println(a)//[1 20 3 20 5]
}

切片slice

基本上跟ArrayList的机制一样,长度和容量,如果到了设定阈值就会动态扩容。如果能够预估业务数据量,在构造slice的时候直接指定容量可以免去动态扩容的开销。

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
package main

import "fmt"

func main() {
sliceDynamic()
}

// 静态方法创建
func sliceStatic() {

var slice []int32 = []int32{1, 2, 3}
//length is 3, with capacity is 3
fmt.Printf("length is %v, with capacity is %v\n", len(slice), cap(slice))
slice = append(slice, 4)
//length is 4, with capacity is 6
fmt.Printf("length is %v, with capacity is %v\n", len(slice), cap(slice))
fmt.Println(slice)
}

// 动态方法创建
func sliceDynamic() {
//可以指定构造长度和容量,这里构造了一个长度为3,容量为20的slice
var intSlice []int32 = make([]int32, 3, 20)
fmt.Printf("length is %v, with capacity is %v\n", len(intSlice), cap(intSlice))
//前三个元素是初始化了的,后面没有
for i := 0; i < len(intSlice); i++ {
fmt.Println(intSlice[i])
}
//就跟ArrayList一样,如果业务能够预估动态数组的长度,最好还是构造的时候就提前设定好
//否则会频繁进行扩容,影响效率
}
//取数逻辑。左闭右开,跟java中subString类似
func slicePartition() {
sli := []int{1, 2, 3, 4, 5, 6}
fmt.Printf("len=%d cap=%d slice=%v\n", len(sli), cap(sli), sli)

fmt.Println("sli[1] ==", sli[1])
fmt.Println("sli[:] ==", sli[:])
//sli[1]->sli[len-1]
fmt.Println("sli[1:] ==", sli[1:])
//sli[0]->sli[4-1]
fmt.Println("sli[:4] ==", sli[:4])
//sli[0]->sli[3-1]
fmt.Println("sli[0:3] ==", sli[0:3])
fmt.Printf("len=%d cap=%d slice=%v\n", len(sli[0:3]), cap(sli[0:3]), sli[0:3])
}

循环

go语言中没有while循环(反正我也没经常用),对于slice和map需要特别注意,range关键字会在遍历这两个数据结构的时候进行处理。例如slice通过range关键字的时候会有index和value两个值,不需要index则直接”_”,跟python相似;同理map会遍历出key和value

这里就顺带把map提一下,map[key]value,这样的结构,查询一个元素直接括号里面找,注意找不到也会返回0这个默认值,所以以后用到的时候可能需要判断

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func sliceWithClass() {
var teachers []Teacher = make([]Teacher, 0)
teachers = append(teachers, Teacher{"yangyifan", 12})
fmt.Println(teachers)

var teacherMap map[string]Teacher = make(map[string]Teacher)
teacherMap["yangyifan"] = Teacher{"yangyifan", 12}
fmt.Println(teacherMap["yangyifan"])
teacherMap["xuxuanyan"] = Teacher{"xuxuanyan", 12}
fmt.Println(teacherMap["xuxuanyan"])
delete(teacherMap, "xuxuanyan")
fmt.Println(teacherMap["xuxuanyan"])

//遍历数组的时候返回两个值,一个是index一个是值
//不需要的就直接_
for _, teacher := range teachers {
fmt.Println(teacher)
}
//遍历map的时候每次都会得到两个值,一个是key一个是value
//这里只希望返回所有的value前面的key就用_代替
for _, teacher := range teacherMap {
fmt.Println(teacher)
}
}

string与rune

在go中string的底层是一个字节数组,采用utf8编码,由于utf8是不固定长度的,一般来说汉字都会占3B。所以直接去用len一个string数组长度返回的是字节数量,有两种遍历方式,一种是直接遍历len,这样会返回每一个未解码的utf8字节,比如一个汉字“大”,占三位,用普通遍历就会返回这三个字节的初始值;但是如果用range关键字,他会帮我们做一些处理,把这三个字节解码拼成一块,就会返回真实的字符,但这样前面的index仍然不准确。

如果采用rune就是我们直觉上的遍历字符数组了,例子如下:

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
package main

import "fmt"
import "strings"

func main() {
//stringByte()
runeSlice()
stringBuilder()
}
func stringByte() {
var str1 = "大连理工大学"
char := str1[0]
fmt.Println(char)
//大连理工大学 has 18 character
fmt.Printf("%v has %v character\n", str1, len(str1))

//index: 0, char:å
//index: 1, char:¤
//index: 2, char:§
//index: 3, char:è
//index: 4, char:¿
//index: 5, char:ž
//index: 6, char:ç
//index: 7, char:
//index: 8, char:†
//index: 9, char:å
//index: 10, char:·
//index: 11, char:¥
//index: 12, char:å
//index: 13, char:¤
//index: 14, char:§
//index: 15, char:å
//index: 16, char:­
//index: 17, char:¦
//这里出现乱码的原因是字符串底层是一个字节数组结构,
//而一个汉字在utf8中占3个字节,他把每一个字节的内容都输出,就不会组成一个完整的汉字
for i := 0; i < len(str1); i++ {
fmt.Printf("index: %d, char:%c\n", i, str1[i])
}

//如果用range关键字就会帮我们把字符串的utf8解码
//index: 0, char:大
//index: 3, char:连
//index: 6, char:理
//index: 9, char:工
//index: 12, char:大
//index: 15, char:学
//这里也可以看到跳过了一些index
for index, v := range str1 {
fmt.Printf("index: %d, char:%c\n", index, v)
}

}
func runeSlice() {
var runeSlice = []rune("大连理工大学")
//index: 0, char:大
//index: 1, char:连
//index: 2, char:理
//index: 3, char:工
//index: 4, char:大
//index: 5, char:学
//这里的index就是顺序的了
for i := 0; i < len(runeSlice); i++ {
fmt.Printf("index: %d, char:%c\n", i, runeSlice[i])
}

}

此外一些string操作都在strings这个包下面,例如stringBuilder和其他的一些字符串操作,需要的时候导入。

结构体与接口

go应该是一个面向过程的语言,这里采用的还是结构体,但是类似的也有接口和类方法,在实现go中的接口时,不需要有java那种implements,编译器搜索所有有该方法签名的结构体自动绑定;在实现类方法时,需要在方法名前绑定结构体。例子如下所示,有一个engine接口,两个实现类

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
package main

import "fmt"

// Engine 接口
type Engine interface {
milesLeft() uint8
}

type GasEngine struct {
mpg uint8
gallons uint8
}

func (ge GasEngine) milesLeft() uint8 {
return ge.mpg * ge.gallons
}

type ElectricEngine struct {
mpkwh uint8
kwh uint8
}

func (ee ElectricEngine) milesLeft() uint8 {
return ee.mpkwh * ee.kwh
}

func canMakeIt(engine Engine, remainMiles uint8) bool {
if engine.milesLeft() < remainMiles {
return false
} else {
return true
}
}
func main() {
var milesLeft uint8 = 60
var ge GasEngine = GasEngine{10, 20}
fmt.Println(canMakeIt(ge, milesLeft))
var ee ElectricEngine = ElectricEngine{10, 5}
fmt.Println(canMakeIt(ee, milesLeft))
}

网易KM社区分享-小P老师服务GC卡顿定位解决

小P老师作为有道AI大模型的重点服务,稳定性与低延迟至关重要。但随着开学季到来,接口流量增加,服务偶现请求延迟升高和GC卡顿异常重启现象。
本文总结上述问题的排查思路及定位过程,相信对遇到内存泄漏、CPU升高、JVM GC异常等问题的小伙伴,有借鉴意义。

一、背景和现象

小P老师服务(java服务容器部署、jdk17、G1回收器)核心任务是提供教育场景下的大模型对话式问答。随着开学季到来,流量也逐渐上升,保障服务稳定性是比较重要的任务之一。

大模型对话式问答通常是一个流式过程,模型回答是一段一段输出给用户的,为了观察到整个模型的延时情况,大模型回答完毕的时间(total time)以及大模型每一段回答的时间(interval time)都添加了监控。

近期发现,小P老师服务里子曰大模型interval time的监控总是超时告警,但是子曰大模型自身的interval time监控确实正常的,同时很奇怪的是只有一个或者部分容器pod出问题。

这两个监控有什么区别呢?简单来说一个是A使用B时对B的监控,另外一个是B对自身的监控,所以理论来说他两监控应该基本一致才是符合预期的(抛去网络延时)。

从这一现象看,说明小P老师本身代码逻辑存在耗时情况或者网络有问题。

另外之前小P也出现过类似情况,我们使用了Huggingface去做大模型token计算,这个组件cpu占用率很多,所以按照之前惯例会查看cpu是否够用。
img.png

img_1.png

图1 容器cpu使用图

img_2.png

图2 jvm监控图

于是发现了图1这样的现象,在容器cpu监控图中发现在服务告警期间cpu usages(使用量)和cpu cfs throttled(抢占)有尖刺。同时也是机缘巧合,想看看jvm里cpu使用占用率多少,于是在图2(黄色线是分配的内存、绿色线是使用的内存)发现了比较重要的一个信息,jvm在这期间eden区分配降低,old区使用、分配激增,维持了一段时间后就自行恢复了。于是我便去查看了一段时间内的GC日志

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[2024-10-23T20:23:56.727+0800] GC(77) Pause Young (Normal) (G1 Evacuation Pause)
[2024-10-23T20:23:56.727+0800] GC(77) Using 1 workers of 1 for evacuation
[2024-10-23T20:24:02.147+0800] GC(77) MMU target violated: 201.0ms (200.0ms/201.0ms)
[2024-10-23T20:24:02.147+0800] GC(77) Pre Evacuate Collection Set: 12.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Merge Heap Roots: 56.7ms
[2024-10-23T20:24:02.147+0800] GC(77) Evacuate Collection Set: 5334.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Post Evacuate Collection Set: 15.4ms
[2024-10-23T20:24:02.147+0800] GC(77) Other: 0.5ms
[2024-10-23T20:24:02.147+0800] GC(77) Eden regions: 482->0(63)
[2024-10-23T20:24:02.147+0800] GC(77) Survivor regions: 17->13(63)
[2024-10-23T20:24:02.147+0800] GC(77) Old regions: 32->32
[2024-10-23T20:24:02.147+0800] GC(77) Archive regions: 2->2
[2024-10-23T20:24:02.147+0800] GC(77) Humongous regions: 473->456 // 标记1
[2024-10-23T20:24:02.147+0800] GC(77) Metaspace: 156861K(158080K)->156861K(158080K) NonClass: 139277K(139840K)->139277K(139840K) Class: 17583K(18240K)->17583K(18240K)
[2024-10-23T20:24:02.147+0800] GC(77) Pause Young (Normal) (G1 Evacuation Pause) 4018M->2007M(6144M) 5419.626ms // 标记2
[2024-10-23T20:24:02.147+0800] GC(77) User=5.08s Sys=0.20s Real=5.42s // 标记3

确实发现了异常的点,标记1可以看出Humongous regions数量非常多且这一次GC并没有回收该区域的内容。(Normal GC是会回收Humongous区域的)

标记2可以看出整个GC耗时大概5.4s,当然从标记3可以更清楚的看出GC耗时,所以我们猜测子曰大模型interval time告警可能和GC耗时过久有关系。

至此我们整合一下问题现象:

  • 小P老师服务对子曰大模型的延时监控发生告警,且与子曰大模型自身监控不一致
  • 只有一部分pod有问题
  • 告警期间服务cpu使用率激增
  • 告警期间jvm内存eden区域分配减少,old区域使用、分配激增,一段时间后恢复

Humongous regions回收不明显,GC停顿过长

根据上述现象,我们可以判断出服务延时告警时和GC有关系,也就是需要从内存的角度来分析为什么GC会停顿这么久,可以算是一个切入点。

分析内存有一个得力工具MemeoryAnayzer(MAT),接下来会先重点介绍一下这个工具,同时也会介绍在jdk17中的G1垃圾回收器。当然如果对此熟悉的可以直接跳过看定位过程

二、Garbage-First (G1) 垃圾回收器

引用文章

Garbage-First (G1) 垃圾收集器针主要对大内存多核的服务,目的是实现应用程序和环境在延迟和吞吐量之间的最佳平衡。

特点:

  • 服务堆大小大于10GB。
  • 对象分配和对象移动的速度可能会随着时间的推移而发生很大变化。

    Rates of object allocation and promotion that can vary significantly over time.

  • 堆中存在大量碎片。
  • 可预测的暂停时间目标不超过几百毫秒,避免长时间的垃圾收集暂停。

2.1 基本概念

G1 将堆分为年轻代(young)和老年代(gen)。空间回收工作集中在最高效的年轻代上,偶尔也会在老年代进行空间回收。

G1 首先回收最高效区域的空间(即大部分被垃圾填充的区域,因此得名)。

G1 主要通过撤离(evacuation)来回收空间:在选定的内存区域找到存活对象复制到新的内存区域,并在过程中对其进行压缩。撤离完成后,先前的空间可用来重新分配。

G1不是实时收集器。尝试尽可能在设定的暂时时间下完成回收,但对于给定的暂停,不能保证绝对满足。

2.2 堆布局

img_3.png

图3 G1垃圾回收器

年轻代包含eden区域(红色)和survivor区域(红色,带“S”)。这些区域内部是连续的,但在G1中这些区域通常以非连续模式排列在内存中。old区域(浅蓝色)构成老生代。对于跨多个区域的对象,会有一个非常大的old区域(浅蓝色,带“H”),叫做Humongous区域 。

应用程序总是分配到年轻代,即eden区域,巨大对象被分配到old区域。

2.3 垃圾回收周期

G1 收集器在两个阶段之间交替。young-only阶段包括垃圾回收(garbage collections),这个阶段会逐渐填满当前可用的内存

空间回收阶段是 G1 除了处理年轻代之外,还会逐步回收老生代中的空间。然后,循环从年轻代阶段重新开始。

img_4.png

图4 垃圾回收周期预览

以下列表详细描述了 G1 垃圾收集周期的各个阶段、暂停以及阶段之间的转换:

  1. 仅年轻代阶段(Young-only phase):此阶段以Normal young collections收集开始,会将对象提升到老年代。当老年代占用率达到某个阈值时,Young-only phase和Space-reclamation phase之间的过渡就开始了。此时,G1 会执行Concurrent Start young collection,而不是Normalyoung collections。

    • Concurrent Start:这种类型的收集除了执行常规Normalyoung collections,还启动标记过程。并发标记确定old区域中的是否可以被回收。在收集标记尚未完全完成时,可能会发生Normalyoung collections。

    • Remark:此这段会完成重新标记。

    • Cleanup:这个阶段决定是否进行Space-reclamation phase。如果确定进行Space-reclamation phase,那么Young-only phase就会进行一次Prepare Mixed young collection.

  2. 空间回收阶段(Space-reclamation phase):此阶段会进行Mixed collections,除了young区域外,还会撤离old区域中的存活对象。当 G1 确定撤离更多老生代区域不会产生足够的可用空间时,空间回收阶段结束。

  1. Young-only phase: This phase starts with a few Normal young collections that promote objects into the old generation. The transition between the young-only phase and the space-reclamation phase starts when the old generation occupancy reaches a certain threshold, the Initiating Heap Occupancy threshold. At this time, G1 schedules a Concurrent Start young collection instead of a Normal young collection.
  • Concurrent Start : This type of collection starts the marking process in addition to performing a Normal young collection. Concurrent marking determines all currently reachable (live) objects in the old generation regions to be kept for the following space-reclamation phase. While collection marking hasn’t completely finished, Normal young collections may occur. Marking finishes with two special stop-the-world pauses: Remark and Cleanup.
  • Remark: This pause finalizes the marking itself, performs global reference processing and class unloading, reclaims completely empty regions and cleans up internal data structures. Between Remark and Cleanup G1 calculates information to later be able to reclaim free space in selected old generation regions concurrently, which will be finalized in the Cleanup pause.
  • Cleanup: This pause determines whether a space-reclamation phase will actually follow. If a space-reclamation phase follows, the young-only phase completes with a single Prepare Mixed young collection.
  1. Space-reclamation phase: This phase consists of multiple Mixed collections that in addition to young generation regions, also evacuate live objects of sets of old generation regions. The space-reclamation phase ends when G1 determines that evacuating more old generation regions wouldn’t yield enough free space worth the effort.

空间回收后,收集周期将以另一个年轻阶段重新启动。作为兜底,如果应用程序在收集活跃度信息时内存不足,G1 将像其他收集器一样执行就会执行Full

2.4 垃圾回收阶段和回收集

garbage Collection Pauses and Collection Set

G1执行垃圾收集和空间回收是在stop-the-world pauses时间内完成的,存活的对象会从堆的一个区域移动到另一个区域,并且对这些对象的引用也会调整。

对于non-humongous的移动:

  • 年轻一代(eden和survivor)的对象被复制到survivor区域或old区域,取决于它们的年龄。
  • 来自old的对象被复制到其他old

对于大对象来说,除非被回收不然永远不会被移动。

对于回收集(collection set):

  • 在 Young-Only ,回收集仅由年轻一代的区域以及可能被回收的巨大区域组成。
  • 在空间回收(Space-reclamation)阶段,回收集由年轻代中的区域、包含可能被回收的对象的巨大区域、以及来自收集集合候选区域的一些老生代区域组成。

G1 在并发周期(concurrent cycle)内准备回收集候选区域。在Remark pause,G1 选择大量闲置空间的低利用率区域。然后在 Remark 和Cleanup pause之间并发准备这些区域以供以后收集使用。Cleanup pause根据效率对准备的结果进行排序。更高效的区域是说,有更多的空间并且回收的时间更少。mixedcollections会更喜欢这些区域。

三、MemeoryAnayzer(MAT)

https://eclipse.dev/mat/

3.1 重要概念

3.1.1 可达性

可达

这个对象仍然有地方引用着他

不可达

这个对象没有任何对象被引用

3.1.2 Shallow 与Retained Heap的区别

Shallow 是一个对象所消耗的内存。对象每个引用需要32或64位(取决于操作系统体系结构),每个Integer需要4字节,每个Long需要8字节,等等。

Shallow heap is the memory consumed by one object. An object needs 32 or 64 bits (depending on the OS architecture) per reference, 4 bytes per Integer, 8 bytes per Long, etc. Depending on the heap dump format the size may be adjusted (e.g. aligned to 8, etc…) to model better the real consumption of the VM.

X的Retained set表示当X被GC垃圾回收后需要移除的对象列表

Retained set of X is the set of objects which would be removed by GC when X is garbage collected.

X的Retained heap是Retained set里所有对象的Shallow大小

Retained heap of X is the sum of shallow sizes of all objects in the retained set of X, i.e. memory kept alive by X.

通俗的来说,Shallow 是这个对象的大小,Retained heap是这个对象被回收之后内存释放的大小

img_5.png

图5 对象引用图以及Retained Set

3.1.3 Dominator Tree

MAT提供了对象图的Dominator Tree,将对象引用图转化为Dominator Tree能够轻松识别保留内存的最大块以及对象之间的依赖关系,下面是一些定义

  • X dominates Y,表示在对象图中,每一个去Y的路径上都需要经过X。
  • X是Y的immediate dominator ,表示X是距离Y最近的支配者
  • dominator tree 是由对象图直接构建而来,能够展现一个对象的immediate dominator

图6是将对象图(左侧)构建为dominator tree (右侧)

img_6.png

图6 对象引用图以及Retained Set

通俗的来说,X dominates Y表示,如果X被回收那么Y一定被回收。但我们常说的引用,如果X引用Y,那么Y是不一定会被回收的,因为Y有可能被Z引用。这就是为什么MAT引入 Dominator这个概念。

3.2 常用功能

3.2.1 Histogram

Histogram列举出每一个class的对象数量以及他的shallow size和retained size,可以快速找出大的对象类

img_7.png

图7 Histogram列表

默认情况下retained size展示的是估算值,也可通过计算才获取他的准确值。

img_11.png

图8 Histogram计算准确retained size

可以查看对象被谁引用或者他又引用了谁

img_10.png

图9 Histogram查看引用关系

img_9.png

图10 Histogram查看引用关系结果

Histogram默认是通过class是分组的,也可以根据包或者加载器

img_8.png

图11 Histogram通过其他类型分组

3.2.2 Dominator Tree

Dominator tree展示了在堆中最大的对象列表。X对象的下一级表示,X被回收之后需要被垃圾回收的对象列表。(也就是X直接支配的对象)同样也可以按类加载器、包进行分组。

The next level of the tree lists those objects that would be garbage collected if all incoming references to the parent node were removed.

img_12.png

图12 Dominator Tree

以上图为例,占用堆内存最大的是TaskThread的http-no-8080-exec-2线程,其本身大小是Shallow Heap是120字节,Retained Heap是2417669960字节,占用整个堆内存94.90%。图中将AspectJExpressionPointcut展开,表示当AspectJExpressionPointcut被内存回收之后,展开列表里的所有对象都会被回收,也就是他的retained set

3.2.3 Immediate Dominators

可以快速找出当前这组(类/对象)的所有immediate dominator(直接支配者)

img_13.png

图13 Histogram找某个类的immediate dominator

下列展现支配Object[]的类列表

img_14.png

图14 Object[]类的immediate dominator

其中所选的那一行表示,TaskThread一共有37个对象,其中支配了133个Object[],并且TaskThread的本身对象大小(shallow size)是4440bytes,他支配的Object[]是2147491680bytes的大小

3.2.4 Leak report

Leak report会列举出可能存在内存泄漏的点,以及发生的栈信息位置

img_15.png

图15 Leak report

四、定位过程

根据在第一节所观察到的问题现象,我们从内存角度来分析GC停顿之间为何这么久?按照惯例,通常都会看一下内存中的大对象,因为大对象一般是造成内存出现问题的罪魁祸首,并且大对象也是最容易发现的。

4.1 查看大对象

jmap -hsito [pid] | head -n [num]

img_16.png

图16 小P老师服务某时刻大对象

大部分服务大对象前列就是byte、int等基本类型(不同的jdk版本可能会不同),也看不出什么门道。

通常先重点关注项目自己的包,再看一些引用的包。图16已经圈出了一些比较可疑的对象,但类比了同类稳定服务,第10行对象也是存在且现象一致的,于是就暂时排除他的嫌疑。

接下来就是12、13行这两个对象,他们用来做流式场景下线程之间上下文的自动传递,在github上看有人也提出了使用该组件的内存问题,我们把他列为可疑对象。

再接着就是20行这个对象,他是之前讲到的Huggingface组件,用来做大模型token计算。这个组件cpu占用率很高(之前性能自测过,图17)。那有没有可能在某个时刻计算量很大导致cpu激增,而容器分配的cpu不够用(而我们也确实发生了cpu抢占的情况),导致长期持有jvm对象而无法回收带来的GC卡顿,所以我也把他列为了可疑对象。

接下来我们来验证猜想。

img_17.png

图17 Huggingface组件性能测试cpu、内存使用情况

4.1.1 验证猜想

我们将图16中,12、13行对象涉及的组件以及20行对象涉及的组件,分别打开/关闭来做性能测试,看 GC和jvm是否有明显变化,但当时并没有发现带来明显的jvm变化以及GC卡顿问题。那么问题可能出现在其他大对象上,这时候就需要把堆内存dump下来做分析了。

4.2 内存dump

根据我们之前观察的现象,old区域激增,一段时间后回落,这不太符合内存泄漏的现象,可能就是大对象被长期持有无法释放,于是在dump内存时,选择将堆里的对象全部dump而不仅仅是存活的对象。

jmap -dump:live,format=b,file=dump.hprof [pid]

4.3 使用MAT工具分析

下载地址

注意一般堆文件多大,MAT内存就需要分配多大,修改方式参考

MAT工具通常我们可以使用他从这几个角度分析:

  • 堆内存中的大对象有些什么?
  • 这些大对象为什么没被回收?看他的支配者:immediate Dominators,看他的GC root
  • 这些大对象为什么这么大?看他支配了谁:retained set

4.3.1 导入堆文件

img_18.png

图18 堆文件导入示意图

4.3.2 查看大对象

使用Histogram查看大的对象(类),根据Retained Heap来排序(点击Retained Heap按钮就可以排序)

img_19.png

图19 堆文件大的对象(类)列表

发现最大的类是java.lang.object[],是一个数组,于是按照刚才思路我们先看他为什么没被回收?就看他的支配者。

4.3.3 查看大对象支配者

尝试看下这个大对象的支配者,看看是不是因为这个支配者应该被回收但是没被回收。

图20发现java.lang.object[]最大的支配者是TaskThread这个类,一共有37个对象实例,支配了133个java.lang.object[],TaskThread类本身大小是4440bytes,支配的对象java.lang.object[]大小是2147491680bytes。

其实看到这里已经没有意义了,因为他是处理http请求的线程,是不可能被回收的,但我们看一下这个TaskThread的GC Root ,看是否是被不小心创建出来的而没释放。

img_20.png

图20 java.lang.object[]的支配者

4.3.4 查看GC root

一般来说查看Gc root时都会选择 exclude weak/soft references,因为这两个引用肯会被GC掉,这是用来查内存泄漏的,但我们场景是对象是被长时间持有段时间无法回收,而不是一直无法回收。所以这里选择展现了所有的references。

img_21.png

图21 查看TaskThreadGC root示意图

从图22来,TaskThread都是tomcat创建的线程用来处理http请求的,http-nio-8080-exec-2支配了很大的对象,那就是刚才java.lang.object[],这种被线程支配的对象,大概率是临时变量,也就是方法栈里创建出来的变量,http-nio-8080-exec-2是不可能被回收的。

img_22.png

图22 TaskThreadGC root

但是临时变量的回收,会在方法执行完,对他引用没有了之后进行。因为我们dump某一个时刻的堆栈信息,可能线程没有执行完,没被回收也是正常的。但是在http所有的线程中,只有这个线程持有很大的对象明显是不合理。于是我接着看 java.lang.object[]对象为什么这么大?

4.3.5 查看retained set

img_23.png

图23 查看java.lang.object[]Retained Set示意图

查看java.lang.object[]Retained Set可以看出他支配了哪些对象/类,就可以知道他为什么这么大(retained set是包含本身的)

img_24.png图22.png

图24 java.lang.object[]Retained Set

从图24可以看出,在其所有支配的对象中,其本身是最大的,到这里好像陷入了死结。

这个对象被谁支配?是一个线程。这个对象为什么这么大?是因为他本身就很大。

但回想起刚才说的,这个对象被http线程支配,因为线程没有执行完,引用没消失所以一直存在,于是我就想能不能看一下这个线程的栈信息,正好MAT中也有这样的功能。

4.3.6 查看栈信息

img_25.png

图25 所有线程的栈信息

从图25来看,http-nio-8080-exec-2占用了很大的retained heap,就接着点开来看就是整个线程的堆栈情况(不排序的话默认就是执行路径)

img_26.png

图26 http-nio-8080-exec-2堆栈信息

看堆栈信息,一般来说是从上到下找到首个业务代码进行分析,从图26可以看出从业务代码ChatManagerImpl.java:300处添加一个元素到列表,最后触发了容器扩容,最终导致OutOfMemoryError。并且这个线程在执行copyOf时持有很大的内存大小Max Local Retained Heap(本地变量保留大小),已经定位到业务代码了,接下来就根据业务代码去看看原因。

4.3.7 跟踪业务代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public List<ChatInfoDO> getChatInfoHistory(String userId, String taskId, Long parentChatId,
Integer groupLevelCount) throws LlmBusinessException {
// 根据chat_group_level粗筛(只取最近的chatCount个level)
List<ChatInfoDO> chatInfoDOList = chatInfoDOMapper.selectChatHistory(userId, taskId, parentChatId,
groupLevelCount);
if (chatInfoDOList == null || chatInfoDOList.size() == 0) {
throw new LlmBusinessException(ErrorCode.USER_WRONG_CHAT_HISTORY);
}
// 根据 parentChatId 串起 chatHistory 返回,此时是逆序的
Map<Long, ChatInfoDO> chatIdMap = new HashMap<>();
for (ChatInfoDO chatInfoDO : chatInfoDOList) {
chatIdMap.put(chatInfoDO.getChatId(), chatInfoDO);
}
List<ChatInfoDO> history = new ArrayList<>();
Long chatId = parentChatId;
// 逆序查找,从最后一条对话chatId开始,继续条件:chatId=当前parentChatChatId(子节点找父节点)
ChatInfoDO chatInfoDO;
while ((chatInfoDO = chatIdMap.get(chatId)) != null) {
history.add(chatInfoDO); // 标记1 问题代码处
chatId = chatInfoDO.getParentChatId();
}
Collections.reverse(history);
return history;
}

在分析可疑点之前,我先简单描述下这段代码所做的事情。

在小P老师对话场景中,是采用一问一答的形式,例如下方图27所示,蓝色表示用户,淡红色表示系统回答。

img_27.png

图27 大模型对话示意图

为了让模型更好的理解用户问题,通常我们会像图26所示,携带所有的历史消息送给模型。当前业务代码就是找到用户的历史对话然后构建起来提供给模型。

img_28.png

图28 携带历史对话示意

如图29所示,我们给每个消息两个属性id=xxx、parendId=xxx,这样来呈现一种父子关系,用户输入消息时生成id,并通过传入的parentId=3向上寻找消息,找到id=3的消息,循环寻找,直到parentId=-1

img_29.png

图29 构建历史对话示意图

回过头我们来看业务代码,标记1就是栈信息所示的位置,这处代码其实有一个很明显的风险点while循环构建链表,同时结合我们的对象是一个大数组,那这个while循环就很可疑。结合刚才业务代码逻辑的分析,我当时想到了以下可疑点:

  • 一个消息的id和parentId一致发生了循环,导致死循环
  • chatInfoDOMapper.selectChatHistory()从数据库查出来的数据量很大

接着看了数据库查询语句chatInfoDOMapper.selectChatHistory()不可能发生查出很多数据的问题。

那么现在最可疑的就是消息循环了,本来分享到这就结束了。要去查数据库看看有没有id和parentId重复的数据了,但因为当时是和同事们在分享这篇文章,同事们就提出了两个问题。

  • 有没有可能是两个消息发生了循环?消息A找到了消息B,消息B又找回了消息A。
  • MAT可以看这个链表里有啥吗?以及能不能看这个对象的值,不然查库可能会很慢。

很显然第一个是很有可能的。 第二个问题因为对MAT还是初次使用所以不太了解,但在同事的引导下,我们尝试看链表里具体的数据是什么样子。

4.3.8 查看栈具体用了哪些对象
img_30.png

图30 栈的临时变量

如图30所示,我们继续点击业务代码方法栈点,就可以看到这个方法栈点引用了(注意是引用不是支配)HashMap、ArrayList、ChatInfoDO,因为根据业务代码分析可能是ArrayList膨胀,所以继续点击ArrayList可以看他引用的元素elementData,包括了object[]、ChatInfoDO。这里问题就展现出来了,如图30红框所示,ArrayList奇数位置[1],[3],[5]…都是ChatInfoDO_A,偶数位置[0],[2],[4]…都是ChatInfoDO_B,并且再次点击ChatInfoDO_A和ChatInfoDO_B就可以看到他们的chatId、parentChatId,这时候看到他们确实互为引用了,如图31所示。

img_31.png

图31 互为引用的消息

至此问题原因顺利找到。

后续分析还发现,不仅是两个消息会循环,多个消息也会循环。历史消息构建其实是单链表从尾到头的构建过程,找到头节点就停止,但某个位置产生了环就导致悲剧。所以得出一点建议:之后while的使用一定得注意!!!。

虽然原因找到了,但为什么产生重复的Id呢?我们设计的Id可是唯一的!于是我们又分析了生成Id的代码。

4.4 分析ID重复的原因

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

/**
* 循环数
*/
private static long cycleNumber = 10000;

/**
* 循环下限
*/
private static long startNumber = 10001;

/**
* 循环上限
*/
private static long stopNumber = 99999;

/**
* 返回一个当前时间的long类型数字(非线程安全)
* 理论上每毫秒可生成id 89999 个
*
* @return
*/
public static long getNextId() {
if (cycleNumber < stopNumber) {
cycleNumber++;
} else {
cycleNumber = startNumber;
}
return System.currentTimeMillis() + cycleNumber;
}
}

因为小P老师服务是分布式服务,有多个节点,需要保障消息唯一Id。常见唯一Id方式很多:UUID、雪花等等,但基于我们的考虑并没有使用上边的方式。

当时在设计唯一Id时主要考虑以下几点:

  • 具有时间性
  • 生产效率高
  • 符合数字需求
    于是就通过时间戳来体现时间性,在加一个全局唯一的循环数,这样是不是具有符合上述的要求了?

但在大家的分析下发现了这样一个BUG,假如当前时间是10,随机数是10,过了一段时间后当前时间是19,随机数已经发生循环变成了1,这样两个Id是不是都一样变成20了(但概率确实很低!!!)

到此终于真相大白了!

五、 总结

分析过程其实是坎坷的,总结的时候,已变成已知答案寻找答案的过程,所以看起来会很顺畅。

问题千奇百怪,分析过程也千奇百怪,但总结了一些小经验。

  • 监控jvm内存或者可以观察jvm是比较重要的
  • GC日志也是比较重要的日志
  • 内存问题一般可以从大对象着手,分析对象为什么这么大?为什么没被回收?
  • MAT的Histogram、Dominator Tree看大对象
  • MAT的Immediate Dominators看大对象被谁直接支配而没回收
  • MAT的retained set看大对象支配了哪些,导致他这么大
  • MAT的线程分析,来分析线程持有对象特别大的情况,分析栈信息

当然,在问题处理的过程中,还有一些不可忽视的细节操作,对排查问题至关重要。

  • 如何抓取偶现问题的JVM dump现场?
  • 只有内存泄漏才会引起内存使用率升高吗?
  • 如何分析GC日志数据,推断问题原因?

基于篇幅有限,本文不再赘述,后续会编写系列KM文章,为大家带来实践中走过的弯路与总结的小技巧。

网易牛马日志-完结篇

过年以后回来做的东西太杂了,想到哪说哪吧。

需求12:出海项目搜索功能

这个得包装了,好不容易一个可能的高并发C端接口,但是实际上做的很简单。

搜索V1:实际做的

数据库直接like就完了,纯纯没有一丝的技术含量。

搜索V2:包装。。。未完待续

需求13:全球搜数据工程产品图片爬取

这部分只做了前段部分,用jsoup去解析标签,再getDocumentByClass去找url,图片名称和信息。

这里也不清楚class会不会随着编译改变,但是测试下来确实是可以的。

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
public ExtraResultDTO doExtra(String domain){
ExtraResultDTO resultDTO = new ExtraResultDTO();
try{
String html = getHtml(domain);
if(html == null || StringUtils.isBlank(html)){
log.warn("doExtra error.html is null");
resultDTO.setStatus(101);
return resultDTO;
}
if(html.contains("Our systems have detected unusual traffic from your computer")){
log.warn("doExtra Google Pickpocket Detection Limit");
resultDTO.setStatus(102);
return resultDTO;
}
Document doc = Jsoup.parse(html);
List<ImageDTO> images = new ArrayList<>();
Elements divs = doc.getElementsByClass("RntSmf");
for (Element div : divs) {
//图片路径
String imgUrl = div.getElementsByTag("img").get(0).attr("src");
String desc = div.getElementsByClass("qXLe6d x3G5ab").get(0).text();
String jumpUrl = div.getElementsByClass("qXLe6d F9iS2e").get(0).text();
ImageDTO image = new ImageDTO();
image.setUrl(imgUrl);
image.setFormatUrl(imgUrl);
image.setAlt(desc);
images.add(image);
}
resultDTO.setStatus(200);
resultDTO.setImages(images);
}catch (Exception e){
log.error("doExtra error.",e.getMessage());
resultDTO.setStatus(205);
resultDTO.setErrorMessage(e.getMessage());
}
return resultDTO;
}

需求14:全球搜数据工程公司Logo爬取

比较有挑战性的一整个链路,问题在于es里面logo字段并不是索引,所以不能用exist来查询。主要思路是查询线上有域名的公司,过滤掉有logo字段的,将无logo字段但是有域名的公司通过kafka消费到本地,然后通过爬虫将图片下载下来。

前处理链路:

链路:

  • 猛犸抽取线上es到hive,这一段全量数据写入hive,大概2600万。

img_1.png

  • 然后hive ->hive,通过sql来过滤掉有logo的公司域名,此外由于抽取的域名domain是从一个list里面来的,在变成字符串后有”[“和”]“,需要过滤,最后得到数据量大概1600万。

img_2.png

1
2
3
4
5
6
7
8
9
10
11
insert
OVERWRITE table qiye_mail_data.logo_extra_offline_domain_v1
select
REPLACE(SUBSTR(
domain,
2,
LENGTH(domain) -2
), '"', '') as domain,
companyid,
locationdomain
from qiye_mail_data.logo_extra_offline_domain where logourl=''
  • 最后猛犸任务hive->kafka,测试环境集群做消费,这才正式进入logo图片提取的链路。

责任链模式进行公司Logo爬取

首先是三种找Logo的方法,一般来说Logo都会放在浏览器的ico上,相关链接在csdn

  • 通过google某个api拿,这种成功率最高,但是会返回默认图片,后续需要校验md5来过滤。
  • 直接在网站域名后面拼接/favicon.ico,成功率不高,因为小公司的网页并不一定有这么规范,其次是可能会返回404的html页面,也会有默认的ico文件,所以要写一个方法过滤html和默认的md5.
  • 爬虫解析,拿到domain的源码,再去解析里面的,然后通过正则表达式去匹配icon,成功率不高,属于是最后的底牌了。

另外这里有的都是domain,意思是没有http和https的,所以都需要进行尝试,综上所述,一共得走6个链路,哪个成功了哪个就返回,很适合责任链模式。

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
// 责任链执行
private UploadResultBO handleLogoCrawlAndUploadChain(String domain,String locationDomain) {
//有locationDomain的情况
if (StringUtils.isNotEmpty(locationDomain)){
UploadResultBO googleStrategy = googleStrategy(locationDomain);
if (StringUtils.isNotBlank(googleStrategy.getUrl())){
return googleStrategy;
}
UploadResultBO straightStrategy = straightStrategy(locationDomain);
if (StringUtils.isNotBlank(straightStrategy.getUrl())){
return straightStrategy;
}
return htmlLinkTagStrategy(locationDomain);
}
//无locationDomain或者失效的情况,需拼接http和https尝试
UploadResultBO googleStrategy = googleStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(googleStrategy.getUrl())){
return googleStrategy;
}
UploadResultBO straightStrategy = straightStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(straightStrategy.getUrl())){
return straightStrategy;
}
UploadResultBO htmlLinkTagStrategy = htmlLinkTagStrategy(HTTP_PREFIX + domain);
if (StringUtils.isNotBlank(htmlLinkTagStrategy.getUrl())){
return htmlLinkTagStrategy;
}
UploadResultBO googleStrategyHttps = googleStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(googleStrategyHttps.getUrl())){
return googleStrategyHttps;
}
UploadResultBO straightStrategyHttps = straightStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(straightStrategyHttps.getUrl())){
return straightStrategyHttps;
}
UploadResultBO htmlLinkTagStrategyHttps = htmlLinkTagStrategy(HTTPS_PREFIX + domain);
if (StringUtils.isNotBlank(htmlLinkTagStrategyHttps.getUrl())){
return htmlLinkTagStrategyHttps;
}
return new UploadResultBO(StringUtils.EMPTY, LogoExtraStatusEnum.STRATEGY_FAIL.getStatus());
}

上传到nos

调洋总的接口就完事了,然后将这个链接保存进es,良总那里会有一个同步链路将触发版本更新的数据同步到线上。最终的效果就是测试集群在消费数据,将爬取的logo的nosurl保存进es并更新版本号,最后用同步链路更新到线上。

需求15:全球搜应用工程ai推荐理由总结

比较简单,就是多线程调用大模型api,由于需要时效性,deepseek要输出思维链所以时效性很差,不适合用在业务里面,所以用gpt。其次开一个线程池来优化并发请求,此外就是prompt优化,很简单的一个需求。

img.png

关于提示词,mentor的意思是尽量可读性高,产品词输出中文,看的会比较丝滑,但是在第一版的提示词里面海关数据基本没怎么用,后续就变为:

1
2
3
prompt:
根据提供的信息,总结公司的核心产品、主营类目、主要交易产品等信息,并判断分析与关键词XXXXX,XXXx的相关性,给出最终的匹配理由。输出格式:
"匹配理由":"XXX"
  • 线程池
    1
    2
    3
    private static final ThreadFactory MATCH_ANALYZE_LLM_THREAD_FACTORY = new ThreadFactoryBuilder().setNameFormat("MatchAnalyzeService-llm-pool-%d").build();
    private static final ExecutorService LLM_REQUEST_EXECUTOR = new ThreadPoolExecutor(20,
    40, 60 * 5L, TimeUnit.SECONDS, new LinkedBlockingDeque<>(3000), MATCH_ANALYZE_LLM_THREAD_FACTORY, new ThreadPoolExecutor.CallerRunsPolicy());
  • prompt
    1
    2
    3
    private static final String BASE_PROMPT = "根据提供的信息,总结公司的主营产品、海关交易产品等信息,并判断分析与关键词{0}的相关性,给出最终的匹配理由。输出格式:\n" +
    "\"匹配理由\":\"XXX\"\n" +
    "以下是公司信息:\n";
  • 动态组装和展示
    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
    @Override
    public MatchAnalyzeResultDTO getMatchAnalyze(MatchAnalyzeParam globalSearchParam) {
    //参数校验,id非空
    if (globalSearchParam == null || StringUtils.isEmpty(globalSearchParam.getId())){
    return new MatchAnalyzeResultDTO();
    }
    CompanySearchBO companySearchBO = companySearchService.queryById(globalSearchParam.getId(),
    new String[]{"customsItems", //海关交易数据
    "htagItems", //公司官网
    "overviewDescription", //公司描述
    "detail.productList.name", //产品图片描述
    "keywords", //公司关键词
    "detail.mainProducts", //公司主营产品
    "brandNames" //公司品牌信息
    },
    null);
    //索引不存在,返回空
    if (companySearchBO == null){
    return new MatchAnalyzeResultDTO();
    }
    //合并搜索词和扩展词
    List<String> nearSynonymList = globalSearchParam.getNearSynonymList();
    nearSynonymList.add(globalSearchParam.getProduct());
    String trimNearSynonymList = nearSynonymList.toString().replaceAll("\\[|\\]", "");
    StringBuilder promptStringBuilder = new StringBuilder(MessageFormat.format(BASE_PROMPT,trimNearSynonymList));
    if (companySearchBO.getCustomsItems() != null && !companySearchBO.getCustomsItems().isEmpty()){
    //裁剪为10个以内,避免token超出
    List<String> subCustomsItemsList = companySearchBO.getCustomsItems().subList(0, Math.min(companySearchBO.getCustomsItems().size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司的海关交易记录:").append(subCustomsItemsList).append("\n");
    }
    if (companySearchBO.getHtagItems() != null && !companySearchBO.getHtagItems().isEmpty()){
    //裁剪为10个以内,避免token超出
    List<String> subHtagItemsList = companySearchBO.getHtagItems().subList(0, Math.min(companySearchBO.getHtagItems().size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司的官网信息:").append(subHtagItemsList).append("\n");
    }
    if (StringUtils.isNotEmpty(companySearchBO.getOverviewDescription())){
    promptStringBuilder.append("公司描述:").append(companySearchBO.getOverviewDescription()).append("\n");
    }
    //产品图片描述处理
    if (companySearchBO.getDetail() != null && companySearchBO.getDetail().getProductList() != null && !companySearchBO.getDetail().getProductList().isEmpty()){
    List<ProductVO> productList = companySearchBO.getDetail().getProductList().subList(0, Math.min(companySearchBO.getDetail().getProductList().size(), LIST_LENGTH_LIMIT));
    //映射为name
    List<String> productListName = productList.stream().map(ProductVO::getName).collect(Collectors.toList());
    promptStringBuilder.append("产品图片描述:").append(productListName).append("\n");
    }
    if (companySearchBO.getKeywords() != null && !companySearchBO.getKeywords().isEmpty()){
    promptStringBuilder.append("公司关键词:").append(companySearchBO.getKeywords()).append("\n");
    }
    //公司主营产品处理
    if (companySearchBO.getDetail() != null && companySearchBO.getDetail().getMainProducts() != null && !companySearchBO.getDetail().getMainProducts().isEmpty()){
    Set<String> mainProducts = companySearchBO.getDetail().getMainProducts();
    List<String> subMainProductsList = new ArrayList<>(mainProducts).subList(0, Math.min(mainProducts.size(), LIST_LENGTH_LIMIT));
    promptStringBuilder.append("公司主营产品:").append(subMainProductsList).append("\n");
    }
    if (companySearchBO.getBrandNames() != null && !companySearchBO.getBrandNames().isEmpty()){
    List<String> subBrandNamesList = companySearchBO.getBrandNames().subList(0, Math.min(companySearchBO.getBrandNames().size(), LIST_LENGTH_LIMIT));

    promptStringBuilder.append("公司品牌信息:").append(subBrandNamesList).append("\n");
    }
    //拼接的最终prompt
    String finalPrompt = promptStringBuilder.toString();
    CompletableFuture<String> future2 = CompletableFuture.supplyAsync(
    () -> gptGrpcWrapper.gptRequest("6888072","583828445","yangyifan12@corp.netease.com",finalPrompt, GPTModelVersionEnum.GPT_4O_MINI.getVersion()), LLM_REQUEST_EXECUTOR);
    String result = (String) FutureResultUtil.getResult("match-analyze-llm-future",future2,120, TimeUnit.SECONDS);
    return new MatchAnalyzeResultDTO(result);
    }