一个专注于记录技术成长与实战经验的个人技术博客，涵盖 Java/Spring Boot 开发实战、Kubernetes 和云原生架构、数据库与性能调优、DevOps 工具链等方向的技术解析与干货分享。 宗旨是：好记性不如烂笔头。https://www.kops.cc。技术交流加微 haxicc
在云环境中，Kubernetes 控制面的高可用通常交给云厂商的 LoadBalancer 来解决。但在以下场景中，这条路并不成立：私有 IDC / 裸金属集群边缘计算、离线环境公有云迁回自建机房（缺少云 LB、云 SLB）希望减少组件复杂度、避免引入 keepalived / VRRP我之前有了解过 sealos，它提供了一种非常“反常规但优雅”的方案：不做 VIP，不做 VRRP，而是用 IPVS + LVScare 在每个节点本地维护 kube-apiserver 的高可用访问能力。本文就是在这一思路启发下，对 LVScare 作为 Kubernetes 高可用组件 的一次整理与实践总结。传统 Kubernetes API Server 高可用方案回顾方案一：云 LoadBalancer优点：简单、稳定对使用者透明缺点：强依赖云厂商私有化 / IDC 场景不可用方案二：VRRP + keepalived（VIP） VIP:6443 | +----------+------------+ | | Nginx <-keepalived-> Nginx(Backup) | +---+--------------------------+ | | | apiserver apiserver apiserver问题在于：需要额外的 LB 节点需要维护 VRRP / VIP架构组件偏重sealos 的思路：去中心化的 API Server 高可用sealos 提出的核心思路是：不再提供一个“中心入口”，而是让每个节点都具备访问“所有 API Server 的能力”。hosts 配置假设节点主机名和 IP 如下，在每个节点上配置 hosts。10.0.0.1 apiserver.cluster.local # 临时解析到第一个master的ip，集群搭建完成后会改掉 10.0.0.1 master1 10.0.0.2 master2 10.0.0.3 master3 10.0.0.11 node1安装软件配置 Kubernetes 软件源：cat <<EOF | tee /etc/yum.repos.d/kubernetes.repo [kubernetes] name=Kubernetes baseurl=https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.33/rpm/ enabled=1 gpgcheck=1 gpgkey=https://mirrors.aliyun.com/kubernetes-new/core/stable/v1.33/rpm/repodata/repomd.xml.key exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni EOF安装所需软件：yum install -y runc ipset ipvsadm yum install -y kubelet kubeadm kubectl --disableexcludes=kubernetes安装containerd：从 containerd 发布页面（https://github.com/containerd/containerd/releases）根据芯片架构下载最新的版本即可。比如 x86_64 平台，下载 containerd-2.x.x-linux-amd64.tar.gz解压 tar 包到 /usr/localtar xf containerd-2.x.x-linux-amd64.tar.gz -C /usr/local配置systemd服务cat > /usr/local/lib/systemd/system/containerd.service << "EOF" [Unit] Description=containerd container runtime Documentation=https://containerd.io After=network.target [Service] ExecStartPre=-/sbin/modprobe overlay ExecStart=/usr/local/bin/containerd Type=notify Delegate=yes KillMode=process Restart=always RestartSec=5 LimitNPROC=infinity LimitCORE=infinity TasksMax=infinity OOMScoreAdjust=-999 [Install] WantedBy=multi-user.target EOF设置服务开机自启systemctl enable containerd kubelet配置 containerdmkdir -p /etc/containerd cat > /etc/containerd/config.toml << "EOF" version = 3 root = "/var/lib/containerd" state = "/run/containerd" [grpc] address = "/run/containerd/containerd.sock" [plugins.'io.containerd.internal.v1.opt'] path = "/var/lib/containerd" [plugins.'io.containerd.grpc.v1.cri'] stream_server_address = "127.0.0.1" stream_server_port = "10010" [plugins.'io.containerd.cri.v1.runtime'] enable_selinux = false enable_unprivileged_ports = true enable_unprivileged_icmp = true device_ownership_from_security_context = false [plugins.'io.containerd.cri.v1.images'] snapshotter = "overlayfs" disable_snapshot_annotations = true [plugins.'io.containerd.cri.v1.images'.pinned_images] sandbox = "registry.k8s.io/pause:3.10" [plugins.'io.containerd.cri.v1.runtime'.cni] conf_dir = "/etc/cni/net.d" [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2" [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runc.options] SystemdCgroup = true [plugins.'io.containerd.cri.v1.runtime'.containerd.runtimes.runhcs-wcow-process] runtime_type = "io.containerd.runhcs.v1" [plugins.'io.containerd.cri.v1.images'.registry] config_path = "/etc/containerd/certs.d" EOF配置私有仓库：你需要一个私有仓库，并且做了镜像代理：hub.yourdomain.com/docker.io -> 可以直接代理拉取docker.io的镜像hub.yourdomain.com/registry.k8s.io -> 可以直接代理拉取registry.k8s.io的镜像如果你的私有仓库还不具备这样的能力，你需要手动搬运镜像并且适当改造以下containerd的配置文件。mkdir -p /etc/containerd/certs.d/{docker.io,registry.k8s.io} cat > /etc/containerd/certs.d/docker.io/hosts.toml << "EOF" [host] [host."https://hub.yourdomain.com/v2/docker.io"] capabilities = ["pull", "resolve"] override_path = true EOF cat > /etc/containerd/certs.d/registry.k8s.io/hosts.toml << "EOF" [host] [host."https://hub.yourdomain.com/v2/registry.k8s.io"] capabilities = ["pull", "resolve"] override_path = true EOF运行 containerd：systemctl start containerd以上操作，在每个节点都要执行。初始化第一个控制节点生成 kubeadm 配置文件：kubeadm config print init-defaults --component-configs KubeletConfiguration > kubeadm.yaml在此基础上，修改：apiVersion: kubeadm.k8s.io/v1beta4 bootstrapTokens: - groups: - system:bootstrappers:kubeadm:default-node-token token: abcdef.0123456789abcdef ttl: 24h0m0s usages: - signing - authentication kind: InitConfiguration localAPIEndpoint: advertiseAddress: 10.0.0.1 # 修改，节点的IP bindPort: 6443 nodeRegistration: criSocket: unix:///var/run/containerd/containerd.sock imagePullPolicy: IfNotPresent imagePullSerial: true name: master1 # 修改，节点的主机名 taints: null timeouts: controlPlaneComponentHealthCheck: 4m0s discovery: 5m0s etcdAPICall: 2m0s kubeletHealthCheck: 4m0s kubernetesAPICall: 1m0s tlsBootstrap: 5m0s upgradeManifests: 5m0s --- apiVersion: kubeproxy.config.k8s.io/v1alpha1 # 新增配置段，设置kube-proxy为ipvs模式 kind: KubeProxyConfiguration mode: ipvs --- apiServer: certSANs: # 新增配置段，添加其他master节点的相关信息 - master1 - master2 - master3 - 10.0.0.1 - 10.0.0.2 - 10.0.0.3 - 100.100.100.100 # 固定写 100.100.100.100，后面用作负载均衡IP - apiserver.cluster.local apiVersion: kubeadm.k8s.io/v1beta4 caCertificateValidityPeriod: 87600h0m0s # 修改证书有效期为10年 certificateValidityPeriod: 87600h0m0s # 修改证书有效期为10年 certificatesDir: /etc/kubernetes/pki clusterName: kubernetes controlPlaneEndpoint: apiserver.cluster.local:6443 # 添加这个配置 controllerManager: {} dns: {} encryptionAlgorithm: RSA-2048 etcd: local: dataDir: /var/lib/etcd imageRepository: hub.yourdomain.com/docker.io/kubesphere # 修改为私有仓库地址，使用kubesphere是因为这个仓库有完整的Kubernetes镜像 kind: ClusterConfiguration kubernetesVersion: 1.33.3 # 修改版本号，一般安装这个版本的最新小版本 networking: dnsDomain: cluster.local serviceSubnet: 10.96.0.0/12 # 按需修改 podSubnet: 10.244.0.0/16 # 按需修改 proxy: {} scheduler: {} --- apiVersion: kubelet.config.k8s.io/v1beta1 authentication: anonymous: enabled: false webhook: cacheTTL: 0s enabled: true x509: clientCAFile: /etc/kubernetes/pki/ca.crt authorization: mode: Webhook webhook: cacheAuthorizedTTL: 0s cacheUnauthorizedTTL: 0s cgroupDriver: systemd clusterDNS: - 10.96.0.10 # 如果修改了serviceSubnet，这个也要改 clusterDomain: cluster.local containerRuntimeEndpoint: "" cpuManagerReconcilePeriod: 0s crashLoopBackOff: {} evictionPressureTransitionPeriod: 0s fileCheckFrequency: 0s healthzBindAddress: 127.0.0.1 healthzPort: 10248 httpCheckFrequency: 0s imageMaximumGCAge: 0s imageMinimumGCAge: 0s kind: KubeletConfiguration logging: flushFrequency: 0 options: json: infoBufferSize: "0" text: infoBufferSize: "0" verbosity: 0 memorySwap: {} nodeStatusReportFrequency: 0s nodeStatusUpdateFrequency: 0s rotateCertificates: true runtimeRequestTimeout: 0s shutdownGracePeriod: 0s shutdownGracePeriodCriticalPods: 0s staticPodPath: /etc/kubernetes/manifests streamingConnectionIdleTimeout: 0s syncFrequency: 0s volumeStatsAggPeriod: 0s拉取镜像：kubeadm config images pull --config kubeadm.yaml初始化节点：kubeadm init --upload-certs --config kubeadm.yamlinit 完成后，会输出两条 join 命令，一条是添加控制节点，一条是添加工作节点。添加控制节点执行第一个控制节点输出的那条添加控制节点的 kubeadm join 命令即可。kubeadm join apiserver.cluster.local:6443 --token abcdef.0123456789abcdef \--discovery-token-ca-cert-hash sha256:xxx \--control-plane --certificate-key xxx添加工作节点为了实现 kube-apiserver 的高可用，引入一个负载均衡工具 lvscare，其工作原理如下： +----------+ +---------------+ virturl server: 100.100.100.100:6443 | master1 |<--------------------| nodes(ipvs) | real servers: +----------+ |---------------+ 10.0.0.1:6443 | 10.0.0.2:6443 +----------+ | 10.0.0.3:6443 | master2 |<--------------------+ +----------+ | | +----------+ | | master3 |<--------------------+ +----------+lvscare 做为静态 Pod 运行，会被 kubelet 拉起。配置静态 Pod 部署文件：注意修改 rs 的地址mkdir -p /etc/kubernetes/manifests cat > /etc/kubernetes/manifests/lvscare.yaml << "EOF" apiVersion: v1 kind: Pod metadata: creationTimestamp: null name: kube-lvscare namespace: kube-system spec: containers: - args: - care - --vs - 100.100.100.100:6443 - --health-path - /healthz - --health-schem - https - --rs - 10.0.0.1:6443 # 第一个控制节点的apiserver - --rs - 10.0.0.2:6443 # 第二个控制节点的apiserver - --rs - 10.0.0.3:6443 # 第三个控制节点的apiserver command: - /usr/bin/lvscare image: hub.yourdomain.com/docker.io/labring/lvscare:v5.0.1 imagePullPolicy: IfNotPresent name: kube-lvscare resources: {} securityContext: privileged: true volumeMounts: - mountPath: /lib/modules name: lib-modules readOnly: true hostNetwork: true priorityClassName: system-node-critical volumes: - hostPath: path: /lib/modules type: "" name: lib-modules status: {} EOF执行那条添加工作节点的 kubeadm join 命令即可。kubeadm join apiserver.cluster.local:6443 --token abcdef.0123456789abcdef \--discovery-token-ca-cert-hash sha256:xxx安装 canal 网络插件下载 canal 部署文件：https://raw.githubusercontent.com/projectcalico/calico/v3.30.2/manifests/canal.yaml默认网段：10.244.0.0/16如果前面部署 Kubernetes 集群修改过这个网段，那么 canal的yaml也需要改成一样的网段。 # Flannel network configuration. Mounted into the flannel container. net-conf.json: | { "Network": "10.244.0.0/16", "Backend": { "Type": "vxlan" } }部署 canal：kubectl apply -f canal.yaml修改 apiserver.cluster.local hosts 解析工作节点100.100.100.100 apiserver.cluster.local控制节点控制节点的 apiserver.cluster.local 解析到本机 IP 地址（或127.0.0.1）即可。
GitLab OAuth Plugin + Matrix Authorization Strategy 子群组授权报错问题分析与修复
背景在统一发布平台中，Jenkins 通过 GitLab OAuth Plugin 实现单点登录，并结合 Matrix Authorization Strategy Plugin 在 Folder 级别启用 Enable project-based security，实现项目矩阵授权。在实际使用中，发现一个明显的问题：GitLab 子群组（subgroup）无法作为授权主体使用具体表现为：GitLab 中存在如下群组结构：group1 └── subgroup1在 Jenkins Folder → 权限配置中添加群组：group1/subgroup1Jenkins 校验时报错：java.lang.NullPointerException: Cannot invoke "org.gitlab4j.api.models.Group.getName()" because "this.gitlabGroup" is null添加子群组报错：点开显示详情，报错信息如下：问题影响需要特别说明的是：经过测试，该报错并不会影响 Jenkins 实际的权限生效逻辑，功能是可用的，只是前端报错了。即：即使页面校验报错子群组成员在实际访问 Job / Folder 时权限是生效的但该问题依然具有较大影响，主要体现在使用体验和认知层面：Jenkins 页面在添加子群组时直接报错，给人“配置失败”的强烈暗示管理员往往会误以为 Jenkins 不支持 GitLab 子群组容易导致误判为权限未生效，从而放弃子群组授权方案迫使平台退回到顶层群组授权，权限粒度被迫放大因此，这是一个：不影响核心功能，但严重影响可用性与可理解性的实现缺陷在多项目、多团队的 Jenkins 场景下，该问题依然会对平台治理产生明显负面影响。问题定位过程现象分析从异常栈可以看出，问题发生在：Matrix Authorization Strategy Plugin 校验群组名称时调用 GitLab OAuth Plugin 提供的群组解析逻辑关键异常信息：Cannot invoke Group.getName() because gitlabGroup is null说明：Jenkins 期望从 GitLab OAuth Plugin 中获取一个 Group但根据传入的 group1/subgroup1，插件返回了 null代码分析定位到 GitLab OAuth Plugin 中用于加载群组的方法：public Group loadOrganization(String organization)原始实现逻辑如下：public Group loadOrganization(String organization) { if (StringUtils.isEmpty(organization)) return null; try { if (gitLabAPI != null && isAuthenticated()) { List<Group> gitLabGroups = gitLabAPI.getGroupApi().getGroups(); if (!gitLabGroups.isEmpty()) { return gitLabGroups.stream() .filter(group -> group.getName().equalsIgnoreCase(organization)) .findFirst() .orElse(null); } } } catch (GitLabApiException e) { LOGGER.log(Level.FINEST, e.getMessage(), e); } return null; }关键问题点organization 实际上传入的是：group1/subgroup1但代码却使用：group.getName()而在 GitLab 中：字段示例说明namesubgroup1仅群组名称fullPathgroup1/subgroup1完整路径name 永远不可能匹配子群组的完整路径。因此：顶层群组（group1）可匹配子群组（group1/subgroup1）必然返回 null这是一个确定性的实现错误。修复方案修复思路将群组匹配逻辑从：name → fullPath使其与 Jenkins 中输入的 GitLab 群组标识保持一致。修复后的代码public Group loadOrganization(String organization) { if (StringUtils.isEmpty(organization)) return null; try { if (gitLabAPI != null && isAuthenticated()) { List<Group> gitLabGroups = gitLabAPI.getGroupApi().getGroups(); if (!gitLabGroups.isEmpty()) { return gitLabGroups.stream() .filter(group -> group.getFullPath().equalsIgnoreCase(organization)) .findFirst() .orElse(null); } } } catch (GitLabApiException e) { LOGGER.log(Level.FINEST, e.getMessage(), e); } return null; }修复点非常简单，仅修改了一行代码：- group.getName() + group.getFullPath()修改后，授权页面添加 GitLab 子群组不再报错。修复验证验证方式：Jenkins 启用 GitLab OAuth 登录Folder 启用 Enable project-based security添加授权主体：group1/subgroup1权限校验通过，无异常抛出子群组成员权限生效效果：
建设目标构建一个 统一、规范、安全、可扩展 的发布平台，满足以下目标：支持多项目并行使用，项目之间权限严格隔离通过 GitLab 单点登录，避免用户双重维护权限最小化，授权清晰项目自治，减少平台管理员日常介入成本支持项目规模扩展（用户 200+、项目数持续增长）总体架构核心组件组件作用GitLab用户身份源、组管理、代码仓库Jenkins统一发布平台Jenkins插件：GitLab Authentication plugin实现 GitLab SSO 登录Jenkins插件：Matrix Authorization Strategy Plugin实现细粒度授权（全局 / 文件夹 / Job）Jenkins插件：Folder Plugin按项目文件夹隔离，方便权限管理认证与用户管理设计GitLab 单点登录（SSO）Jenkins 不本地维护任何用户所有用户通过 GitLab OAuth 登录 JenkinsJenkins 用户名 = GitLab username（保持唯一性）收益：用户账号由 GitLab 统一管理避免 Jenkins 账号残留GitLab 的群组方便在 Jenkins 上分配权限GitLab 组作为权限载体不直接给个人授权，优先授权 GitLab 组Jenkins 权限配置中仅引用：GitLab Group少量特权用户（如项目管理员）项目与 Job 组织结构项目级文件夹模型设计原则：一个项目 = 一个 Jenkins FolderJenkins ├── project-a/ │ ├── app1 │ ├── app2 │ └── app3 ├── project-b/ │ ├── app1 │ └── app2文件夹的职责项目 Job 的唯一容器项目权限边界项目管理员的管理单元禁止：禁止在 Jenkins 根目录直接创建 Job禁止跨 Folder 复用 Job（避免权限穿透）权限模型设计授权策略配置Jenkins 全局授权策略：授权策略：项目矩阵授权策略对象权限匿名用户无Authenticated UsersOverall → Read平台管理员Overall → Administer⚠️ 普通用户 不允许 看到非授权项目项目级（文件夹）授权策略：启用：Enable project-based security项目基础用户组（GitLab Group）示例：crm适用对象：项目开发测试项目管理员示例：陈日志职责：维护项目 Job管理项目成员权限权限最小化原则落实方式默认：给项目组授予 Job 的 Read + Build + Cancel 等基础权限基础权限尽量授权给「组」升权：仅对明确职责人员授予更高的权限不允许：不允许直接授予 Overall → Administer不允许在 Global 级别开放 Job 管理权限项目管理员机制项目管理员来源通常为该项目的运维或项目开发负责人由平台管理员在项目初始化时指定项目管理员的权限边界仅限该项目 Folder无法影响其他项目无法修改 Jenkins 全局配置凭证与敏感信息管理Credentials 使用原则使用 Folder Scoped Credentials禁止使用 Global Credentials（除非全局凭证）权限控制项目管理员：授予凭证的创建、删除、更新、查看等权限普通项目成员：无凭证管理权限风险与注意事项常见风险风险对策项目管理员误删 Job开启 Job 配置备份权限配置混乱统一模板 + 规范初始化流程 + 平台管理员/安全员定期核查人员离职权限残留依赖 GitLab SSO初始化流程新项目接入流程：GitLab 创建项目组Jenkins 创建 Folder绑定 GitLab 组权限指定项目管理员
如何修改容器时间而不影响宿主机
关键词：libfaketime、时间模拟在测试时间相关逻辑（证书过期、订单超时、定时任务、跨年问题）时，很多人第一反应是：能不能只改一个程序/进程的时间，而不影响宿主机？结论很明确：不能直接改。在容器/Pod 环境中也存在同样的需求，甚至需求更强烈，因为 Kubernetes 中，宿主机往往不会只运行一个业务程序，修改系统时间所造成的影响可能是无法预估的。Linux 内核只有一份系统时间，容器共享内核，不存在所谓的 Time Namespace。于是，libfaketime 成为了容器环境中最常被提及的“替代方案”。本文将结合主流编程语言获取时间的 Demo 来验证 libfaketime 的实际效果：libfaketime 能做什么、不能做什么为什么有的语言生效、有的完全无效Java 为什么必须额外配置libfaketime 是什么，它解决了什么问题libfaketime 并不是修改系统时间，而是：通过 LD_PRELOAD 劫持部分 libc 的时间函数，让进程“认为”时间发生了变化。典型被劫持的函数包括：time()gettimeofday()clock_gettime(CLOCK_REALTIME)这意味着：宿主机时间 不会变其他容器 不受影响只对当前进程有效这正好契合了容器 / Pod 级别的测试需求。环境准备export LD_PRELOAD=/usr/local/lib/faketime/libfaketime.so.1 export FAKETIME="2026-01-01 00:00:00" # FAKETIME 还支持很多模拟时间的表达式，请参考 https://github.com/wolfcw/libfaketime在 Java 应用中，必须额外加一句：export FAKETIME_DONT_FAKE_MONOTONIC=1后文会详细解释原因。主流编程语言行为对照1. Shelldate输出结果：Thu Jan 1 00:00:00 CST 2026✅ 生效2. C 语言#include <stdio.h> #include <time.h> #include <sys/time.h> int main() { time_t t = time(NULL); struct timeval tv; gettimeofday(&tv, NULL); struct tm *localTime; localTime = localtime(&t); printf("time(): %04d-%02d-%02d %02d:%02d:%02d\n", localTime->tm_year + 1900, localTime->tm_mon + 1, localTime->tm_mday, localTime->tm_hour, localTime->tm_min, localTime->tm_sec); localTime = localtime(&tv.tv_sec); printf("gettimeofday():%04d-%02d-%02d %02d:%02d:%02d\n", localTime->tm_year + 1900, localTime->tm_mon + 1, localTime->tm_mday, localTime->tm_hour, localTime->tm_min, localTime->tm_sec); return 0; }输出结果：time(): 2026-01-01 00:00:00 gettimeofday():2026-01-01 00:00:00✅ 生效3. Pythonimport time from datetime import datetime time_time = time.time() datetime_now = datetime.now() print("time.time():", time.strftime("%Y-%m-%d %H:%M:%S",time.localtime(time_time))) print("datetime.now():", datetime_now.strftime("%Y-%m-%d %H:%M:%S"))输出结果：time.time(): 2026-01-01 00:00:00 datetime.now(): 2026-01-01 00:00:00✅ 生效4. Node.jsfunction formatWithNative(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); const seconds = String(date.getSeconds()).padStart(2, '0'); return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } console.log(formatWithNative());输出结果：2026-01-01 00:00:00✅ 生效5. Javaimport java.time.Instant; import java.time.LocalDateTime; import java.time.ZoneId; public class Demo { public static void main(String[] args) { System.out.println("System.currentTimeMillis(): " + System.currentTimeMillis()); System.out.println("Instant.now(): " + Instant.now()); System.out.println("LocalDateTime.now(): " + LocalDateTime.now()); System.out.println("LocalDateTime.systemDefault(): " + LocalDateTime.now(ZoneId.systemDefault())); } }输出结果：System.currentTimeMillis(): 1767196800000 Instant.now(): 2025-12-31T16:00:00Z LocalDateTime.now(): 2026-01-01T00:00 LocalDateTime.systemDefault(): 2026-01-01T00:00✅ 生效6. Gopackage main import ( "fmt" "time" ) func main() { fmt.Println("time.Now():", time.Now()) fmt.Println("Unix: ", time.Now().Unix()) }输出结果：time.Now(): 2026-01-14 02:01:21.162059381 +0000 UTC m=+0.000047738 Unix: 1768356081❌ 不生效行为汇总表语言faketime 生效Shell / C✅Python / Node✅Java✅Go❌为什么 Java 需要 FAKETIME_DONT_FAKE_MONOTONIC=11. Java 有两种“时间”墙上时间（Wall Clock）System.currentTimeMillis()可回拨，用于业务时间单调时间（Monotonic Clock）System.nanoTime()只递增，用于：Thread.sleep锁 / 条件变量定时器GC / JIT2. libfaketime 的默认行为默认情况下，libfaketime 会连单调时间一起 fake。这会直接破坏 JVM 的基本假设，导致应用卡死。3. 正确做法export FAKETIME_DONT_FAKE_MONOTONIC=1结语本文只针对简单的场景进行了测试，测试结果表明 C、Python、NodeJS、Java 等语言都可以使用 libfaketime 来修改/模拟时间，Go 语言因为其静态编译的特性，不依赖 libc，因此不支持用 libfaketime 来修改/模拟时间。
浏览器后台 Tab 导致 WebSocket 断连问题排查与解决
背景在使用 gotty 这类基于 WebSocket 的 Web Terminal 工具时，遇到一个问题：浏览器打开页面后，WebSocket 连接一切正常切换到其他浏览器标签页（Tab），停留一段时间再切回原 Tab，发现 WebSocket 已断开gotty 日志：2026/01/13 01:06:16 New client connected: 10.42.2.8:41704, connections: 1/0 2026/01/13 01:12:18 Connection closed by client: 10.42.2.8:41704, connections: 0/0更有意思的是：本地调试（localhost / 内网）几乎不会复现通过公网访问时，问题稳定出现浏览器切换 Tab 的影响第一反应是怀疑浏览器行为。通过简单验证可以确认：浏览器会对后台页面进行 JS 执行节流 / 暂停（可以通过浏览器的开发者工具，查看WebSocket的数据交互）setInterval / setTimeout 的触发频率被大幅拉长，甚至完全暂停（取决于浏览器的行为）如果 WebSocket 的心跳依赖前端 JS 定时器，那么在后台 Tab 场景下：心跳不再发送，连接会逐渐变成“空闲连接”为什么本地不断，公网却会断？我发现浏览器JS节流/暂停之后，第一时间在电脑本地也跑一个gotty，验证的结果是本地连接，即使浏览器对JS节流/暂停，也不会导致连接中断。因此初步得出一个结论：问题出现在网络环境的差异上。本地 / 内网访问链路browser → localhost / LAN → server特点是：没有 NAT几乎不存在 TCP idle timeout即使连接长时间无数据，也不会被回收因此，本地连接时即使 JS 完全暂停，连接依然“看起来还活着”。公网访问的真实链路browser ↓ 路由器/防火墙 NAT ↓ 运营商 ↓ 云防火墙 / 负载均衡 ↓ server这条链路上的每一层设备，几乎都会对 空闲 TCP 连接设置超时回收机制。常见的 idle timeout：家用路由器：60–300 秒云负载均衡：60–600 秒移动网络：更短而这些回收行为通常是 silent drop：不发送 FIN不发送 RST双方都不知道连接已经被中间设备“忘记”半开连接（Half-open TCP）在后台 Tab 场景下，典型的故障链路是：浏览器切后台 ↓ JS 心跳暂停 ↓ WebSocket 无任何流量 ↓ TCP 连接空闲 ↓ NAT / 防火墙回收连接 ↓ 连接变成半开状态此时：浏览器认为连接仍然存在服务端认为连接仍然存在但中间网络设备已经不再转发数据只有在下一次写数据时，问题才会暴露。reconnect 有用吗？gotty 支持 reconnect 机制，使用 --reconnect 参数开启。经过测试，在这个场景下无效。我查找资料后，reconnect 是客户端重连，可能适用的场景：服务端主动close例如：服务端升级/重启超过最大会话时长服务端进程异常退出例如：panicOOMSIGKILL客户端网络短暂中断（可感知）例如：Wi-Fi断开又恢复VPN切换它解决的是：“断了怎么办”，而不是 “保持连接不断”。服务端 WebSocket Ping问题的根因可以归结为一句话：连接在后台 Tab 时没有任何网络流量，导致被中间网络设备回收因此，正确的解决方向是：不依赖前端 JS由服务端主动制造“最小但持续”的流量WebSocket 协议本身已经提供了标准答案：Ping / Pong（RFC 6455）服务端发送 Ping frame浏览器网络栈自动回复 Pong该过程 不依赖 JavaScript 执行即使页面 JS 被完全暂停，只要连接仍存在，Pong 也会被自动发送。改造方案在服务端 WebSocket 建立后，增加三点：定期发送 Ping（例如每 30 秒）设置 PongHandler，刷新 ReadDeadline在 Pong 超时后主动关闭连接，交给 reconnect 兜底这样可以同时达到：防止 NAT / 防火墙回收连接及时发现真正的死连接与现有 reconnect 机制完美配合因为 gotty 项目维护者已经不活跃，不管是提ISSUE或是PR，维护者也久久不回复，因此，我在我 fork 的版本中做了修改，修改的代码在这个提交：COMMIT：https://github.com/chenrizhi/gotty/commit/5578b76f535c9260c53c58907108a52a2994971e参数取值的取舍修改的代码有两个常量：serverSidePingInterval、serverSidePongWait，这两个值的取值也不能乱取的。在不清楚真实网络 idle timeout 的前提下，采用保守策略：serverSidePingInterval = 30sserverSidePongWait = 90s经验法则是：ping 间隔要 小于任何可能的 NAT idle timeoutpongWait 至少是 pingInterval 的 2–3 倍30s / 90s 是一个在公网、NAT、移动网络下都相对安全的折中方案。最终效果引入服务端 Ping 后：后台 Tab 长时间停留，连接依然保持公网环境下不再频繁触发断连reconnect 只在真正异常时才会发生总结浏览器后台 Tab 会暂停 JS 执行，这是规范行为公网环境中的 NAT / 防火墙会回收空闲 TCP 连接两者叠加，导致 WebSocket 在后台场景下“莫名其妙断开”reconnect 只是补救措施，无法防止断连服务端 WebSocket Ping 才是根治方案
背景近期在一套 Kubernetes 集群中，发现一个非常反常的现象：在节点上执行 netstat / ss 命令异常缓慢，甚至需要几分钟才能返回结果节点本身负载并不高，CPU、内存看起来都比较正常由于 netstat/ss 本质上只是读取内核和 /proc 信息，这种“卡死级别”的慢速，往往意味着 系统层面存在异常规模的数据结构。这篇文章记录了从一个看似普通的 netstat 慢问题，一步步定位到 Calico socket FD 泄漏 Bug，并最终通过升级版本彻底解决的完整过程。从 netstat/ss 变慢开始最初的直接表现是：time netstat -ntlpActive Internet connections (only servers) Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name tcp 0 0 127.0.0.1:10010 0.0.0.0:* LISTEN 1530975/cri-dockerd tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 1165/sshd: /usr/sbi tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN 164980/nginx: worke tcp 0 0 0.0.0.0:111 0.0.0.0:* LISTEN 1/systemd tcp 0 0 127.0.0.1:9099 0.0.0.0:* LISTEN 1171111/calico-node tcp 0 0 0.0.0.0:20048 0.0.0.0:* LISTEN 2503623/rpc.mountd tcp 0 0 0.0.0.0:2049 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:10256 0.0.0.0:* LISTEN 623016/kube-proxy tcp 0 0 127.0.0.1:10248 0.0.0.0:* LISTEN 1531381/kubelet tcp 0 0 127.0.0.1:10249 0.0.0.0:* LISTEN 623016/kube-proxy tcp 0 0 0.0.0.0:57325 0.0.0.0:* LISTEN 2500368/rpc.statd tcp 0 0 0.0.0.0:46433 0.0.0.0:* LISTEN - tcp6 0 0 :::22 :::* LISTEN 1165/sshd: /usr/sbi tcp6 0 0 :::111 :::* LISTEN 1/systemd tcp6 0 0 :::20048 :::* LISTEN 2503623/rpc.mountd tcp6 0 0 :::2049 :::* LISTEN - tcp6 0 0 :::2380 :::* LISTEN 619966/etcd tcp6 0 0 :::2379 :::* LISTEN 619966/etcd tcp6 0 0 :::40007 :::* LISTEN - tcp6 0 0 :::6443 :::* LISTEN 621032/kube-apiserv tcp6 0 0 :::9369 :::* LISTEN 2484838/pushprox-cl tcp6 0 0 :::9796 :::* LISTEN 2485357/node_export tcp6 0 0 :::10250 :::* LISTEN 1531381/kubelet tcp6 0 0 :::10259 :::* LISTEN 621666/kube-schedul tcp6 0 0 :::10257 :::* LISTEN 621333/kube-control tcp6 0 0 :::48607 :::* LISTEN 2500368/rpc.statd real 3m21.534s user 3m9.634s sys 0m11.357s可见执行非常慢。这类问题通常有几种可能：系统中存在大量 socket / 连接/proc 下某些目录规模异常某个进程打开了极多的文件描述符（FD）为了确认 netstat 到底“卡”在什么地方，我使用了 strace 对其系统调用进行跟踪。使用 strace 定位卡顿原因执行：strace netstat -ntlp很快发现了异常点，大量重复的系统调用如下：... readlink("/proc/3698662/fd/77625", "socket:[837323590]", 63) = 18 getxattr("/proc/3698662/fd/77625", "security.selinux", 0x558e62a555d0, 255) = -1 EOPNOTSUPP readlink("/proc/3698662/fd/77626", "socket:[837626395]", 63) = 18 getxattr("/proc/3698662/fd/77626", "security.selinux", 0x558e62a55670, 255) = -1 EOPNOTSUPP readlink("/proc/3698662/fd/77627", "socket:[837554271]", 63) = 18 ...可以看到：netstat 在遍历 /proc/3698662/fd/ 目录对每一个 fd 执行 readlinkfd 指向的几乎全部是 socket:[inode]这意味着：某个 PID 打开了数量极其夸张的 socket fd，导致 netstat 在遍历时较长锁定异常进程：calico-node通过 PID 确认该进程身份：ps aux | grep 3698662输出显示：root 3698662 ... calico-node -felix进一步确认 fd 数量：ll /proc/3698662/fd | wc -l 1108645 ll /proc/3698662/fd | grep socket | wc -l 1108642结论已经非常清晰：calico-node 打开了 110 万+ 文件描述符几乎全部是 socket这已经远远超出“连接多”的范畴，而是一个明确的 FD 泄漏问题。进一步验证：是否为泄漏？为了确认这是“可回收的异常状态”，还是“真实业务负载”，我做了一个关键验证。重启 calico-node（Canal Pod）在该节点上重启 canal / calico-node Pod 后：ll /proc/<new-pid>/fd | wc -l 35结果立刻恢复到正常水平。这一步直接证明：socket 数量并非业务真实连接而是 calico-node 进程内部的 socket 泄漏环境信息与问题定性环境关键信息如下：Calico 版本：v3.29.0问题特征：fd 持续增长重启即恢复综合现象可以定性为：Calico v3.29.0 存在 socket FD 泄漏问题解决方案：升级 Calico为什么只能升级FD 已经泄漏，无法通过配置回收调整 fs.file-max 不能解决问题，并且可能会导致calico不能正常工作，报 Too many open files根因在 Calico 代码本身实际处理将 Calico 从：v3.29.0升级到：v3.29.3升级完成后观察：calico-node fd 数量保持稳定netstat / ss 执行响应速度恢复正常问题未再复现问题彻底解决。从源码中确认根因：netlink socket 泄漏在现象层面已经基本可以断定这是一个 socket FD 泄漏，但我个人更习惯从源码中找到“证据链”，否则总觉得少了最后一块拼图。关键字搜索无果最初我直接在 GitHub 上 Calico 项目的 issue 中搜索：socketfd但并没有发现明显、直观的线索。采用版本 diff 思路定位既然问题在 v3.29.0 存在，而 v3.29.3 消失，那最直接、也是最可靠的方式就是：对比 v3.29.0..v3.29.3 的源码差异在 diff 过程中，我很快注意到一个非常关键的提交：Fix netlink leak (#9609)这个提交本身就已经点明了核心问题：netlink 泄漏。这个提交主要是对文件句柄进行了关闭，在 v3.29.2 上修复了该 Bug。diff --git a/cni-plugin/internal/pkg/testutils/utils_linux.go b/cni-plugin/internal/pkg/testutils/utils_linux.go index e1b40fc553..f88a812cd0 100644 --- a/cni-plugin/internal/pkg/testutils/utils_linux.go +++ b/cni-plugin/internal/pkg/testutils/utils_linux.go @@ -287,6 +287,7 @@ func RunCNIPluginWithId( return err } + defer nlHandle.Close() contVeth, err = nlHandle.LinkByName(ifName) if err != nil { return err diff --git a/felix/dataplane/linux/int_dataplane.go b/felix/dataplane/linux/int_dataplane.go index 71df22c00f..7838b66c5d 100644 --- a/felix/dataplane/linux/int_dataplane.go +++ b/felix/dataplane/linux/int_dataplane.go @@ -1124,6 +1124,7 @@ func findHostMTU(matchRegex *regexp.Regexp) (int, error) { return 0, err } + defer nlHandle.Delete() links, err := nlHandle.LinkList() if err != nil { log.WithError(err).Error("Failed to list interfaces. Unable to auto-detect MTU.")netlink 与 socket FD 的关系这里有一个很容易被忽略的点：netlink 本质上就是一种 socket（AF_NETLINK）每一个 netlink 连接，都会对应一个 socket fd如果 netlink socket 没有被正确关闭，最终体现出来的就是：/proc/PID/fd 下出现大量 socket:[inode]这与我在现场看到的现象：fd 数量 110 万+几乎全部是 socket形成了完全一致的证据闭环。
一次由 Maven 可重复构建引发的 Spring Boot 运行时类加载问题排查
背景在一个 Spring Boot 3.x 项目中，为了满足可重复构建（Reproducible Build）的要求(可查看我另一篇文章了解可重复构建)，我在 pom.xml 中新增了如下配置：<project.build.outputTimestamp>2026-01-01T00:00:00Z</project.build.outputTimestamp>该配置用于固定构建产物的时间戳，确保多次构建生成的 JAR 文件在字节级别保持一致。然而，加上该配置后，应用在运行时出现了异常：启动正常业务请求触发时抛出运行时错误错误与 okhttp 相关jakarta.servlet.ServletException: Handler dispatch failed: java.lang.NoClassDefFoundError: Could not initialize class com.xxx.common.util.HttpUtil ... Caused by: java.lang.NoClassDefFoundError: Could not initialize class com.xxx.common.util.HttpUtil ... Caused by: java.lang.ExceptionInInitializerError: Exception java.lang.NoSuchFieldError: Companion [in thread "http-nio-80-exec-9"] at okhttp3.internal.Util.<clinit>(Util.kt:70) ~[okhttp-4.10.0.jar!/:?]而在未添加该配置时，应用运行完全正常。问题初步分析异常发生在运行期，而非编译期，表现为典型的 ABI 不兼容问题（如 NoSuchMethodError）。通过分析依赖树，发现 okhttp 4.10.0 是通过 com.huaweicloud:esdk-obs-java:jar 依赖间接引入：mvn dependency:tree[INFO] +- com.huaweicloud:esdk-obs-java:jar:3.20.6.1:compile[INFO] | +- com.jamesmurty.utils:java-xmlbuilder:jar:1.2:compile[INFO] | +- com.squareup.okhttp3:okhttp:jar:4.10.0:compile[INFO] | | +- com.squareup.okio:okio-jvm:jar:3.0.0:compile[INFO] | | | +- org.jetbrains.kotlin:kotlin-stdlib-jdk8:jar:1.7.22:compile[INFO] | | | | - org.jetbrains.kotlin:kotlin-stdlib-jdk7:jar:1.7.22:compile[INFO] | | | - org.jetbrains.kotlin:kotlin-stdlib-common:jar:1.7.22:compile[INFO] | | - org.jetbrains.kotlin:kotlin-stdlib:jar:1.7.22:compile[INFO] | | - org.jetbrains:annotations:jar:13.0:compile[INFO] | +- com.squareup.okio:okio:jar:1.17.2:compile同时，OBS SDK 又引入了一个较老版本的 okio：okio 1.17.2Fat Jar 中的实际情况通过查看 Spring Boot 打包后的 fat jar 内容：jar tf app.jar | grep -Ei "okio|okhttp"未开启 project.build.outputTimestamp 时BOOT-INF/lib/feign-okhttp-12.4.jar BOOT-INF/lib/okhttp-4.10.0.jar BOOT-INF/lib/okio-jvm-3.0.0.jar BOOT-INF/lib/okio-1.17.2.jar开启 project.build.outputTimestamp 后BOOT-INF/lib/feign-okhttp-12.4.jar BOOT-INF/lib/okhttp-4.10.0.jar BOOT-INF/lib/okio-1.17.2.jar BOOT-INF/lib/okio-jvm-3.0.0.jar可以看到，两个场景中 jar 的内容是相同的，但顺序发生了变化。这个顺序是关键线索，后面会有 JVM 的加载验证。关键认知一：JVM 类加载的硬规则JVM 对类加载有一个不可违背的规则：同一个 ClassLoader 下，类的唯一性由「类的全限定名 + ClassLoader」决定这意味着：同一个类（如：okio.ByteString）只能被加载一次不存在“版本共存”先加载的实现会永久生效关键认知二：Spring Boot fat jar 对顺序是敏感的Spring Boot 的 LaunchedURLClassLoader 在启动时，会：遍历 fat jar 中的 BOOT-INF/lib/*.jar按 JAR 内的物理顺序 将它们加入 classpath不做任何排序或版本判断因此：fat jar 中 BOOT-INF/lib 的顺序，直接决定了类加载优先级JVM 类加载验证为了确认是否是类加载顺序问题，启用 JVM 类加载日志：-Xlog:class+load=info观察结果：未启用 outputTimestamp → okio-jvm 被加载[68.099s][info][class,load] okio.Source source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.099s][info][class,load] okio.BufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.099s][info][class,load] okio.Sink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.099s][info][class,load] okio.BufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.100s][info][class,load] okio.Buffer source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.101s][info][class,load] okio._UtilKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.102s][info][class,load] okio.Buffer$UnsafeCursor source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.110s][info][class,load] okio.Options source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.111s][info][class,load] okio.Options$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.114s][info][class,load] okio.ByteString source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.116s][info][class,load] okio.ByteString$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.117s][info][class,load] okio.internal._ByteStringKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.197s][info][class,load] okio.SegmentPool source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.198s][info][class,load] okio.Segment source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.198s][info][class,load] okio.Segment$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.345s][info][class,load] okio.Timeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.345s][info][class,load] okio.AsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.354s][info][class,load] okio.Timeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.355s][info][class,load] okio.Timeout$Companion$NONE$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.355s][info][class,load] okio.AsyncTimeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.396s][info][class,load] okio.Okio source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.396s][info][class,load] okio.Okio__JvmOkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.398s][info][class,load] okio.SocketAsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.399s][info][class,load] okio.InputStreamSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.400s][info][class,load] okio.AsyncTimeout$source$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.400s][info][class,load] okio.Okio__OkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.402s][info][class,load] okio.RealBufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.403s][info][class,load] okio.OutputStreamSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.404s][info][class,load] okio.AsyncTimeout$sink$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.405s][info][class,load] okio.RealBufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.688s][info][class,load] okio.Utf8 source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.709s][info][class,load] okio._JvmPlatformKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.783s][info][class,load] okio.ForwardingSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.784s][info][class,load] okio.AsyncTimeout$Watchdog source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.860s][info][class,load] okio.ForwardingSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.863s][info][class,load] okio.GzipSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.865s][info][class,load] okio.InflaterSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/ [68.869s][info][class,load] okio.internal._BufferKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-jvm-3.0.0.jar!/启用 outputTimestamp → okio 被加载[694.157s][info][class,load] okio.Source source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.157s][info][class,load] okio.BufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.159s][info][class,load] okio.Sink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.159s][info][class,load] okio.BufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.159s][info][class,load] okio.Buffer source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.161s][info][class,load] okio.-Util source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.172s][info][class,load] okio.Options source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.174s][info][class,load] okio.Options$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.177s][info][class,load] okio.ByteString source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.179s][info][class,load] okio.ByteString$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.181s][info][class,load] okio.internal.ByteStringKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.276s][info][class,load] okio.SegmentPool source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.277s][info][class,load] okio.Segment source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.277s][info][class,load] okio.Segment$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.457s][info][class,load] okio.Timeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.457s][info][class,load] okio.AsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.471s][info][class,load] okio.Timeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.472s][info][class,load] okio.Timeout$Companion$NONE$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.473s][info][class,load] okio.AsyncTimeout$Companion source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.508s][info][class,load] okio.Okio source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.508s][info][class,load] okio.Okio__JvmOkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.510s][info][class,load] okio.SocketAsyncTimeout source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.511s][info][class,load] okio.InputStreamSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.512s][info][class,load] okio.AsyncTimeout$source$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.513s][info][class,load] okio.Okio__OkioKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.515s][info][class,load] okio.RealBufferedSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.516s][info][class,load] okio.OutputStreamSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.517s][info][class,load] okio.AsyncTimeout$sink$1 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.519s][info][class,load] okio.RealBufferedSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.758s][info][class,load] okio.Utf8 source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.787s][info][class,load] okio.-Platform source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.811s][info][class,load] okio.ForwardingSink source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [694.812s][info][class,load] okio.AsyncTimeout$Watchdog source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.468s][info][class,load] okio.ForwardingSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.470s][info][class,load] okio.GzipSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.472s][info][class,load] okio.InflaterSource source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/ [696.475s][info][class,load] okio.internal.BufferKt source: jar:file:/app.jar!/BOOT-INF/lib/okio-2.10.0.jar!/且：JVM 只加载了一套 okio 类并不存在“两个版本共存”为什么加 outputTimestamp 才出问题？要解释这个问题，首先要知道可重复构建做了什么？固定 jar entry 顺序固定 zip 时间戳固定 BOOT-INF/lib 打包顺序原因并不在于“引入了新问题”而是：可重复构建消除了原本的“非确定性”未开启：jar entry 写入顺序依赖文件系统、构建时机、并发等因素okio / okio-jvm 的先后顺序是未定义的在这个案例中，“刚好”先加载的是兼容的 okio-jvm问题被掩盖开启后：确保多次构建生成的 JAR 文件在字节级别保持一致，Maven 强制使用确定性的 ZIP entry 顺序okio-1.17.2.jar 按字典序稳定排在 okio-jvm 前JVM 首次加载 okio.* 时命中旧版本okhttp 4.x 在运行期触发 ABI 冲突潜在问题被暴露出来为什么只加载了 okio，而没有加载 okio-jvm？这是完全符合 JVM 预期的行为：okio 与 okio-jvm 提供的是相同包名、相同类名JVM 不会同时加载两个版本后出现的 jar 不会覆盖 已加载的类问题本质总结这是一个被“非确定性构建顺序”掩盖的 classpath 冲突问题，project.build.outputTimestamp 只是让潜在的ABI冲突问题“浮现”。最终解决方案统一 okio 的来源，确保 classpath 中只存在一个实现。排除 com.huaweicloud:esdk-obs-java:jar 引入的不兼容版本的 okio：<dependency> <groupId>com.huaweicloud</groupId> <artifactId>esdk-obs-java</artifactId> <exclusions> <exclusion> <groupId>com.squareup.okio</groupId> <artifactId>okio</artifactId> </exclusion> </exclusions> </dependency>或显式指定与 okhttp 4.x 兼容的版本：<dependency> <groupId>com.squareup.okio</groupId> <artifactId>okio</artifactId> <version>2.10.0</version> </dependency>
Gateway API：Kubernetes 流量管理的未来，Ingress的接班者
在很长一段时间里，Ingress 是 Kubernetes 集群的标准北向流量入口。但随着业务复杂度增加、微服务体系兴起、多协议需求增长，Ingress 的先天缺陷越来越明显。2024–2025 年，Kubernetes 官方与 CNCF 明确：Gateway API 是下一代流量管理标准，将逐步取代 Ingress 成为默认推荐方式。而随着 ingress-nginx 宣布在 2026 年 3 月停止维护，Gateway API 的迁移意义愈发明确。本文将从原理、对比、设计理念、CRD、部署与使用示例多个方面全面介绍 Gateway API。1. 什么是 Gateway API？Gateway API 是 Kubernetes SIG-Network 推出的新一代流量治理标准。核心目标：统一 L4/L7 流量管理取代 Ingress 的简陋模型提供可扩展、可观察、可分权协作的网关体系能适配云厂商、服务网格、Ingress 控制器等各种实现它不是一个控制器，而是一套标准 API（CRD）。控制器厂商（如 Envoy、Traefik、Istio、Kong）可以实现这套 API。Gateway API 主要提供以下能力：多协议支持：HTTP、HTTPS、gRPC、TCP、UDP、TLS更丰富的路由表达式多 listener、多端口、跨命名空间引用运维与开发分离可扩展策略（HTTPRouteFilter、Policy Attachment）统一的条件与状态反馈这些是 Ingress 无法做到的。2. 与 Ingress 的对比下表总结两者的核心差异：对比项IngressGateway API设计年代2015（Kubernetes 1.2）2022（现代网关能力需求）支持协议仅 HTTP/HTTPSHTTP、HTTPS、TCP、UDP、gRPC、TLS扩展方式annotation（混乱、不可维护）Policy Attachment、Filters（结构化扩展）资源模型IngressClass、IngressGatewayClass、Gateway、xRoute 多资源模型权限与分权不支持完整支持 Ops（Gateway）与 Dev（Route）分离可观察性状态简陋丰富的 Conditions多 Listener不支持支持多个端口、TLS、SNI团队协作困难一等公民能力行业支持ingress-nginx 等传统控制器Google、AWS、Azure、Envoy、Istio、Kong、Traefik核心结论Ingress 是简陋的 1.0 版本，而 Gateway API 是可扩展的现代化网关规范。3. 为什么 Kubernetes 官方不升级改造 Ingress，而是重做 Gateway API？这是很多人关心的问题。官方给出的根本原因：Ingress 模型太简陋，结构性无法扩展Ingress 的原始设计只包含：hostpathbackend没有 header、method、流量镜像、权重路由、重写规则、多 listener 等能力。为了加功能，各控制器全靠 annotation hack，最终变成：“同一能力不同 controller 语法不同，生态彻底碎片化。”Ingress 的设计已经补不动了。Ingress 是单资源模型，无法支持现代需求现代网关需要：LB 生命周期Listener多团队协作不同 protocol 的路由TLS 策略绑定多个 Route权限隔离Ingress 单一资源没办法表达这些需求。Ingress 只支持 HTTP，不支持多协议如今的需求包括：TCP 服务UDP 服务gRPCTLS PassthroughWebSocketMesh IngressIngress 无法扩展。厂商实现完全不兼容，社区无法统一标准Nginx、Traefik、Kong、云厂商的 Ingress 都不兼容，这让官方无法继续推进它作为标准。提升 Ingress = 大修 API → 这是破坏性变更Kubernetes 官方不允许破坏性 API 升级，这意味着：“想升级 Ingress，但升级后的 API 必须不破坏，不可能。”所以，只能重做一套更先进的网关模型。4. Gateway API 的资源模型（CRD）Gateway API 使用多层次模型，清晰分权：GatewayClass → Gateway → xRoute → BackendGatewayClass描述网关的“类型”，类似于 IngressClass。由运维团队创建，比如：Envoy GatewayNginx Gateway云厂商负载均衡器由集群管理员创建维护。Gateway实际的网关实例，例如：一个 VIP（load balancer）一个 Nginx/Envoy pod 部署一个 Istio IngressGateway由 Ops 团队创建。Route可以是以下任意一种：HTTPRouteTCPRouteUDPRouteTLSRouteGRPCRouteRoute 由业务开发创建，声明服务如何暴露。Backend（Service）最终转发的目标服务。5. 部署与使用示例下面以 Envoy Gateway 为例。注意：Gateway API还在高速发展中，CRDs在变化，因此Controller不同版本存在兼容矩阵，在部署的时候要根据你的Kubernetes版本选择兼容的Controller、Gateway API版本。如Envoy Gateway，请查看https://gateway.envoyproxy.io/news/releases/matrix/安装 Gateway API CRDs这一步可能不需要主动安装，大多常见的控制器厂家在他们的部署文件中已经包含了Gateway API CRDs。kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/latest/download/standard-install.yaml部署 Envoy Gatewaykubectl apply -f https://github.com/envoyproxy/gateway/releases/latest/download/install.yaml创建 GatewayClassapiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: name: envoy spec: controllerName: gateway.envoyproxy.io/gatewayclass-controller创建 Gateway（入口网关）apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: my-gateway namespace: default spec: gatewayClassName: envoy listeners: - allowedRoutes: namespaces: from: All name: http protocol: HTTP port: 80创建HTTPRoute（代替Ingress）apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: httpbin namespace: default spec: hostnames: - www.example.local parentRefs: - group: gateway.networking.k8s.io kind: Gateway name: my-gateway namespace: default rules: - backendRefs: - group: "" kind: Service name: httpbin port: 80 weight: 1 matches: - path: type: PathPrefix value: /相当于 Ingress，但语义更完整、结构更清晰。
野草助手分享码口令，最新最全口令
在软件开发与数据库管理的日常中，程序bug或误执行SQL语句导致的数据错误更新是一个不容忽视的问题。这类问题不仅可能导致业务数据的混乱，还可能对企业造成严重的经济损失。幸运的是，数据库系统为我们提供了一种强大的数据恢复机制——binlog（Binary Log），它能够记录所有修改数据的SQL语句，为我们在数据遭遇不测时提供了一线生机。本文将详细介绍如何通过解析binlog来恢复因程序bug或误执行SQL导致的数据错误更新或删除。binlog基础什么是binlog？binlog是MySQL数据库中的一种二进制日志文件，它记录了数据库中所有修改数据的SQL语句，包括增删改操作。binlog的主要作用是用于数据库的主从复制和数据恢复。binlog的类型MySQL的binlog有三种格式：STATEMENT、ROW和MIXED。STATEMENT：记录每一条会修改数据的SQL语句本身。ROW：记录每一行数据的变化，不记录SQL语句本身。MIXED：默认情况下使用STATEMENT格式，但在某些特殊情况下会自动切换为ROW格式。通过binlog恢复数据理论基础准备工作确认binlog已开启：确保MySQL的binlog功能已开启，且binlog类型为ROW。定位问题时间点：确定误执行的时间范围，以便在binlog中找到相关的记录。误执行的SQL语句：确定误执行的SQL语句，以便在binlog中找到相关的记录。备份当前数据：在进行数据恢复前，务必备份当前的数据状态，以防恢复过程中出现意外。恢复步骤查找binlog文件：根据问题时间点，定位到包含误操作的binlog文件。解析binlog：使用MySQL提供的mysqlbinlog工具解析binlog文件，找到异常的SQL语句。mysqlbinlog --no-defaults -v -v --base64-output=DECODE-ROWS mysql-bin.000001 > binlog.out在生成的SQL文件中搜索关键词，定位到误执行的SQL语句。编写恢复脚本：根据解析出的binlog内容，编写一个反向操作的SQL脚本，用于撤销误执行的SQL影响的数据修改。例如，如果错误地更新了某个字段的值，可以在恢复脚本中将其改回原来的值。执行恢复脚本：在确保备份了当前数据后，执行恢复脚本。验证恢复结果：检查恢复后的数据是否与预期一致，确保业务数据恢复正常。实战场景一：update语句不加where条件误操作数据恢复事件描述程序BUG导致一张表的所有数据被逻辑删除（is_deleted字段被更新为1），该程序BUG是由于执行了一条UPDATE语句，WHERE条件设置有误引起，如下：UPDATE `test`.`t1` SET is_deleted = 1;根据开发同事的反馈，该SQL是在2025-09-05 14:48左右执行的。诉求恢复程序BUG导致的错误更新is_deleted字段的值。解析binlog根据此前的理论知识，binlog记录每一行数据的变化。那么我们可以通过解析binlog，拿到这条SQL所影响的数据，它记录修改前、后的数据。获取binlog文件：如果是云数据，看不到最新的日志，需要执行：flush logs执行binlog解析命令：因为明确知道SQL的执行时间，我这里加了--start-datetime参数，减少数据量。2025-09-05 14:48左右执行的，我这里往前推了几分钟。因为时区问题，减去8小时。mysqlbinlog --no-defaults -v -v --base64-output=DECODE-ROWS --start-datetime="2025-09-05 06:40:00" mysqlbin.000391 > binlog.out查找关键字UPDATE `test`.`t1`# at 273068250 #250905 6:48:12 server id 414163 end_log_pos 273068548 CRC32 0x06b6ee0e Update_rows: table id 5546 flags: STMT_END_F ### UPDATE `test`.`t1` ### WHERE ### @1=5816 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=4956 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=2.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=0 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ ### SET ### @1=5816 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=4956 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=2.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=1 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ ### UPDATE `test`.`t1` ### WHERE ### @1=5818 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=11309 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=1.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=0 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:31:43' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2025-05-29 22:12:25' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=493 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=16 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ ### SET ### @1=5818 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=11309 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=1.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=1 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:31:43' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2025-05-29 22:12:25' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=493 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=16 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ …… # at 273068548 #250905 6:48:12 server id 414163 end_log_pos 273068579 CRC32 0xce483344 Xid = 494745070 COMMIT/*!*/;先看一下binlog解析后输出的内容格式：# at 273068250 #250905 6:48:12 server id 414163 end_log_pos 273068548 CRC32 0x06b6ee0e Update_rows: table id 5546 flags: STMT_END_F ### UPDATE `test`.`t1` ### WHERE （旧值，用来定位被更新的行） ### SET （新值，表示更新后的结果） ……（这个事件修改的其他行的内容） # at 273068548 #250905 6:48:12 server id 414163 end_log_pos 273068579 CRC32 0xce483344 Xid = 494745070 COMMIT/*!*/;稍做分析# at 273068250表示这条 binlog 事件的起始位置（offset = 273068250）。这个表有18个字段，字段的位置以@序号的表示方式。我们还需要结合这个表的表结构的字段名和binlog的@序号对应起来。CREATE TABLE `test` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', -- @1 `project_id` bigint(20) DEFAULT NULL COMMENT '项目id', -- @2 `oppty_info_id` bigint(20) DEFAULT NULL COMMENT '商机id', -- @3 `oppty_info_palmtop_id` bigint(20) DEFAULT NULL COMMENT '掌上通id', `device_name` varchar(255) NOT NULL DEFAULT '' COMMENT '分类名称', `device_type` varchar(255) NOT NULL DEFAULT '' COMMENT '设备型号', `price` decimal(12,4) DEFAULT NULL COMMENT '单价(万)', `unit` varchar(100) DEFAULT '' COMMENT '单位', `unit_barcode` text COMMENT '机组条码List,[xxx,xxx]', `qty` int(11) NOT NULL DEFAULT '0' COMMENT '数量', `drawing_no` varchar(32) DEFAULT NULL COMMENT '图号', `tool_no` varchar(100) DEFAULT NULL COMMENT '工装编号', `is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除状态：1-删除 0-未删除', -- @13 `created_time` datetime DEFAULT NULL COMMENT '创建时间', `updated_time` datetime DEFAULT NULL COMMENT '更新时间', `created_by` bigint(20) DEFAULT NULL COMMENT '创建人', `updated_by` bigint(20) DEFAULT NULL COMMENT '更新人', `remark` varchar(255) DEFAULT '' COMMENT '备注', -- @18 PRIMARY KEY (`id`), KEY `idx_oppty_info_id_deleted` (`oppty_info_id`,`is_deleted`) ) ENGINE=InnoDB AUTO_INCREMENT=146778 DEFAULT CHARSET=utf8mb4 COMMENT='设备清单'提取这条SQL的完整binlog内容：sed -n '/# at 273068250/,/COMMIT/p' binlog.out > 273068250.out组装恢复脚本cat 273068250.out | \ sed '/WHERE/{:a;N;/SET/!ba;s/\([^\n]*\)\n\(.*\)\n\(.*\)/\3\n\2\n\1/}' | \ sed -r '/SET/{:a;N;/@18/!ba;s/@14.*//g}' | \ sed -r '/SET/{:a;N;/@12/!ba;s/@1.*//g}' | \ sed -r '/WHERE/{:a;N;/@18/!ba;s/@2.*//g}' | \ sed 's/### //g;s/\/\*.*\*\//,/g' | \ sed '/WHERE/{:a;N;/@1/!ba;s/,/;/g};s/#.*//g;s/COMMIT.*//g' | \ sed '/^$/d' | \ sed 's/@13/is_deleted/g;s/@1/id/g' | \ sed -r 's/(is_deleted=.*),/\1/g' > 273068250.recovery.sql逐行解释一下：sed '/WHERE/{:a;N;/SET/!ba;s/\([^\n]*\)\n\(.*\)\n\(.*\)/\3\n\2\n\1/}'/WHERE/{ ... }只有匹配到包含 WHERE 的行时，才会执行 {} 里的动作。:a; N; /SET/!ba;这几条是一个 循环结构：:a → 定义一个标签 a。N → 把下一行读进来（追加到 pattern space）。/SET/!ba → 如果当前缓存区里还 没有匹配到 SET，就跳回到 a 标签继续读下一行。作用：一直读，直到读到包含 SET 的那一行，才停止。这样就能保证 pattern space 中存放的是：WHERE ... SETs/\([^\n]*\)\n\(.*\)\n\(.*\)/\3\n\2\n\1/这是一个替换操作，里面有三个分组：([^\n]*) → 第一行（匹配到换行符前的内容）。(.*) → 第二行到倒数第二行。(.*) → 最后一行。综合起来看，整个命令做的事情就是：找到 WHERE 那一行开始。一直往下读，直到 SET 那一行。然后把这WHERE和SET调换位置。举个例子假设输入：### WHERE ### @1=123 ### @2=456 ### SET ### ...经过这个 sed 命令处理后，会变成：### SET ### @1=123 ### @2=456 ### WHERE ### ...sed -r '/SET/{:a;N;/@18/!ba;s/@14.*//g}'sed -r '/SET/{:a;N;/@12/!ba;s/@1.*//g}'这两行一起看，先看第一行。-r打开 扩展正则（ERE），写法上可以少一些反斜杠。/SET/{ ... }只有匹配到包含 SET 的那一行时，才会执行 {} 里的动作。:a; N; /@18/!ba;这是一个典型的 sed 循环：:a → 定义标签 a。N → 把下一行读入到 pattern space（追加）。/@18/!ba → 如果没有读到包含 @18 的行，就跳回 a 继续读。作用：把从 SET 开始到 @18 行为止的 整个多行内容 都读进来。s/@14.*//g在当前累积的 pattern space 中，匹配从@14开始，后面跟任意字符 .*，并替换为空。作用：删除@14 那行到@18那行的内容。第二行同样的道理，作用是删除@1那行到@12那行的内容。假设输入：### SET ### @1=5816 ### @2=4956 ### @3=0 ### @4=0 ### @5='风管机' ### @6='FGR3.5Pd/C3(S)Nha-N3' ### @7=2.0000 ### @8='套' ### @9=NULL ### @10=2 ### @11=NULL ### @12=NULL ### @13=0 ### @14='2023-04-14 16:18:08' ### @15='2023-04-14 16:18:08' ### @16=19 ### @17=19 ### @18='' ### WHERE ### ...经过这个 sed 命令处理后，会变成：### SET ### @13=0 ### WHERE ### ...sed -r '/WHERE/{:a;N;/@18/!ba;s/@2.*//g}'作用：删除WHERE后面的@2到@18的行，因为WHERE条件只需要id字段，即@1。sed 's/### //g;s/\/\*.*\*\//,/g'作用：### 替换为空，/* ... */替换成逗号。sed '/WHERE/{:a;N;/@1/!ba;s/,/;/g};s/#.*//g;s/COMMIT.*//g'作用：@1那一行的,替换成;删除有#号的行，删除COMMIT那一行sed '/^$/d'作用：删除空白行sed 's/@13/is_deleted/g;s/@1/id/g'作用：把@13替换成is_deleted，把@1替换成idsed -r 's/(is_deleted=.*),/\1/g'作用：删除is_deleted那一行后面的,命令组合后的整体效果：假设输入：# at 273068250 #250905 6:48:12 server id 414163 end_log_pos 273068548 CRC32 0x06b6ee0e Update_rows: table id 5546 flags: STMT_END_F ### UPDATE `test`.`t1` ### WHERE ### @1=5816 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=4956 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=2.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=0 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ ### SET ### @1=5816 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=4956 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=2.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=1 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ # at 273068548 #250905 6:48:12 server id 414163 end_log_pos 273068579 CRC32 0xce483344 Xid = 494745070 COMMIT/*!*/;处理后输出：UPDATE `test`.`t1` SET is_deleted=0 WHERE id=5816 ;我这个案例中，我只需要恢复一个字段（is_deleted），如果你要恢复多个字段，需要改造一下命令。实战场景二：delete语句不加where条件误操作数据恢复事件描述误操作导致一张表的多条数据被物理删除，该操作是由于执行了一条DELETE语句不加WHERE条件，或WHERE条件有误引起。DELETE FROM `test`.`t1`;诉求恢复误操作删除的数据。解析binlogmysqlbinlog --no-defaults -v -v --base64-output=DECODE-ROWS binlog.000029 > binlog.out查找关键字DELETE FROM `test`.`t1`# at 39574418 #250909 5:32:07 server id 1 end_log_pos 39581451 CRC32 0x239d3070 Delete_rows: table id 102 flags: STMT_END_F ### DELETE FROM `test`.`t1` ### WHERE ### @1=5816 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=4956 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=2.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=0 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2023-04-14 16:18:08' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=19 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ ### DELETE FROM `test`.`t1` ### WHERE ### @1=5818 /* LONGINT meta=0 nullable=0 is_null=0 */ ### @2=11309 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @3=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @4=0 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @5='风管机' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @6='FGR3.5Pd/C3(S)Nha-N3' /* VARSTRING(1020) meta=1020 nullable=0 is_null=0 */ ### @7=1.0000 /* DECIMAL(12,4) meta=3076 nullable=1 is_null=0 */ ### @8='套' /* VARSTRING(400) meta=400 nullable=1 is_null=0 */ ### @9=NULL /* BLOB/TEXT meta=2 nullable=1 is_null=1 */ ### @10=2 /* INT meta=0 nullable=0 is_null=0 */ ### @11=NULL /* VARSTRING(128) meta=128 nullable=1 is_null=1 */ ### @12=NULL /* VARSTRING(400) meta=400 nullable=1 is_null=1 */ ### @13=0 /* TINYINT meta=0 nullable=1 is_null=0 */ ### @14='2023-04-14 16:31:43' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @15='2025-05-29 22:12:25' /* DATETIME(0) meta=0 nullable=1 is_null=0 */ ### @16=493 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @17=16 /* LONGINT meta=0 nullable=1 is_null=0 */ ### @18='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */ …… # at 39581451 #250909 5:32:07 server id 1 end_log_pos 39581482 CRC32 0x7d7f2b3a Xid = 96546 COMMIT/*!*/;稍做分析# at 39574418表示这条 binlog 事件的起始位置（offset = 39574418）。这个表有18个字段，字段的位置以@序号的表示方式。CREATE TABLE `t1` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', -- @1 `project_id` bigint(20) DEFAULT NULL COMMENT '项目id', -- @2 `oppty_info_id` bigint(20) DEFAULT NULL COMMENT '商机id', -- @3 `oppty_info_palmtop_id` bigint(20) DEFAULT NULL COMMENT '掌上通id', `device_name` varchar(255) NOT NULL DEFAULT '' COMMENT '分类名称', `device_type` varchar(255) NOT NULL DEFAULT '' COMMENT '设备型号', `price` decimal(12,4) DEFAULT NULL COMMENT '单价(万)', `unit` varchar(100) DEFAULT '' COMMENT '单位', `unit_barcode` text COMMENT '机组条码List,[xxx,xxx]', `qty` int(11) NOT NULL DEFAULT '0' COMMENT '数量', `drawing_no` varchar(32) DEFAULT NULL COMMENT '图号', `tool_no` varchar(100) DEFAULT NULL COMMENT '工装编号', `is_deleted` tinyint(1) DEFAULT '0' COMMENT '删除状态：1-删除 0-未删除', `created_time` datetime DEFAULT NULL COMMENT '创建时间', `updated_time` datetime DEFAULT NULL COMMENT '更新时间', `created_by` bigint(20) DEFAULT NULL COMMENT '创建人', `updated_by` bigint(20) DEFAULT NULL COMMENT '更新人', `remark` varchar(255) DEFAULT '' COMMENT '备注', -- @18 PRIMARY KEY (`id`), KEY `idx_oppty_info_id_deleted` (`oppty_info_id`,`is_deleted`) ) ENGINE=InnoDB AUTO_INCREMENT=146778 DEFAULT CHARSET=utf8mb4 COMMENT='设备清单'提取这条SQL的完整binlog内容：sed -n '/# at 39574418/,/COMMIT/p' binlog.out > 39574418.out组装恢复脚本cat 39574418.out | \ sed -n '/###/p' | \ sed 's/### //g;s/\/\*.*\*\//,/g;s/DELETE FROM/INSERT INTO/g;s/WHERE/SELECT/g' | \ sed -r 's/(@18.*),/\1;/g' | \ sed -r 's/@[0-9]+=//g' > 39574418.recovery.sql这个sed还算容易理解，经过处理后的内容如下：INSERT INTO `test`.`t1` SELECT 5816 , 4956 , 0 , 0 , '风管机' , 'FGR3.5Pd/C3(S)Nha-N3' , 2.0000 , '套' , NULL , 2 , NULL , NULL , 0 , '2023-04-14 16:18:08' , '2023-04-14 16:18:08' , 19 , 19 , '' ; INSERT INTO `test`.`t1` SELECT 5818 , 11309 , 0 , 0 , '风管机' , 'FGR3.5Pd/C3(S)Nha-N3' , 1.0000 , '套' , NULL , 2 , NULL , NULL , 0 , '2023-04-14 16:31:43' , '2025-05-29 22:12:25' , 493 , 16 , '' ; ……觉得太复杂了？来点简单的上面的操作太复杂了，有没有易用的工具呢？在github上找到这两个项目，可以大大简化操作。binlog2sqlbinlog2sql使用Python写的项目，多年没有更新了，可能对MySQL 8.0支持有限。binlog2sql_gobinlog2sql_go使用Go写的项目，此项目使用的人不多，可能有BUG，需要你自行测试功能。使用限制必须连接MySQL Server，因为字段名是从Server上获取的。解析本地binlog，也是需要连接数据库的。因为字段名是从Server上获取的，所以如果表结构有变动的话（特别是新增列和删除列），会导致生成的SQL不对或报错。