阅读视图

发现新文章,点击刷新页面。

【AI 编程实战】第 3 篇:后端小白也能写 API:AI 带我 1 小时搭完 Next.js 服务

后端 API 开发是很多前端开发者的"心理阴影"——数据库设计、ORM 操作、JWT 认证、错误处理……每一步都是坑。但有了 AI,这一切都变得简单起来。这是《AI 编程实战:TRAE SOLO 全栈开发指南》专栏的第三篇文章,带你用 AI 快速搭建 Next.js 15 后端 API。

一、开篇:前端开发者的后端焦虑

1.1 后端开发的"噩梦"

还记得小何吗?完成前端架构搭建后,他面临一个更大的挑战:后端 API 开发

作为一个前端开发者,小何对后端并不陌生,但每次写后端代码时,总有一种"如履薄冰"的感觉:

数据库表怎么设计?字段类型怎么选?
ORM 怎么用?Prisma 和 TypeORM 有什么区别?
JWT 怎么生成?Token 过期怎么处理?
接口怎么设计才符合 RESTful 规范?
错误处理怎么统一?日志怎么记录?

传统方式下,小何需要:

任务 预计时间
学习 Next.js API 2 小时
数据库表设计 3 小时
Prisma 配置和学习 4 小时
JWT 认证实现 3 小时
登录注册 API 4 小时
响应格式统一 2 小时
错误处理和日志 2 小时
总计 20 小时

实际上,因为各种问题,可能需要 3-4 天

1.2 AI 辅助的后端开发

有了 TRAE SOLO,小何的体验完全不同:

小何:帮我搭建 Next.js 15 后端 API,需要:
      1. MySQL 数据库,使用 Prisma ORM
      2. JWT 认证
      3. 微信小程序登录
      4. 统一的响应格式
      5. 日志记录

AI:好的,我来帮你搭建完整的后端架构:

    ✓ 分析项目结构,确定 Pages Router 模式
    ✓ 设计数据库表结构(用户、会员、账单等)
    ✓ 配置 Prisma ORM 连接 MySQL
    ✓ 实现 JWT 生成和验证
    ✓ 封装微信登录流程
    ✓ 统一响应格式(responseHelper)
    ✓ 配置 Winston 日志系统

    后端架构已生成,请查看...

时间对比

任务 传统方式 AI 辅助 提升倍数
数据库设计 3 小时 15 分钟 12x
Prisma 配置 4 小时 10 分钟 24x
JWT 认证 3 小时 10 分钟 18x
登录 API 4 小时 20 分钟 12x
响应格式统一 2 小时 5 分钟 24x
总计 ~20 小时 ~1 小时 ~20x

二、技术选型:为什么选择 Next.js 15 + Prisma

2.1 Next.js 作为后端的优势

小何:为什么后端要用 Next.js?不是应该用 Express 或 Koa 吗?

AI:Next.js 作为后端有独特优势:

    1. 全栈一体化
       - 前后端可以共享代码和类型
       - 部署更简单,一个服务搞定

    2. API Routes 开箱即用
       - 文件即路由,无需额外配置
       - 支持中间件、动态路由

    3. TypeScript 原生支持
       - 类型安全,减少运行时错误
       - 与 Prisma 完美配合

    4. 生态完善
       - 丰富的中间件和插件
       - 社区活跃,问题容易解决

2.2 Prisma vs TypeORM

小何:Prisma 和 TypeORM 怎么选?

AI:(通过 MCP 查阅最新文档)

    ┌────────────────┬────────────────┬────────────────┐
    │     维度       │     Prisma     │    TypeORM     │
    ├────────────────┼────────────────┼────────────────┤
    │ 类型安全       │ ⭐⭐⭐⭐⭐     │ ⭐⭐⭐⭐       │
    │ 学习曲线       │ ⭐⭐⭐⭐⭐     │ ⭐⭐⭐         │
    │ 迁移管理       │ ⭐⭐⭐⭐⭐     │ ⭐⭐⭐⭐       │
    │ 查询直观性     │ ⭐⭐⭐⭐⭐     │ ⭐⭐⭐         │
    │ 性能           │ ⭐⭐⭐⭐       │ ⭐⭐⭐⭐       │
    │ 生态系统       │ ⭐⭐⭐⭐       │ ⭐⭐⭐⭐⭐     │
    └────────────────┴────────────────┴────────────────┘

    推荐:Prisma

    理由:
    1. Schema 定义直观,一目了然
    2. 自动生成类型,类型安全
    3. 迁移管理简单,prisma migrate 一键搞定
    4. 查询 API 简洁,链式调用友好

2.3 最终技术栈

后端技术栈:
├── 框架:Next.js 15 (Pages Router)
├── 数据库:MySQL
├── ORM:Prisma 4.11
├── 认证:JWT (jsonwebtoken)
├── 加密:bcryptjs
├── 日志:Winston + daily-rotate-file
├── AI 集成:OpenAI SDK
├── 支付:微信支付 + 支付宝
├── 存储:腾讯云 COS
└── 类型共享:shared-types (workspace)

三、数据库设计:Prisma Schema

3.1 用户表设计

数据库设计是后端开发的第一步,也是最重要的一步。

提示词

帮我设计用户表,需要支持:
1. 微信小程序登录(openid)
2. 手机号登录
3. 性别、年龄段等用户属性
4. 免费使用次数限制
5. 注册来源和渠道追踪

AI 生成的 Prisma Schema

// prisma/schema.prisma

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "darwin", "darwin-arm64", "linux-musl-openssl-3.0.x"]
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  user_id               String    @id @default(uuid())
  username              String
  email                 String?   @unique
  phone_number          String?
  openid                String?   // 微信 openid
  hashed_password       String
  email_verified        Boolean   @default(false)
  phone_verified        Boolean   @default(false)

  // 地理信息
  country               String?
  province              String?
  city                  String?
  isp                   String?

  // 用户属性
  verification_code     String?
  avatar                String?
  gender                Int?      // 1: 男, 2: 女
  age_group             Int?      // 1: 00后, 2: 05后, 3: 90后, 4: 80后, 5: 70后

  // 状态管理
  disabled_status       Int?      @default(0)
  disabled_time         DateTime?

  // 免费额度
  free_reply_total      Int       @default(3)
  free_reply_used       Int       @default(0)
  free_reply_reset_time DateTime?

  // 来源追踪
  register_source       Int       @default(1)  // 注册来源
  register_channel      Int       @default(1)  // 注册渠道
  latest_source         Int       @default(1)  // 最近来源
  latest_channel        Int       @default(1)  // 最近渠道

  // 时间戳
  code_send_time        DateTime?
  create_time           DateTime? @default(now())
  update_time           DateTime  @default(now()) @updatedAt
  operate_time          DateTime?

  // 索引
  @@unique([phone_number, deleted_status])
  @@index([register_source], name: "idx_users_register_source")
  @@index([latest_source], name: "idx_users_latest_source")
  @@map("users")
}

设计要点解析

  1. UUID 主键:使用 @default(uuid()) 而非自增 ID,分布式友好,自己玩的小项目推荐自增 ID
  2. 软删除deleted_status + deleted_time,数据可恢复
  3. 来源追踪register_source/channellatest_source/channel,用于数据分析
  4. 免费额度free_reply_total/used/reset_time,实现每日免费次数限制

3.2 会员表设计

// 会员类型模型
model MembershipType {
  id              Int       @id @default(autoincrement())
  name            String    // 会员名称:月度会员、季度会员、年度会员
  price           Decimal   @db.Decimal(10, 2)
  duration_days   Int       // 会员时长(天)
  description     String?
  is_active       Boolean   @default(true)
  sort_order      Int       @default(0)
  create_time     DateTime  @default(now())
  update_time     DateTime  @default(now()) @updatedAt

  @@map("membership_types")
}

// 用户会员记录
model UserMembership {
  id              Int       @id @default(autoincrement())
  user_id         String
  membership_type Int       // 关联会员类型
  start_time      DateTime
  end_time        DateTime
  is_active       Boolean   @default(true)
  source          String    @default("purchase") // purchase/gift/invite
  order_id        String?   // 关联订单
  create_time     DateTime  @default(now())
  update_time     DateTime  @default(now()) @updatedAt

  @@index([user_id], name: "idx_user_membership_user_id")
  @@index([end_time], name: "idx_user_membership_end_time")
  @@map("user_memberships")
}

3.3 Prisma 初始化和迁移

# 安装 Prisma
pnpm --filter xingdong-server add prisma @prisma/client

# 初始化 Prisma
pnpm --filter xingdong-server prisma init

# 生成 Prisma Client
pnpm --filter xingdong-server prisma generate

# 创建迁移
pnpm --filter xingdong-server prisma migrate dev --name init

# 查看数据库
pnpm --filter xingdong-server prisma studio

四、项目结构:分层架构设计

4.1 目录结构

AI 帮小何设计了清晰的分层架构:

apps/xindong-server/
├── prisma/
│   └── schema.prisma           # 数据库模型定义
├── src/
│   ├── constants/              # 常量定义
│   │   └── index.ts
│   ├── db/                     # 数据访问层 (DAL)
│   │   ├── user.ts             # 用户数据操作
│   │   ├── chat.ts             # 聊天数据操作
│   │   ├── membership.ts       # 会员数据操作
│   │   └── ...
│   ├── helper/                 # 辅助工具
│   │   ├── logger.ts           # 日志工具
│   │   └── responseHelper.ts   # 响应格式化
│   ├── pages/
│   │   └── api/                # API 路由
│   │       ├── auth/           # 认证相关
│   │       │   ├── login.ts
│   │       │   ├── wx-login.ts
│   │       │   └── register.ts
│   │       ├── chat/           # 聊天相关
│   │       ├── membership/     # 会员相关
│   │       └── upload/         # 文件上传
│   ├── service/                # 业务逻辑层
│   │   ├── auth.ts             # 认证服务
│   │   ├── chatService.ts      # 聊天服务
│   │   ├── membershipService.ts
│   │   └── ...
│   ├── type/                   # 类型定义
│   └── utils/                  # 工具函数
│       ├── auth/
│       │   └── auth.ts         # JWT 工具
│       ├── prismaProxy.ts      # Prisma 代理
│       └── logRequest.ts       # 请求日志
├── .env                        # 环境变量
└── package.json

分层职责

  1. pages/api:接收请求、参数验证、调用 Service
  2. service:业务逻辑处理、调用多个 DB 方法
  3. db:纯数据操作、Prisma 查询封装
  4. helper:通用辅助功能(日志、响应格式化)
  5. utils:工具函数(加密、JWT、请求处理)

4.2 Prisma 代理封装

AI 生成了一个 Prisma 代理,自动记录所有数据库操作日志:

// src/utils/prismaProxy.ts
import logger from '@/helper/logger';
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

function handlePrismaError(error: Error) {
  logger.error(`Prisma error: ${error.message}`, { error });
}

const handler: ProxyHandler<PrismaClient> = {
  get(target, propKey) {
    const origMethod = target[propKey as keyof PrismaClient];

    if (typeof origMethod === 'function') {
      return function (this: PrismaClient, ...args: unknown[]) {
        const boundMethod = (origMethod as Function).bind(this);
        try {
          // 记录请求日志
          logger.info(`Prisma request: ${propKey.toString()} with args: ${JSON.stringify(args)}`);

          const result = boundMethod(...args);
          if (result instanceof Promise) {
            return result
              .then((res) => {
                // 如果响应包含 count 属性,则记录所影响的行数
                if (res && typeof res.count === 'number') {
                  logger.info(`Prisma response: ${propKey.toString()} affected rows: ${res.count}`);
                }
                return res;
              })
              .catch(handlePrismaError);
          }
          return result;
        } catch (error) {
          return handlePrismaError(error as Error);
        }
      };
    }

    return target[propKey as keyof PrismaClient];
  },
};

const prismaProxy = new Proxy(prisma, handler);
export default prismaProxy;

使用方式

// 在 db 层使用代理后的 prisma
import prisma from '@/utils/prismaProxy';

// 所有操作自动记录日志
const user = await prisma.user.findUnique({
  where: { user_id: userId },
});

五、JWT 认证:从生成到验证

5.1 JWT 工具封装

提示词

帮我封装 JWT 工具,需要:
1. 生成 Token(带过期时间)
2. 验证 Token
3. 密码哈希和验证
4. 使用 bcryptjs 加密

AI 生成的代码

// src/utils/auth/auth.ts
import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';
import { TOKEN_EXPIRE_IN } from '@/constants';

// 密码哈希
export const hashPassword = async (password: string): Promise<string> => {
  const salt = await bcrypt.genSalt(10);
  return bcrypt.hash(password, salt);
};

// 密码验证
export const verifyPassword = async (
  password: string,
  hashedPassword: string,
): Promise<boolean> => {
  return bcrypt.compare(password, hashedPassword);
};

// 生成 JWT
export const generateJWT = (userId: string): string => {
  return jwt.sign({ userId }, process.env.JWT_SECRET as string, {
    expiresIn: TOKEN_EXPIRE_IN, // 例如 '7d'
  });
};

// 验证 JWT
export const verifyJWT = (token: string): Promise<any> => {
  return new Promise((resolve, reject) => {
    jwt.verify(token, process.env.JWT_SECRET as string, (err, decoded) => {
      if (err) {
        reject(err);
      } else {
        resolve(decoded);
      }
    });
  });
};

5.2 认证中间件

// src/utils/auth/authMiddleware.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { verifyJWT } from './auth';
import { sendUnauthorizedResponse } from '@/helper/responseHelper';
import { getUserById } from '@/db/user';

export interface AuthenticatedRequest extends NextApiRequest {
  userId: string;
  user: any;
}

export const withAuth = (
  handler: (req: AuthenticatedRequest, res: NextApiResponse) => Promise<void>,
) => {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    try {
      // 从 Header 获取 Token
      const authHeader = req.headers.authorization;
      if (!authHeader || !authHeader.startsWith('Bearer ')) {
        return sendUnauthorizedResponse(res, '请先登录');
      }

      const token = authHeader.substring(7);

      // 验证 Token
      const decoded = await verifyJWT(token);
      if (!decoded || !decoded.userId) {
        return sendUnauthorizedResponse(res, 'Token 无效');
      }

      // 获取用户信息
      const user = await getUserById(decoded.userId);
      if (!user) {
        return sendUnauthorizedResponse(res, '用户不存在');
      }

      // 注入用户信息
      (req as AuthenticatedRequest).userId = decoded.userId;
      (req as AuthenticatedRequest).user = user;

      // 调用原始处理函数
      return handler(req as AuthenticatedRequest, res);
    } catch (error) {
      return sendUnauthorizedResponse(res, '认证失败,请重新登录');
    }
  };
};

使用方式

// pages/api/user/profile.ts
import { withAuth, AuthenticatedRequest } from '@/utils/auth/authMiddleware';
import { sendSuccessResponse } from '@/helper/responseHelper';
import { NextApiResponse } from 'next';

const handler = async (req: AuthenticatedRequest, res: NextApiResponse) => {
  // req.userId 和 req.user 已经可用
  sendSuccessResponse(res, '获取成功', {
    user_id: req.userId,
    username: req.user.username,
  });
};

export default withAuth(handler);

六、统一响应格式

6.1 响应格式设计

统一的响应格式是 API 开发的基本要求。AI 帮小何设计了清晰的响应结构:

// src/helper/responseHelper.ts
import { NextApiResponse } from 'next';
import logger from './logger';
import { UNAUTHORIZED_TIPS } from '@/constants';

interface JsonResponse {
  code: number;
  message: string;
  data?: any;
}

// 通用响应
export function sendResponse(res: NextApiResponse, code: number, message: string, data: any = null): void {
  const jsonResponse: JsonResponse = {
    code,
    message,
    data,
  };
  res.status(code).json(jsonResponse);
}

// 成功响应
export function sendSuccessResponse(
  res: NextApiResponse,
  message: string = '数据获取成功',
  data: any = null,
): void {
  sendResponse(res, 200, message, data);
}

// 错误响应(服务器错误)
export function sendErrorResponse(res: NextApiResponse, message: string, data: any = null): void {
  sendResponse(res, 500, message, data);
  logger.error(message);
  logger.error(data);
}

// 警告响应(业务错误)
export function sendWarnningResponse(res: NextApiResponse, message: string, data: any = null): void {
  sendResponse(res, 503, message, data);
}

// 未授权响应
export function sendUnauthorizedResponse(
  res: NextApiResponse,
  message: string = UNAUTHORIZED_TIPS,
): void {
  sendResponse(res, 401, message);
}

// 方法不允许
export function sendMethodNotAllowedResponse(
  res: NextApiResponse,
  message: string = 'Method Not Allowed',
): void {
  sendResponse(res, 405, message);
}

6.2 响应码设计

状态码 含义 使用场景
200 成功 请求成功处理
401 未授权 Token 无效或过期
405 方法不允许 GET 接口收到 POST 请求
500 服务器错误 代码异常、数据库错误
503 业务警告 参数错误、业务规则不满足

前端统一处理

// 前端 HTTP 拦截器
const handleResponse = (response: any) => {
  const { code, message, data } = response;

  switch (code) {
    case 200:
      return data;
    case 401:
      // 跳转登录
      uni.reLaunch({ url: '/pages/login/login' });
      throw new Error(message);
    case 503:
      // 业务警告,显示 Toast
      uni.showToast({ title: message, icon: 'none' });
      throw new Error(message);
    case 500:
      // 服务器错误
      uni.showToast({ title: '服务器开小差了', icon: 'none' });
      throw new Error(message);
    default:
      throw new Error(message || '未知错误');
  }
};

七、微信小程序登录实现

7.1 登录流程设计

微信小程序登录是"心动恋聊"的核心功能。AI 帮小何设计了完整的登录流程:

sequenceDiagram
    participant Client as 小程序端
    participant Server as 后端服务
    participant WX as 微信服务器

    Client->>Client: 1. wx.login()
    Client->>Client: 2. 返回 code

    Client->>Server: 3. POST /api/auth/wx-login
    Note right of Client: { code, gender, age_group }

    Server->>WX: 4. jscode2session
    WX-->>Server: 5. 返回 openid

    Server->>Server: 6. 查找/创建用户
    Server->>Server: 7. 生成 JWT

    Server-->>Client: 8. 返回 token + userInfo

7.2 登录 API 实现

提示词

帮我实现微信小程序登录 API,需要:
1. 接收 wx.login 的 code
2. 调用微信服务器获取 openid
3. 新用户需要收集性别和年龄段
4. 老用户直接返回 token
5. 支持多来源(小程序、App)
6. 记录用户来源和渠道

AI 生成的代码

// src/pages/api/auth/wx-login.ts
import { sendErrorResponse, sendSuccessResponse } from '@/helper/responseHelper';
import { generateJWT } from '@/utils/auth/auth';
import { getUserByOpenid, createUserByOpenid } from '@/db/user';
import { NextApiRequest, NextApiResponse } from 'next';

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  if (req.method !== 'POST') {
    return sendErrorResponse(res, 'Method Not Allowed');
  }

  try {
    const { code, gender, age_group } = req.body;

    // 1. 调用微信服务器获取 openid
    const wxLoginUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${process.env.WX_APPID}&secret=${process.env.WX_SECRET}&js_code=${code}&grant_type=authorization_code`;
    const wxRes = await fetch(wxLoginUrl);
    const { openid } = await wxRes.json();

    // 2. 查找或创建用户
    let user = await getUserByOpenid(openid);
    let isNewUser = false;

    if (!user) {
      // 新用户需要提供性别和年龄
      if (!gender || !age_group) {
        return sendSuccessResponse(res, '需要完善信息', { needsRegistration: true, openid });
      }
      user = await createUserByOpenid(openid, gender, age_group);
      isNewUser = true;
    }

    // 3. 生成 JWT Token
    const token = generateJWT(user.user_id);

    // 4. 返回登录结果
    sendSuccessResponse(res, '登录成功', { token, ...user, isNewUser });
  } catch (error: any) {
    sendErrorResponse(res, '登录失败', error.message);
  }
};

export default handler;

7.3 登录服务层

手机号登录的服务层封装,支持密码和验证码两种方式:

// src/service/auth.ts
import { getUserByPhoneNumber } from '@/db/user';
import { checkCodeValid } from '@/service/checkCodeValid';
import { generateJWT, verifyPassword } from '@/utils/auth/auth';
import { LoginCredentials, LoginResult } from 'shared-types';

export class AuthServiceError extends Error {
  constructor(
    message: string,
    public code: string,
  ) {
    super(message);
    this.name = 'AuthServiceError';
  }
}

export async function loginUser(credentials: LoginCredentials): Promise<LoginResult> {
  const { phone_number, password, verification_code } = credentials;

  // 从数据库获取用户
  const user = await getUserByPhoneNumber(phone_number!);
  if (!user) {
    throw new AuthServiceError('该用户不存在,请先注册', 'USER_NOT_FOUND');
  }

  if (password) {
    // 密码登录
    const decodedPassword = Buffer.from(password, 'base64').toString();
    const isPasswordValid = await verifyPassword(decodedPassword, user.hashed_password);
    if (!isPasswordValid) {
      throw new AuthServiceError('手机号或密码输入有误', 'INVALID_CREDENTIALS');
    }

    if (!user.phone_verified) {
      throw new AuthServiceError(
        '手机号未验证通过,请先使用手机号+验证码的方式登录',
        'PHONE_UNVERIFIED',
      );
    }
  } else {
    // 验证码登录
    if (!verification_code) {
      throw new AuthServiceError('请输入验证码', 'MISSING_VERIFICATION_CODE');
    }
    const { valid, msg } = checkCodeValid({ user, code: verification_code });
    if (!valid) {
      throw new AuthServiceError(msg, 'INVALID_VERIFICATION_CODE');
    }

    if (!user.phone_verified) {
      throw new AuthServiceError('请先注册完成后再登录', 'USER_NOT_REGISTERED');
    }
  }

  // 生成 JWT
  const token = await generateJWT(user.user_id);

  return {
    user,
    token,
  };
}

八、前后端类型共享:shared-types

8.1 为什么需要类型共享

前后端分离开发时,最常见的问题是接口对不上

后端:{ user_id: "xxx" }
前端:{ userId: "xxx" }  // 拿不到数据

后端:{ gender: 1 }
前端:{ gender: "男" }  // 类型不匹配

解决方案:shared-types 包

8.2 类型定义

// packages/shared-types/enums.ts

/**
 * 性别枚举
 */
export enum GenderEnum {
  MALE = 1,
  FEMALE = 2,
}

/**
 * 年龄段枚举
 */
export enum AgeGroupEnum {
  POST_00 = 1,  // 00后
  POST_05 = 2,  // 05后
  POST_90 = 3,  // 90后
  POST_80 = 4,  // 80后
  POST_70 = 5,  // 70后
}

/**
 * 年龄段枚举映射(用于显示)
 */
export const AgeGroupMap = {
  [AgeGroupEnum.POST_00]: '00后',
  [AgeGroupEnum.POST_05]: '05后',
  [AgeGroupEnum.POST_90]: '90后',
  [AgeGroupEnum.POST_80]: '80后',
  [AgeGroupEnum.POST_70]: '70后',
};

/**
 * 客户端来源枚举
 */
export enum ClientSourceEnum {
  MP_WEIXIN = 1,  // 微信小程序
  ANDROID = 2,    // 安卓 App
  HARMONY = 3,    // 鸿蒙
}

8.3 在项目中使用

后端使用

// apps/xingdong-server/src/service/auth.ts
import { LoginCredentials, LoginResult } from 'shared-types';

export async function loginUser(credentials: LoginCredentials): Promise<LoginResult> {
  // 类型自动推导,IDE 智能提示
}

前端使用

// apps/unibest-mp/src/api/auth.ts
import type { WxLoginParams, WxLoginResult } from 'shared-types';

export const apiWxLogin = (params: WxLoginParams): Promise<WxLoginResult> => {
  return http.post('/api/auth/wx-login', params);
};

package.json 配置

{
  "dependencies": {
    "shared-types": "workspace:*"
  }
}

Next.js 配置(重要!):

// next.config.js
const nextConfig = {
  reactStrictMode: true,
  transpilePackages: ['shared-types'], // 编译本地 TypeScript 包
};

九、日志系统:Winston 配置

9.1 日志配置

// src/helper/logger.ts
import winston from 'winston';
import DailyRotateFile from 'winston-daily-rotate-file';
import path from 'path';

// 日志目录
const logDir = path.join(process.cwd(), 'logs');

// 日志格式
const logFormat = winston.format.combine(
  winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
  winston.format.errors({ stack: true }),
  winston.format.printf(({ timestamp, level, message, ...meta }) => {
    let msg = `${timestamp} [${level.toUpperCase()}]: ${message}`;
    if (Object.keys(meta).length > 0) {
      msg += ` ${JSON.stringify(meta)}`;
    }
    return msg;
  }),
);

// 创建 logger
const logger = winston.createLogger({
  level: process.env.LOG_LEVEL || 'info',
  format: logFormat,
  transports: [
    // 控制台输出
    new winston.transports.Console({
      format: winston.format.combine(winston.format.colorize(), logFormat),
    }),
    // 按日期滚动的文件日志
    new DailyRotateFile({
      dirname: logDir,
      filename: 'app-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      maxSize: '20m',
      maxFiles: '14d',
    }),
    // 错误日志单独存放
    new DailyRotateFile({
      dirname: logDir,
      filename: 'error-%DATE%.log',
      datePattern: 'YYYY-MM-DD',
      level: 'error',
      maxSize: '20m',
      maxFiles: '30d',
    }),
  ],
});

export default logger;

9.2 请求日志中间件

// src/utils/logRequest.ts
import { NextApiRequest, NextApiResponse } from 'next';
import logger from '@/helper/logger';

const logRequest = (req: NextApiRequest, res: NextApiResponse) => {
  const { method, url, body, query } = req;

  logger.info(`API Request: ${method} ${url}`, {
    body: JSON.stringify(body),
    query: JSON.stringify(query),
    ip: req.headers['x-forwarded-for'] || req.socket.remoteAddress,
    userAgent: req.headers['user-agent'],
  });

  // 记录响应时间
  const startTime = Date.now();
  res.on('finish', () => {
    const duration = Date.now() - startTime;
    logger.info(`API Response: ${method} ${url} - ${res.statusCode} (${duration}ms)`);
  });
};

export default logRequest;

十、总结与下一步

10.1 本篇完成的工作

通过 AI 辅助,我们在 约 1 小时 内完成了:

任务 完成情况
✅ Prisma 数据库设计 用户、会员、账单等核心表
✅ 分层架构 API → Service → DB 清晰分离
✅ JWT 认证 生成、验证、中间件完整实现
✅ 微信登录 支持小程序、App 多端登录
✅ 统一响应格式 responseHelper 标准化
✅ 类型共享 shared-types 前后端类型一致
✅ 日志系统 Winston 按日期滚动、分级存储

10.2 核心提示词模板

数据库设计

帮我设计 [表名] 表,需要支持:
1. [功能点 1]
2. [功能点 2]
3. [功能点 3]
使用 Prisma Schema 语法

API 开发

帮我实现 [功能名称] API,需要:
1. [接口功能]
2. [参数要求]
3. [返回格式]
4. [错误处理]

工具封装

帮我封装 [工具名称],需要:
1. [功能点 1]
2. [功能点 2]
示例用法:[使用场景]

10.3 下一篇预告

《【AI 编程实战】第 4 篇:用 AI 打造原子化 CSS 开发体系 - UnoCSS 实战》

我们将学习:

  • UnoCSS 高级配置
  • 设计稿转代码技巧
  • 主题定制和换肤
  • 小程序端样式优化
  • 响应式布局实践

关注我,不错过每一篇实战干货!


如果这篇文章对你有帮助,请点赞、收藏、转发,让更多人了解 AI 编程的强大!

有任何问题,欢迎在评论区留言,我们一起讨论。

我只是给Typescript提个 typo PR,为什么还要签协议?

第一次给大公司的开源项目(Typescript)提 PR,提交完 GitHub 就弹出一条评论,让你签什么 CLA:

@microsoft-github-policy-service agree

image.png 什么玩意儿?我就改了个拼写错误,还要签协议?

CLA 是什么

CLA,全称 Contributor License Agreement,翻译过来叫"贡献者许可协议"。

简单说,就是一份法律文件,你签了之后,就授权项目方可以合法使用你贡献的代码。

为什么需要这东西

想象一个场景:

张三给某开源项目提了个 PR,合并了。过了两年,张三突然跳出来说:"这段代码是我写的,你们用了我的代码,侵犯我版权,赔钱!"

项目方一脸懵:代码是你自己提交的啊?

张三:提交归提交,我没说授权你们用啊。

听起来像碰瓷,但法律上还真不好说。毕竟代码确实是张三写的,版权默认归作者。

CLA 就是为了堵这个漏洞。你签了 CLA,就相当于白纸黑字写清楚了:

  • 这代码是我自己写的(不是抄的)
  • 我授权你们用、改、分发
  • 以后不会反悔找你们麻烦

CLA 里具体写了啥

以微软的 CLA 为例,核心条款就这几条:

1. 原创声明

你保证提交的代码是你自己写的。如果包含别人的代码,要标注清楚来源和许可证。

2. 版权授权

你授予项目方永久的、全球范围的、免版税的版权许可。说白了就是:他们可以随便用,不用给你钱,也不用每次都问你。

3. 专利授权

如果你的代码涉及专利(虽然大多数情况下不会),你也授权他们使用。

4. 雇主确认

如果你是在工作中写的代码,公司可能对代码有知识产权。这种情况下,你得先拿到公司的许可才能签 CLA。

签了会怎样

签 CLA 不会让你:

  • 失去代码的版权(版权还是你的)
  • 不能在别处使用这段代码
  • 承担什么法律责任

签 CLA 只是说:

  • 项目方可以合法使用你的贡献
  • 你不会秋后算账

不同项目的 CLA

不是所有开源项目都要签 CLA,主要是大公司的项目:

公司 需要 CLA
微软
Google
Meta
Apache 基金会
个人项目 通常不需要

个人维护的开源项目一般不搞这套,太麻烦。但大公司不行,法务部不允许有法律风险敞口。

怎么签

以 GitHub 上的微软项目为例:

  1. 提交 PR
  2. 机器人会自动评论,让你签 CLA
  3. 回复:@microsoft-github-policy-service agree
  4. 搞定

就这么简单。签一次就行,以后再给同一个组织提 PR 就不用重复签了。

如果你是代表公司贡献代码,需要加上公司名:

@microsoft-github-policy-service agree company="你的公司名"

一些细节

Q:我就改了个 typo,也要签?

是的。哪怕只改了一个字符,也是贡献,也要签。

Q:签了 CLA,代码版权归谁?

版权还是你的。CLA 只是授权,不是转让。

Q:能撤回吗?

理论上你不能撤回已经合并的代码的授权。但你可以随时停止贡献。

Q:CLA 和开源许可证什么关系?

开源许可证(MIT、Apache 等)是项目对外的授权,告诉使用者可以怎么用这个项目。

CLA 是贡献者对项目的授权,告诉项目方可以怎么用贡献者的代码。

两个方向不一样。

小结

CLA 这东西,说白了就是大公司的法务需求。对贡献者来说,签一下也没什么损失,就是授权项目方合法使用你的代码。

第一次遇到可能有点懵,但理解了它的目的,就知道这是正常流程,不是什么坑。

签就完了。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

数据标注平台正式上线啦! 标注赚现金,低门槛真收益 | 掘金一周 12.10

本文字数1400+ ,阅读时间大约需要 5分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣) @ErpanOmer

文章讲述公司内部敏感文档泄露后,利用基于零宽字符的盲水印技术抓“内鬼”。介绍零宽字符概念,阐述加密、解密原理,给出实现代码,还提及水印可被清除,强调这是低成本、高隐蔽性防御手段。

不仅免费,还开源?这个 AI Mock 神器我必须曝光它 @不一样的少年_

本文介绍了一款零侵入的接口 Mock 插件,重构为 Sidebar 常驻侧栏,体验更佳。它接入 AI 自动生成数据,支持延时和状态码模拟。具备拦截、匹配、响应控制等功能,覆盖前端 90% 的 Mock 需求,推荐试用。

后端

开源企业级 IM!一款高颜值的即时通讯聊天应用! @Java陈序员

本文推荐了基于 GO 开发的开源即时通讯系统 TangSengDaoDaoServer,它轻量、高性能且重安全,支持多端同步。介绍了其功能特色、项目架构,给出 Docker 部署步骤,还展示功能体验,推荐大家尝试。

Android

用 AI 做了几个超炫酷的 Flutter 动画,同时又差点被 AI 气死 @恋猫de小郭

文章介绍用 AI 实现几种 Flutter 动画。奇异粒子动画基于数学公式,解决投影等问题;斐波那契球体让点均匀分布在球面;星云动画模拟星系动力学。不过,AI 实现时遇颜色插值陷阱问题,最终换思路解决。

Android Studio Otter 2 Feature 发布,最值得更新的 Android Studio @恋猫de小郭

Android Studio Otter 2 Feature发布,是值得更新的版本。它内置Gemini 3,增强Agent模式并配备Android知识库。支持备份与同步设置,开发者可接收团队资讯。还整合IntelliJ IDEA 2025.2改进,能免费试用Gemini 3 Pro。

Flutter TolyUI 框架#09 | tolyui_text 轻量高亮文本 @张风捷特烈

本文介绍了 Flutter TolyUI 框架的 tolyui_text 模块。该模块封装文本高亮方案,提供轻量级解决方案。支持搜索关键字高亮、自定义匹配规则和多模式智能识别,还能处理点击事件,未来会有更多新功能。

人工智能

🔥 懂原理但不会说?我怒写了个 AI 模拟器折磨自己,M属性大爆发! @HiStewie

作者为解决面试准备难题,用 TRAE SOLO 重构初版工具。从架构设计、核心实现、技术栈选型等多方面展开,一晚完成含简历解析等功能的 MVP,验证新开发范式,未来产品还有诸多扩展方向。

解读 Claude 对开发者的影响:AI 如何在 Anthropic 改变工作?@恋猫de小郭

Anthropic 对内部员工调查显示,AI 显著影响开发者。生产力平均提升 50%,启用新工作,改变委托实践。开发者技能有扩展与退化,社会互动减少,职业认同受冲击。AI 红利与债务并存,重塑职业价值观。

Chatbox支持接入LangGraph智能体?一切都靠Trae Solo!@大模型真好玩

本文作者借助 Trae Solo 实现将 LangChain 智能体接入 Chatbox 客户端。先介绍两者,阐述接入思路,以天气助手智能体为例展示 Trae Solo 自动化开发流程,最后展望其潜力,鼓励用它快速搭建原型、验证逻辑。

IOS

iOS UIKit 全体系知识手册(Objective-C 版) @如此风景

UIKit 是 iOS 开发基石框架,围绕视图、控制器、事件展开。掌握布局、事件处理、渲染优化是关键。开发中用 Masonry 简化布局,结合适配特性,借助 Instruments 定位问题,可高效构建稳定适配界面。

社区活动日历

掘金官方 文章头图 1303x734.jpg

活动日历

活动名称 活动时间
🚀TRAE SOLO 实战赛 2025年11月13日-2025年12月16日
数据标注平台正式上线啦! -

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

从后端模板到响应式驱动:界面开发的演进之路

从后端模板到响应式驱动:界面开发的演进之路

在 Web 开发的发展历程中,界面与数据的交互方式经历了多次重要变革。从早期的后端模板渲染到如今的响应式数据驱动,每一次演进都深刻影响着开发模式和用户体验。

一、纯后端套模板时代:MVC 模式的兴起

早期的 Web 开发普遍采用 MVC(Model-View-Controller)开发模式,这种模式将应用程序分为三个核心部分:

  • Model(模型) :负责数据管理,通常与数据库交互例如通过 MySQL 等数据库进行数据抽象存储
  • View(视图) :负责数据展示,以 HTML 模板为载体
  • Controller(控制器) :处理业务逻辑,协调模型和视图

这一时期的后端代码通常直接处理 HTTP 请求并渲染模板,典型的 Node.js 示例如下:

// 简化的后端MVC示例
const http = require('http');
const mysql = require('mysql');

// 模型:数据库连接
const db = mysql.createConnection({
  host: 'localhost',
  user: 'root',
  password: '',
  database: 'todoapp'
});

// 控制器:处理业务逻辑
const getTodos = (req, res) => {
  db.query('SELECT * FROM todos', (err, results) => {
    if (err) throw err;
    // 渲染视图(模板)
    const html = `
      
        
          <ul>
            ${results.map(todo => `<li>${todo.content}</li>`).join('')}
          </ul>
        
      
    `;
    res.end(html);
  });
};

// HTTP服务
http.createServer((req, res) => {
  if (req.url === '/todos') {
    getTodos(req, res);
  } else {
    res.end('Hello World');
  }
}).listen(3000);

在这种模式下,HTML 的静态部分和动态数据部分混合在一起,动态内容通过模板语法(如{{todos}})由后端数据驱动生成。

二、前后端分离:开发职责的解耦

随着 Web 应用复杂度提升,前后端分离架构逐渐成为主流。这种模式将应用拆分为两个独立部分:

  • 前端:专注于 HTML、CSS 和 JavaScript 实现,通过 Ajax 或 Fetch 主动从后端拉取数据
// 前端获取数据示例
fetch('http://localhost:3000/users')
.then(response => response.json())
.then(users => {
  // 处理并展示数据
  const userList = document.getElementById('user-list');
  userList.innerHTML = users.map(user => `<li>${user.name}</li>`).join('');
});

后端:不再返回完整 HTML,而是提供纯粹的数据接口(API)

// 后端API示例
http.createServer((req, res) => {
  if (req.url === '/users') {
    res.setHeader('Content-Type', 'application/json');
    res.end(JSON.stringify([
      { id: 1, name: '张三' },
      { id: 2, name: '李四' }
    ]));
  }
}).listen(3000);

前后端分离带来了显著优势:

  • 前端开发者可专注于数据展示和用户体验
  • 后端开发者可专注于数据处理和系统性能
  • 双方可并行开发,通过 API 契约协作

三、Vue 响应式:数据驱动的革命

Vue 框架的出现彻底改变了前端开发模式,其核心是响应式数据驱动

1. 响应式数据(ref 实现)

Vue 通过ref将普通数据包装为响应式对象:

import { ref } from 'vue';

// 创建响应式数据
const message = ref('Hello Vue');
const todos = ref([
  { id: 1, content: '学习响应式' },
  { id: 2, content: '使用v-for' }
]);

// 修改数据(自动触发界面更新)
message.value = 'Hello Reactive';
todos.value.push({ id: 3, content: '掌握Vue' });

2. 模板语法(数据与界面绑定)

Vue 模板通过{{}}和指令实现数据与界面的自动关联:


  <div>
    
    <p>{{ message }}</p>
    
    
    <ul>
      <li>
        {{ todo.content }}
      </li>
    </ul>
  </div>

3. 完整 Vue 组件示例


  <div>
    <h2>{{ title }}</h2>
    <ul>
      <li>{{ item.name }}</li>
    </ul>
    添加项
  </div>



import { ref } from 'vue';

// 响应式数据
const title = ref('Vue响应式示例');
const list = ref([
  { id: 1, name: '第一项' },
  { id: 2, name: '第二项' }
]);

// 业务逻辑(只操作数据)
const addItem = () => {
  list.value.push({
    id: Date.now(),
    name: `新项${list.value.length + 1}`
  });
};

在 Vue 的响应式体系中,开发者只需关注数据变化,界面更新完全由框架自动处理。这种模式继承了后端模板 "数据驱动界面" 的思想,但通过前端响应式系统实现了更高效、更灵活的开发体验,彻底摆脱了手动 DOM 操作的困扰。

从后端模板到 Vue 响应式,界面开发的演进始终围绕一个核心:让开发者聚焦业务逻辑,而非界面更新细节。Vue 的响应式系统正是这一理念的完美实践。

从美团全栈化看 AI 冲击:前端转全栈,是自救还是必然 🤔🤔🤔

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

大厂日报 称,美团履约团队近期正在推行"全栈化"转型。据悉,终端组的部分前端同学在 11 月末左右转到了后端组做全栈(前后端代码一起写),主要是 agent 相关项目。内部打听了一下,团子目前全栈开发还相对靠谱,上线把控比较严格。

这一消息在技术圈引起了广泛关注,也反映了 AI 时代下前端工程师向全栈转型的必然趋势。但更重要的是,我们需要深入思考:AI 到底给前端带来了什么冲击?为什么前端转全栈成为了必然选择?

最近,前端圈里不断有"前端已死"的话语流出。有人说 AI 工具会替代前端开发,有人说低代码平台会让前端失业,还有人说前端工程师的价值正在快速下降。这些声音虽然有些极端,但确实反映了 AI 时代前端面临的真实挑战。

一、AI 对前端的冲击:挑战与机遇并存

1. 代码生成能力的冲击

冲击点:

  • 低复杂度页面生成:AI 工具(如 Claude Code、Cursor)已经能够快速生成常见的 UI 组件、页面布局
  • 重复性工作被替代:表单、列表、详情页等标准化页面,AI 生成效率远超人工
  • 学习门槛降低:新手借助 AI 也能快速产出基础代码,前端"入门红利"消失

影响: 传统前端开发中,大量时间花在"写页面"上。AI 的出现,让这部分工作变得极其高效,甚至可以说,只会写页面的前端工程师,价值正在快速下降。这也正是"前端已死"论调的主要依据之一。

2. 业务逻辑前移的冲击

冲击点:

  • AI Agent 项目激增:如美团案例中的 agent 相关项目,需要前后端一体化开发
  • 实时交互需求:AI 应用的流式响应、实时对话,要求前后端紧密配合
  • 数据流转复杂化:AI 模型调用、数据处理、状态管理,都需要全栈视角

影响: 纯前端工程师在 AI 项目中往往只能负责 UI 层,无法深入业务逻辑。而 AI 项目的核心价值在于业务逻辑和数据处理,这恰恰是后端能力。

3. 技术栈边界的模糊

冲击点:

  • 前后端一体化趋势:Next.js、Remix 等全栈框架兴起,前后端代码同仓库
  • Serverless 架构:边缘函数、API 路由,前端开发者需要理解后端逻辑
  • AI 服务集成:调用 AI API、处理流式数据、管理状态,都需要后端知识

影响: 前端和后端的边界正在消失。只会前端的前端工程师,在 AI 时代会发现自己"够不着"核心业务。

4. 职业发展的天花板

冲击点:

  • 技术深度要求:AI 项目需要理解数据流、算法逻辑、系统架构
  • 业务理解能力:全栈开发者能更好地理解业务全貌,做出技术决策
  • 团队协作效率:全栈开发者减少前后端沟通成本,提升交付效率

影响: 在 AI 时代,只会前端的前端工程师,职业天花板明显。而全栈开发者能够:

  • 独立负责完整功能模块
  • 深入理解业务逻辑
  • 在技术决策中发挥更大作用

二、为什么前端转全栈是必然选择?

1. AI 项目的本质需求

正如美团案例所示,AI 项目(特别是 Agent 项目)的特点:

  • 前后端代码一起写:业务逻辑复杂,需要前后端协同
  • 数据流处理:AI 模型的输入输出、流式响应处理
  • 状态管理复杂:对话状态、上下文管理、错误处理

这些需求,纯前端工程师无法独立完成,必须掌握后端能力。

2. 技术发展的趋势

  • 全栈框架普及:Next.js、Remix、SvelteKit 等,都在推动全栈开发
  • 边缘计算兴起:Cloudflare Workers、Vercel Edge Functions,前端需要写后端逻辑
  • 微前端 + 微服务:前后端一体化部署,降低系统复杂度

3. 市场需求的转变

  • 招聘要求变化:越来越多的岗位要求"全栈能力"
  • 项目交付效率:全栈开发者能独立交付功能,减少沟通成本
  • 技术决策能力:全栈开发者能更好地评估技术方案

三、后端技术栈的选择:Node.js、Python、Go

对于前端转全栈,后端技术栈的选择至关重要。不同技术栈有不同优势,需要根据项目需求选择。

1. Node.js + Nest.js:前端转全栈的最佳起点

优势:

  • 零语言切换:JavaScript/TypeScript 前后端通用
  • 生态统一:npm 包前后端共享,工具链一致
  • 学习成本低:利用现有技能,快速上手
  • AI 集成友好:LangChain.js、OpenAI SDK 等完善支持

适用场景:

  • Web 应用后端
  • 实时应用(WebSocket、SSE)
  • 微服务架构
  • AI Agent 项目(如美团案例)

学习路径:

  1. Node.js 基础(事件循环、模块系统)
  2. Nest.js 框架(模块化、依赖注入)
  3. 数据库集成(TypeORM、Prisma)
  4. AI 服务集成(OpenAI、流式处理)

2. Python + FastAPI:AI 项目的首选

优势:

  • AI 生态最完善:OpenAI、LangChain、LlamaIndex 等原生支持
  • 数据科学能力:NumPy、Pandas 等数据处理库
  • 快速开发:语法简洁,开发效率高
  • 模型部署:TensorFlow、PyTorch 等模型框架

适用场景:

  • AI/ML 项目
  • 数据分析后端
  • 科学计算服务
  • Agent 项目(需要复杂 AI 逻辑)

学习路径:

  1. Python 基础(语法、数据结构)
  2. FastAPI 框架(异步、类型提示)
  3. AI 库集成(OpenAI、LangChain)
  4. 数据处理(Pandas、NumPy)

3. Go:高性能场景的选择

优势:

  • 性能优秀:编译型语言,执行效率高
  • 并发能力强:Goroutine 并发模型
  • 部署简单:单文件部署,资源占用少
  • 云原生友好:Docker、Kubernetes 生态完善

适用场景:

  • 高并发服务
  • 微服务架构
  • 云原生应用
  • 性能敏感场景

学习路径:

  1. Go 基础(语法、并发模型)
  2. Web 框架(Gin、Echo)
  3. 数据库操作(GORM)
  4. 微服务开发

4. 技术栈选择建议

对于前端转全栈的开发者:

  1. 首选 Node.js:如果目标是快速转全栈,Node.js 是最佳选择

    • 学习成本最低
    • 前后端代码复用
    • 适合大多数 Web 应用
  2. 考虑 Python:如果专注 AI 项目

    • AI 生态最完善
    • 适合复杂 AI 逻辑
    • 数据科学能力
  3. 学习 Go:如果追求性能

    • 高并发场景
    • 微服务架构
    • 云原生应用

建议:

  • 第一阶段:选择 Node.js,快速转全栈
  • 第二阶段:根据项目需求,学习 Python 或 Go
  • 长期目标:掌握多种技术栈,根据场景选择

四、总结

AI 时代的到来,给前端带来了深刻冲击:

  1. 代码生成能力:低复杂度页面生成被 AI 替代
  2. 业务逻辑前移:AI 项目需要前后端一体化
  3. 技术边界模糊:前后端边界正在消失
  4. 职业天花板:只会前端的前端工程师,发展受限

前端转全栈,是 AI 时代的必然选择。

对于技术栈选择:

  • Node.js:前端转全栈的最佳起点,学习成本低
  • Python:AI 项目的首选,生态完善
  • Go:高性能场景的选择,云原生友好

正如美团的全栈化实践所示,全栈开发还相对靠谱,关键在于:

  • 选择合适的技术栈
  • 建立严格的开发流程
  • 持续学习和实践

对于前端开发者来说,AI 时代既是挑战,也是机遇。转全栈,不仅能应对 AI 冲击,更能打开职业发展的新空间。那些"前端已死"的声音,其实是在提醒我们:只有不断进化,才能在这个时代立足。

实现一个内网服务监测告警系统

ChatGPT Image 2025年12月9日 14_27_46

前言

昨天我的pve系统整个挂掉了,之前搭建的告警服务自然也死掉了,这就导致了我不能及时发现网站崩掉了,重启机器。

于是,我就把目光锁定到了家里的软路由上面,它是x86架构的,也安装了docker,我只需要用python写个脚本,做个docker服务即可。

功能设计

有了想法后,接下来需要先确定下要实现什么功能。

  • 定时检查:每 N 秒检查一次指定主机的指定端口
  • 自动告警:如果连续失败 N 次,就自动通过 QQ 邮箱发邮件通知
  • Docker / docker-compose 支持:一个 docker-compose up -d 就搞定,不需要在宿主机安装什么复杂依赖
  • 日志 + 时区:日志里记录访问时间 / 成功失败 / 告警状态,就算重启也能看到历史

实现过程

接下来就跟大家分享下我的具体实现过程。

  • 用 Python + smtp + socket,做一个循环脚本:
    • 尝试 TCP connect(检测端口)
    • 连不上就计数,超过阈值就发邮件
  • 用 Dockerfile 构建一个镜像,在里面安装 pingca-certificates ,配置时区,使得:
    • 容器里的时间符合预期
    • 脚本日志能实时输出,中断重启也方便查看
  • docker-compose 管理:使用的时候只需要填写环境变量(目标主机 + 端口 + 邮箱 + 授权码…),然后 docker-compose up -d 就能全自动运行。
import os
import smtplib
import time
import socket
from email.mime.text import MIMEText
from email.header import Header
from email.utils import formataddr

# 监控配置
TARGET_HOST = os.getenv("TARGET_HOST", "127.0.0.1")
TARGET_PORT = int(os.getenv("TARGET_PORT", "80"))
INTERVAL_SEC = int(os.getenv("INTERVAL_SEC", "60"))
FAIL_THRESHOLD = int(os.getenv("FAIL_THRESHOLD", "3"))

# 邮件配置(QQ 邮箱)
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.qq.com")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASS = os.getenv("SMTP_PASS", "")
MAIL_FROM = os.getenv("MAIL_FROM", SMTP_USER)
MAIL_TO = os.getenv("MAIL_TO", "")


def check_port(host: str, port: int, timeout=2) -> bool:
    """
    返回 True 表示端口可连接,False 表示失败
    """
    try:
        with socket.create_connection((host, port), timeout=timeout):
            return True
    except Exception:
        return False


def send_mail(subject: str, content: str):
    if not (SMTP_HOST and SMTP_USER and SMTP_PASS and MAIL_TO):
        print("SMTP 配置不完整,无法发送邮件")
        return

    from_addr = MAIL_FROM or SMTP_USER

    msg = MIMEText(content, "plain", "utf-8")
    msg["From"] = formataddr(("Ping告警系统", from_addr))
    msg["To"] = formataddr(("告警接收人", MAIL_TO))
    msg["Subject"] = Header(subject, "utf-8")

    print(f"【邮件】准备连接 SMTP: host={SMTP_HOST}, port={SMTP_PORT}, user={SMTP_USER}")

    server = None
    try:
        if SMTP_PORT == 465:
            print("【邮件】使用 SMTP_SSL 连接(465 端口)")
            server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=10)
        else:
            print("【邮件】使用 SMTP + STARTTLS 连接")
            server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=10)
            server.ehlo()
            server.starttls()
            server.ehlo()

        server.login(SMTP_USER, SMTP_PASS)
        # sendmail 如果不抛异常,就认为成功
        failed = server.sendmail(from_addr, [MAIL_TO], msg.as_string())
        if failed:
            print("【邮件】部分收件人发送失败:", failed)
        else:
            print("【邮件】告警邮件已发送(sendmail 返回正常)")

    except smtplib.SMTPResponseException as e:
        if e.smtp_code == -1 and e.smtp_error == b'\x00\x00\x00':
            print("【邮件】QQ 在 QUIT 阶段返回 (-1, b'\\x00\\x00\\x00'),可忽略,邮件已经入队。")
        else:
            print(f"【邮件】SMTPResponseException:code={e.smtp_code}, error={e.smtp_error}")
    except Exception as e:
        print(f"【邮件】发送失败:{repr(e)},类型:{type(e)}")
    finally:
        if server is not None:
            try:
                server.quit()
            except Exception as e:
                # 这里的异常直接吞掉即可
                print(f"【邮件】关闭连接时异常(可忽略):{repr(e)}")





def main():
    fail_count = 0
    print(
        f"开始监控 {TARGET_HOST}:{TARGET_PORT},每 {INTERVAL_SEC}s 检测一次,"
        f"连续失败 {FAIL_THRESHOLD} 次触发一次告警"
    )

    while True:
        now = time.strftime("%F %T")
        ok = check_port(TARGET_HOST, TARGET_PORT)

        if ok:
            print(f"{now} [OK]  {TARGET_HOST}:{TARGET_PORT} 端口可访问")
            if fail_count > 0:
                print(f"{now} 恢复正常,之前连续失败 {fail_count} 次,计数清零")
            fail_count = 0
        else:
            fail_count += 1
            print(f"{now} [FAIL] {TARGET_HOST}:{TARGET_PORT} 无法连接,连续失败次数:{fail_count}")

            if fail_count == FAIL_THRESHOLD:
                subject = f"[告警] {TARGET_HOST}:{TARGET_PORT} 无法访问"
                content = (
                    f"目标 {TARGET_HOST}:{TARGET_PORT} 已连续 {FAIL_THRESHOLD} 次连接失败。\n"
                    f"时间:{now}"
                )
                send_mail(subject, content)

        time.sleep(INTERVAL_SEC)


if __name__ == "__main__":
    main()

构建与上传镜像

编写DockerFile镜像文件

FROM python:3.11-slim

ENV TZ=Asia/Shanghai

WORKDIR /app

RUN apt-get update && \
    apt-get install -y iputils-ping ca-certificates tzdata && \
    ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && \
    echo $TZ > /etc/timezone && \
    update-ca-certificates && \
    rm -rf /var/lib/apt/lists/*

COPY ping_alert.py .

CMD ["python", "-u", "ping_alert.py"]

编写构建脚本

#!/usr/bin/env sh
set -e

# === 配置区:按需修改 ===
IMAGE_NAME="magiccoders/ping-alert" # magiccoders需要改成你的docker-hub的用户名
TAG="latest"
BUILD_CONTEXT="./app"
# =======================

echo "==> 构建镜像: ${IMAGE_NAME}:${TAG}"
docker build -t "${IMAGE_NAME}:${TAG}" "${BUILD_CONTEXT}"

echo "==> 推送镜像到仓库: ${IMAGE_NAME}:${TAG}"
docker push "${IMAGE_NAME}:${TAG}"

echo "==> 完成:${IMAGE_NAME}:${TAG} 已发布"

执行此脚本前,需要先在终端执行docker login 命令登录到你的docker-hub账户。

编写docker-compose配置

构建好镜像后,需要创建docker-compose.yml文件来编排这个镜像运行所需的环境变量。

version: '3.8'

services:
  ping-alert:
    image: magiccoders/ping-alert:latest # 此处就是存储在docker-hub上的镜像
    container_name: ping-alert
    restart: always
    environment:
      # ===== 监控目标配置 =====
      TARGET_HOST: "192.168.9.131" #监控目标机器ip
      TARGET_PORT: "80" # 目标机器端口号
      INTERVAL_SEC: "30"              # 每 30 秒检查一次
      FAIL_THRESHOLD: "3"             # 连续 3 次失败发一封告警邮件

      # ===== QQ 邮箱 SMTP 配置 =====
      SMTP_HOST: "smtp.qq.com"
      SMTP_PORT: "465"
      SMTP_USER: ""    # 你的 QQ 邮箱
      SMTP_PASS: ""  # 开通 SMTP 服务时得到的授权码
      MAIL_FROM: ""    # 和 SMTP_USER 保持一致
      MAIL_TO: ""  # 接受告警的邮箱

    # 直接复用宿主机网络,方便访问内网 IP
    network_mode: "host"

实现效果

我的软路由使用DPanel来管理docker,此处我就以它为例来讲解如何使用这个镜像。

如图所示,切换到compose选项卡,点击创建任务。

image-20251209144128674

在打开的面板中,填写标识、名称,以及刚才的docker-compose配置代码,按需更改里面的变量即可

image-20251209144606047

做完这些操作后,启动容器,查看日志,如果你的服务正常运行你就能看到如下所示的输出:

image-20251209144814577

我把端口关闭,再来验证下失败的情况。

image-20251209144954699

image-20251209145134325

邮箱也收到了邮件。

image-20251209152415652

最后,我启动服务,再来验证下他是否会清零计数。

image-20251209145400497

image-20251209145438229

项目地址

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

数据标注平台重磅上线 | 标注赚现金 低门槛真收益

68f71ddf171f8850e881f947f1cfb861.png

  • 你是否想亲身体验最前沿的 AI 数据工作,却找不到专业的参与路径?
  • 你是否希望让精通的编程技能,在业余时间创造实实在在的价值?

一个多领域的数据采集与标注服务平台,为你而来!

低门槛、真收益! 利用碎片时间,将你的代码洞察力直接变现

这不仅仅是一个任务平台,更是一个专为资深开发者打造的、用技术换取回报的高价值社区。

📒 招募介绍

我们是谁

AIDP(AI Data Platform) 提供完整的 AI 数据解决方案,涵盖:

  • 数据采集
  • 数据标注
  • 数据合成
  • 多种数据生产能力

需要你做什么

参与后训练高质量代码数据生产,包括但不限于:

任务类型 说明
强化学习数据生产 为 RLHF 训练提供高质量反馈数据
监督微调(SFT) 生产指令-响应对数据
奖励模型(RM) 对模型输出进行偏好排序和评分
数据质检 审核和优化已有数据质量

你将获得的回报

  • 💰现金收益:深度参与数据标注的代码专家,月均获取 ¥10000+ 以上回报
  • 时薪范围:50 - 1000 元,具体以项目最终定价为准,多劳多得,任务明码标价;
  • 计税方式:劳务独立计税,收益清晰透明。
  • 参与智能 Agent 调优,掌握前沿技术动态

需要具备的条件

  • 对 AI 有热情,日常高频使用 AI Coding 工具
  • 具备扎实的编程基础和代码理解能力

加分项

  • LeetCode Hard 题目高通过率
  • GitHub 高星仓库贡献者
  • 开源社区活跃成员

同时欢迎:PM、QA 同学参与,平台同时提供非技术类题目

👀 我们的目标用户

我们在寻找这样的你:

  • 技术栈:熟悉大前端、Python、Java、C++、Go 等主流编程语言
  • 质量意识:熟悉测试与质量保障流程
  • 探索精神:对前沿技术有自己的理解,乐于探索新知识
  • 社区参与:有开源贡献、LeetCode 活跃经历者优先

🚀你的专家任务

标注实验

对代码片段、日志、技术文档等进行精准标注

具体职责

  • 按照项目制定的标注规范,对指定数据(文本、代码片段、日志、技术文档等)进行准确标注
  • 识别并修正数据中存在的歧义、错误或不一致之处
  • 对复杂样本给出专业判断,确保标注结果的准确性与一致性

质量把控

互审他人成果,保障数据纯度

具体职责

  • 参与标注标准的制定与完善,提出改进意见
  • 执行专家互审机制,对其他标注结果进行审核和反馈
  • 结合实际经验,对标注样本的合理性和业务适配度进行评估

知识贡献

沉淀最佳实践,打造可复用的知识宝库

具体职责

  • 将个人专业知识转化为标注规范、最佳实践或案例,供团队参考
  • 在遇到疑难问题时,作为专家提供判定依据,形成可复用的知识库
  • 协助项目组优化任务分配、质量监控和流程设计

☎️ 社群交流

参加活动的掘友一定要入群:

  • 重要消息不错过
  • 大家互相鼓励
  • 有问题可以在群内咨询

134067c882fcc733a803fb9c71f0d2a6.png

⁉️ 常见问题 FAQ

Q1:没有标注经验可以参与吗?

我们需要你具备扎实的编程基础即可快速上手,所以需要你有一定的编程经验

Q2: 每天需要投入多少时间?

完全灵活,可以根据自己的时间安排参与,充分利用碎片时间。

Q3: 如何保证收益?

任务明码标价,多劳多得,平台提供清晰的计价规则和结算流程。

Q4: 非程序员可以参与吗?

可以。平台同时提供非技术类题目,欢迎 PM、QA 等岗位同学参与。

Node项目部署到阿里云服务器

一、服务器基础准备

1. 服务器配置

  • 购买云服务器(阿里云)
  • 选择适合的操作系统(CentOS 7.9)
  • 开放安全组端口:22(SSH)、80(HTTP)、443(HTTPS)、你的应用端口(如3000)

2. 本地项目准备

  • 确保项目在本地运行正常
  • 清理 node_modules 和测试文件

二、环境安装与配置

1. 安装Node.js

在Linux上部署Node.js,本文选择使用NVM(Node Version Manager)。与包管理器安装相比,NVM不受系统仓库版本限制,确保获取最新Node.js版本;与下载预编译二进制包相比,NVM省去了繁琐的环境变量配置;与从源代码编译安装相比,NVM大大缩短了安装时间,且对用户编译技能无要求。更重要的是,NVM支持多版本管理,方便切换,且安装的Node.js位于用户家目录,无需sudo权限,有效降低了安全风险。

  1. 安装分布式版本管理系统Git。

    Alibaba Cloud Linux 3/2、CentOS 7.x

    sudo yum install git -y
    
  2. 使用Git将NVM的源码克隆到本地的~/.nvm目录下,并检查最新版本。

    git clone https://gitee.com/mirrors/nvm.git ~/.nvm && cd ~/.nvm && git checkout `git describe --abbrev=0 --tags`
    
  3. 依次运行以下命令,配置NVM的环境变量。

    sudo sh -c 'echo ". ~/.nvm/nvm.sh" >> /etc/profile'
    source /etc/profile
    
  4. 运行以下命令,修改npm镜像源为阿里云镜像,以加快Node.js下载速度。

    export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
    
  5. 运行以下命令,查看Node.js版本。

    nvm list-remote
    
  6. 安装多个Node.js版本。

    Alibaba Cloud Linux 2 和 CentOS 7.x 仅支持 Node.js 17.x 及以下版本,例如需要安装 v17.9.1,则执行nvm install v17.9.1。

    nvm install v17.9.1
    

    image.png

    image.png

  7. 查看已安装的Node.js版本。

    nvm ls
    

    返回结果如下所示,表示当前已安装v22.11.0、v23.3.0两个版本,正在使用的是v22.11.0版本。

    image.png

  8. 切换版本

    您可以使用nvm use <版本号>命令切换Node.js的版本。 例如,切换至Node.js v23.3.0版本的命令为nvm use v23.3.0。

2. 安装PM2(进程管理)

sudo npm install -g pm2

3. 安装Nginx(反向代理)

sudo yum install -y nginx

# 启动Nginx
sudo systemctl start nginx
sudo systemctl enable nginx #开机自动启动

三、部署Node.js项目

假设有两个项目:

  • 项目1:端口3000,子域名api1.example.com
  • 项目2:端口4000,子域名api2.example.com

1. 上传项目代码

# 创建项目目录
mkdir -p /var/www/api1 /var/www/api2

# 上传代码(使用git)

cd /var/www/api1
git clone your-repo-url
npm install

cd /var/www/api2
git clone your-second-repo-url
npm install

2. 使用PM2启动项目

# 启动项目1
cd /var/www/api1
pm2 start app.js --name "api1" -i max --watch

# 启动项目2
cd /var/www/api2
pm2 start app.js --name "api2" -i max --watch

# 保存PM2配置
pm2 save

# 设置PM2开机启动
pm2 startup
# 执行输出的命令(会显示类似下面的命令)
# sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup centos -u root --hp /root

四、配置子域名和Nginx反向代理

1. 域名解析准备

在阿里云DNS解析控制台添加子域名解析:

  • api1.example.com → 服务器IP
  • api2.example.com → 服务器IP

2. 配置Nginx反向代理

为项目1创建配置 (api1.example.com)

server {
    listen 80;
    server_name api1.example.com;

    location / {
        proxy_pass http://localhost:3000;  # 假设项目1运行在3000端口
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

为项目2创建配置 (api2.example.com)

server {
    listen 80;
    server_name api2.example.com;

    location / {
        proxy_pass http://localhost:4000;  # 假设项目2运行在4000端口
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

3. 测试并重启Nginx

nginx -t
systemctl restart nginx

五、维护与监控

1. PM2常用命令

pm2 list          # 查看所有应用
pm2 restart api1  # 重启特定应用
pm2 stop api2     # 停止应用
pm2 delete api1   # 删除应用

2. Nginx常用命令

systemctl restart nginx  # 重启Nginx
nginx -t                # 测试配置
journalctl -u nginx     # 查看Nginx系统日志

3. 查看服务器资源

top
htop
df -h
free -m

通过以上步骤,你可以在CentOS 7.9服务器上部署多个Node.js项目,并通过不同的子域名访问它们。每个项目都运行在独立的端口上,通过Nginx反向代理和PM2进程管理实现稳定运行。

图文并茂-手把手教宝子们3分钟用 GitHub Pages 搭建免费网站 (保姆级教程)

@[TOC]( 图文并茂手把手教宝子们3分钟用 GitHub Pages 搭建你的专属网站 保姆级教程)

宝子们又来看我啦~欢迎!👋

是不是一直想拥有一个属于自己的网页?放放作品集、写写碎碎念,或者单纯用来分享一个个人网站? 但是一听到“服务器”、“域名”、“部署”这些词就头大,而且还不想花钱?💸

来来来,今天教大家用 GitHub Pages “白嫖”一个静态网站!不需要你是程序猿,只要会点鼠标,三分钟就能搞定!✨


🌟 第一步:准备好你的 GitHub 账号

首先,你要有一个 GitHub 账号。 如果你还没有,去 github.com 注册一个,起个好听的英文名哦! image


🌟 第二步:创建一个特殊的仓库 (Repository),配置网站

登录后,点击右上角的 + 号,选择 New repository

image

确保选中 Public (公开),然后点击最下面的绿色按钮 Create repository。搞定!✅ image


🌟 第三步:把你的网站放上去哦

现在仓库是空的,我们需要放一个网页进去。 为了演示方便,我们直接在网页上操作:

  1. 点击蓝色的链接 creating a new file
  2. 文件名填 index.html (这是网页的“大门”)。
  3. 在下面的大框框里,随便写点什么!比如:

4.点击 Commit ... 保存。

image

image

<!DOCTYPE html>
<html lang="zh">
<head>
 <meta charset="UTF-8">
 <title>WebGL 手势控制爱心粒子</title>
 <style>
     body { margin: 0; overflow: hidden; background-color: #000; }
     #canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; }
     /* 隐藏摄像头视频流,只用于后台分析 */
     .input_video { display: none; }
     #loading {
         position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
         color: white; font-family: sans-serif; font-size: 24px; pointer-events: none; z-index: 10;
     }
 </style>
 <!-- 引入 Three.js -->
 <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
 <!-- 引入 MediaPipe Hands -->
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
</head>
<body>

<div id="loading">正在加载模型与摄像头...<br>请允许摄像头权限</div>
<div id="canvas-container"></div>
<video class="input_video"></video>

<script>
 // --- 1. Three.js 场景初始化 ---
 const scene = new THREE.Scene();
 const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
 camera.position.z = 30;

 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
 renderer.setSize(window.innerWidth, window.innerHeight);
 document.getElementById('canvas-container').appendChild(renderer.domElement);

 // --- 2. 创建爱心粒子系统 ---
 const particleCount = 3000; // 粒子数量
 const geometry = new THREE.BufferGeometry();
 const positions = new Float32Array(particleCount * 3);
 const targetPositions = new Float32Array(particleCount * 3); // 存储爱心形状的目标位置
 const randomPositions = new Float32Array(particleCount * 3); // 存储散开时的随机位置

 // 爱心方程函数
 function getHeartPosition(t, scale = 1) {
     const x = 16 * Math.pow(Math.sin(t), 3);
     const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
     const z = 0; 
     return { x: x * scale, y: y * scale, z: z * scale };
 }

 for (let i = 0; i < particleCount; i++) {
     // 生成爱心形状的目标点
     // 为了让爱心立体一点,我们随机分布t,并在Z轴加一点随机扰动
     const t = Math.random() * Math.PI * 2;
     const scale = 0.5; 
     const heartPos = getHeartPosition(t, scale);
     
     // 填充爱心内部 (随机缩放)
     const r = Math.sqrt(Math.random()); 
     
     targetPositions[i * 3] = heartPos.x * r;
     targetPositions[i * 3 + 1] = heartPos.y * r;
     targetPositions[i * 3 + 2] = (Math.random() - 0.5) * 5; // Z轴厚度

     // 生成散开的随机位置 (爆炸效果)
     randomPositions[i * 3] = (Math.random() - 0.5) * 100;
     randomPositions[i * 3 + 1] = (Math.random() - 0.5) * 60;
     randomPositions[i * 3 + 2] = (Math.random() - 0.5) * 50;

     // 初始位置设为散开状态
     positions[i * 3] = randomPositions[i * 3];
     positions[i * 3 + 1] = randomPositions[i * 3 + 1];
     positions[i * 3 + 2] = randomPositions[i * 3 + 2];
 }

 geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

 // 粒子材质
 const material = new THREE.PointsMaterial({
     color: 0xff69b4, // 热粉色
     size: 0.4,
     transparent: true,
     opacity: 0.8,
     blending: THREE.AdditiveBlending
 });

 const particles = new THREE.Points(geometry, material);
 scene.add(particles);

 // --- 3. 交互逻辑变量 ---
 let gatherFactor = 0; // 0 = 完全散开, 1 = 完全聚合成爱心
 let targetGatherFactor = 0; // 目标聚合度,由手势控制

 // --- 4. MediaPipe Hands 配置 ---
 const videoElement = document.getElementsByClassName('input_video')[0];

 function onResults(results) {
     document.getElementById('loading').style.display = 'none';

     if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
         const landmarks = results.multiHandLandmarks[0];

         // 计算手掌开合程度
         // 简单算法:计算拇指指尖(4)与其他四指指尖(8,12,16,20)到手腕(0)的平均距离
         const wrist = landmarks[0];
         const fingerTips = [4, 8, 12, 16, 20];
         let totalDist = 0;

         fingerTips.forEach(idx => {
             const tip = landmarks[idx];
             const dist = Math.sqrt(
                 Math.pow(tip.x - wrist.x, 2) + 
                 Math.pow(tip.y - wrist.y, 2)
             );
             totalDist += dist;
         });

         const avgDist = totalDist / 5;

         // 经验阈值:
         // 握拳时,指尖距离手腕很近 (avgDist 约 0.1 - 0.2)
         // 张开时,指尖距离手腕较远 (avgDist 约 0.4 - 0.6)
         // 我们做一个映射:握拳(distance small) -> 聚合(factor 1), 张开 -> 散开(factor 0)
         
         // 动态调整这些阈值以适应摄像头的距离
         const closeThreshold = 0.25; 
         const openThreshold = 0.5;

         let normalized = (avgDist - closeThreshold) / (openThreshold - closeThreshold);
         normalized = 1 - Math.min(Math.max(normalized, 0), 1); // 反转:距离越小(握拳),值越大(1)

         targetGatherFactor = normalized; 

     } else {
         // 如果没有检测到手,默认缓慢散开
         targetGatherFactor = 0;
     }
 }

 const hands = new Hands({locateFile: (file) => {
     return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
 }});

 hands.setOptions({
     maxNumHands: 1,
     modelComplexity: 1,
     minDetectionConfidence: 0.5,
     minTrackingConfidence: 0.5
 });

 hands.onResults(onResults);

 const cameraUtils = new Camera(videoElement, {
     onFrame: async () => {
         await hands.send({image: videoElement});
     },
     width: 640,
     height: 480
 });
 cameraUtils.start();

 // --- 5. 动画循环 ---
 function animate() {
     requestAnimationFrame(animate);

     // 粒子自身旋转动画
     particles.rotation.y += 0.002;

     // 平滑过渡聚合系数 (Lerp)
     gatherFactor += (targetGatherFactor - gatherFactor) * 0.05;

     // 更新粒子位置
     const posAttr = particles.geometry.attributes.position;
     const currentPositions = posAttr.array;

     for (let i = 0; i < particleCount; i++) {
         const idx = i * 3;
         
         // 目标位置插值:从 randomPositions 过渡到 targetPositions
         const tx = randomPositions[idx] + (targetPositions[idx] - randomPositions[idx]) * gatherFactor;
         const ty = randomPositions[idx+1] + (targetPositions[idx+1] - randomPositions[idx+1]) * gatherFactor;
         const tz = randomPositions[idx+2] + (targetPositions[idx+2] - randomPositions[idx+2]) * gatherFactor;

         // 增加一点动态浮动效果
         currentPositions[idx] += (tx - currentPositions[idx]) * 0.1;
         currentPositions[idx+1] += (ty - currentPositions[idx+1]) * 0.1;
         currentPositions[idx+2] += (tz - currentPositions[idx+2]) * 0.1;
     }

     posAttr.needsUpdate = true;
     renderer.render(scene, camera);
 }

 animate();

 // 窗口大小调整适配
 window.addEventListener('resize', () => {
     camera.aspect = window.innerWidth / window.innerHeight;
     camera.updateProjectionMatrix();
     renderer.setSize(window.innerWidth, window.innerHeight);
 });
</script>
</body>
</html>


🌟 第四步:配置GitHub Pages

  • 在GitHub仓库页面:
    1. 点击"Settings"选项卡
    1. 在左侧菜单中选择"Pages"
    1. 在"Source"部分,选择要部署的分支(通常是main)
    1. 选择根目录(/root)或文档目录(/docs)
    1. 点击"Save"
    1. 等待几分钟,网站将部署完成 这里是最最关键的一步!⚠️ imageimage

🌟 第五步:见证奇迹的时刻!🎉

保存好之后,其实 GitHub 已经在后台偷偷帮你部署啦! 稍等几十秒(有时候可能要一两分钟,喝口水的时间~ ☕️)。

查看搭建状态

点击仓库上方的 Actions -> 可以查看你的网站搭建状态

imageimageimage

查看网站

点击仓库上方的 Settings (设置) ⚙️ -> 左侧栏找到 Pages

恭喜你!!点击那个链接,你就看到了你刚刚写的网页啦!🌏 不管你在地球的哪个角落,只要有网,都能访问这个链接! imageimage


💖 Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~~~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

image

前端Docker多平台构建自动化实践

本文介绍如何使用 Node.js + pnpm + Docker Buildx 构建一个功能完善的多平台镜像构建工具,实现自动版本管理、镜像仓库集成等企业级功能。

背景与挑战

业务背景

在现代前端工程化实践中,容器化部署已成为标准配置。然而,随着 Apple Silicon(ARM64 架构)的普及和云原生技术的发展,我们面临着新的挑战:

  1. 多架构支持:需要同时支持 x86_64 (amd64) 和 ARM64 架构
  2. 版本管理混乱:手动管理版本号容易出错,与镜像仓库不同步
  3. 构建流程复杂:前端构建 → Docker 构建 → 推送仓库,步骤繁琐
  4. CI/CD 集成困难:不同环境配置不一致,难以标准化

痛点分析

传统的 Docker 构建流程存在以下问题:

# 传统流程 - 步骤繁琐,容易出错
pnpm install
pnpm run build:pro
docker build -t my-app:3.14.0 .
docker tag my-app:3.14.0 registry.example.com/my-app:3.14.0
docker push registry.example.com/my-app:3.14.0
docker tag my-app:3.14.0 registry.example.com/my-app:latest
docker push registry.example.com/my-app:latest

# 多平台构建更加复杂
docker buildx build --platform linux/amd64,linux/arm64 \
  -t registry.example.com/my-app:3.14.0 \
  --push .

主要痛点:

  • ❌ 版本号需要手动维护,容易遗忘更新
  • ❌ 多平台构建命令冗长,参数复杂
  • ❌ 需要手动登录 Docker 仓库
  • ❌ 构建失败时难以定位问题
  • ❌ 无法自动检测远程版本,可能覆盖已有镜像

技术选型

核心技术栈

技术 版本 用途
Node.js 20.19.0+ 运行时环境
pnpm 10.23.0+ 包管理器
Docker 19.03+ 容器引擎
Docker Buildx Latest 多平台构建
consola 3.4.2 日志输出

为什么选择 Node.js?

  1. 与前端项目无缝集成:可以直接读取 package.json,调用 pnpm 命令
  2. 跨平台支持:在 macOS、Linux、Windows 上都能运行
  3. 生态丰富:有大量成熟的工具库
  4. 团队熟悉:前端团队无需学习新语言

为什么选择 Docker Buildx?

Docker Buildx 是 Docker 官方提供的多平台构建工具,具有以下优势:

  • ✅ 原生支持多架构构建
  • ✅ 自动处理交叉编译
  • ✅ 支持构建缓存,提升速度
  • ✅ 可以直接推送到镜像仓库

系统架构设计

整体架构

┌─────────────────────────────────────────────────────────────────┐
│                        用户交互层                                 │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 命令行参数    │  │  环境变量     │  │  配置文件     │          │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘          │
│         └─────────────────┼─────────────────┘                   │
└───────────────────────────┼─────────────────────────────────────┘
                            ▼
┌─────────────────────────────────────────────────────────────────┐
│                      主控制器 (DockerBuilder)                    │
│  - 流程编排                                                      │
│  - 错误处理                                                      │
│  - 日志输出                                                      │
└───────────────────────────┬─────────────────────────────────────┘
                            │
        ┌───────────────────┼───────────────────┐
        │                   │                   │
        ▼                   ▼                   ▼
┌───────────────┐  ┌───────────────┐  ┌───────────────┐
│  配置管理      │  │  版本管理      │  │  仓库管理      │
│ BuildConfig   │  │VersionManager │  │DockerRegistry │
└───────────────┘  └───────────────┘  └───────────────┘

模块职责

1. 主控制器 (DockerBuilder)

负责整个构建流程的编排和控制:

class DockerBuilder {
  async build() {
    // 1. 环境检查
    if (!this.checkDocker()) return false
    if (!this.checkNodeEnv()) return false
    
    // 2. 前端构建
    if (!await this.buildFrontend()) return false
    
    // 3. Docker 登录
    if (!await this.dockerLogin()) return false
    
    // 4. 版本管理
    const version = await this.getVersion()
    
    // 5. 镜像构建
    await this.buildMultiPlatform(version)
    
    return true
  }
}

2. 配置管理 (BuildConfig)

实现多源配置合并,优先级清晰:

class BuildConfig {
  constructor(options = {}) {
    // 配置优先级: 命令行参数 > 环境变量 > 配置文件 > 默认值
    this.registry = options.registry
      || process.env.DOCKER_REGISTRY
      || fileConfig.registry
      || 'registry.cn-shanghai.aliyuncs.com'
  }
}

3. 版本管理 (VersionManager)

实现智能版本号管理:

class VersionManager {
  incrementVersion(currentVersion, remoteVersion) {
    // 比较当前版本和远程版本,取较大值
    const baseVersion = this.compareVersion(currentVersion, remoteVersion) >= 0
      ? currentVersion
      : remoteVersion
    
    // 自动递增 patch 版本
    const ver = this.parseVersion(baseVersion)
    ver.patch += 1
    
    return `${ver.major}.${ver.minor}.${ver.patch}`
  }
}

4. 仓库管理 (DockerRegistry)

处理 Docker 仓库的登录和查询:

class DockerRegistry {
  async login() {
    // 使用 --password-stdin 保证安全
    const command = `echo "${this.config.password}" | docker login -u "${this.config.username}" --password-stdin "${this.config.registry}"`
    this.exec(command, { silent: true })
  }
  
  async getLatestVersion() {
    // 查询镜像仓库最新版本
    const versions = await this.getVersionsFromManifest(imageName)
    return this.findLatestVersion(versions)
  }
}

工作流程

开始
  │
  ▼
解析命令行参数 ─────► 加载配置文件 ─────► 合并环境变量
  │
  ▼
检查 Docker 环境 ────────────────┐
  │                              │ 失败
  ▼                              ▼
检查 Node.js 环境 ──────────────► 输出错误 ──► 退出
  │                              ▲
  ▼                              │
构建前端项目 (pnpm)  ─────────────┤
  │                              │
  ▼                              │
Docker 登录 ──────────────────────┤
  │                              │
  ▼                              │
获取版本号                         │
  ├─ 读取 package.json           │
  ├─ 查询远程版本                 │
  ├─ 比较版本                     │
  ├─ 自动递增                     │
  └─ 更新 package.json           │
  │                              │
  ▼                              │
构建多平台镜像 ────────────────────┤
  │                              │
  ▼                              │
推送到仓库 ────────────────────────┤
  │                              │
  ▼                              │
显示结果                           │
  │                              │
  ▼                              │
成功 ◄─────────────────────────────┘

核心功能实现

1. 多平台构建

使用 Docker Buildx 实现多平台构建:

async buildMultiPlatform(version) {
  const fullImageName = `${this.config.registry}/${this.config.namespace}/${this.config.imageName}:${version}`
  
  const args = [
    'buildx',
    'build',
    '--platform',
    this.config.platforms,  // linux/amd64,linux/arm64
    '--tag',
    fullImageName,
  ]
  
  // 添加 latest 标签
  if (this.config.pushImage) {
    args.push('--tag', `${this.config.registry}/${this.config.namespace}/${this.config.imageName}:latest`)
    args.push('--push')
  }
  
  args.push('--file', 'Dockerfile')
  args.push('.')
  
  await this.execStream('docker', args)
}

关键点:

  • 使用 --platform 参数指定多个平台
  • 同时打上版本号和 latest 标签
  • 使用 --push 直接推送到仓库(多平台镜像无法加载到本地)

2. 自动版本管理

实现智能版本号管理的核心逻辑:

async getVersion() {
  // 1. 获取 package.json 版本
  const packageVersion = this.versionManager.getPackageVersion()
  consola.info(`package.json 版本: ${packageVersion}`)
  
  // 2. 获取远程版本
  if (this.config.autoIncrement) {
    const remoteVersion = await this.dockerRegistry.getLatestVersion()
    if (remoteVersion) {
      consola.info(`镜像仓库最新版本: ${remoteVersion}`)
      
      // 3. 比较并自增版本
      const newVersion = this.versionManager.incrementVersion(
        packageVersion,
        remoteVersion
      )
      consola.success(`新版本号: ${newVersion}`)
      
      // 4. 更新 package.json
      if (this.config.updatePackageJson) {
        this.versionManager.updatePackageVersion(newVersion)
      }
      
      return newVersion
    }
  }
  
  // 5. 使用 package.json 版本
  return this.config.version || packageVersion
}

版本比较算法:

compareVersion(v1, v2) {
  const ver1 = this.parseVersion(v1)  // { major: 3, minor: 14, patch: 0 }
  const ver2 = this.parseVersion(v2)  // { major: 3, minor: 14, patch: 5 }
  
  // 比较顺序: major > minor > patch
  if (ver1.major !== ver2.major) {
    return ver1.major > ver2.major ? 1 : -1
  }
  if (ver1.minor !== ver2.minor) {
    return ver1.minor > ver2.minor ? 1 : -1
  }
  if (ver1.patch !== ver2.patch) {
    return ver1.patch > ver2.patch ? 1 : -1
  }
  return 0
}

版本递增策略:

incrementVersion(currentVersion, remoteVersion, type = 'patch') {
  // 取较大的版本作为基准
  const baseVersion = this.compareVersion(currentVersion, remoteVersion) >= 0
    ? currentVersion
    : remoteVersion
  
  const ver = this.parseVersion(baseVersion)
  
  switch (type) {
    case 'major':
      ver.major += 1
      ver.minor = 0
      ver.patch = 0
      break
    case 'minor':
      ver.minor += 1
      ver.patch = 0
      break
    case 'patch':
    default:
      ver.patch += 1
      break
  }
  
  return `${ver.major}.${ver.minor}.${ver.patch}`
}

3. 前端项目自动构建

集成前端构建流程:

async buildFrontend() {
  if (this.config.skipBuild) {
    consola.info('跳过前端构建 (SKIP_BUILD=true)')
    return true
  }
  
  consola.start('开始构建前端项目...')
  
  try {
    // 1. 清理旧的构建产物
    const distPath = join(this.config.projectRoot, 'dist')
    if (existsSync(distPath)) {
      consola.info('清理旧的构建产物...')
      this.exec(`rm -rf ${distPath}`)
    }
    
    // 2. 安装依赖
    consola.info('安装依赖...')
    await this.execStream('pnpm', ['install'])
    
    // 3. 构建生产版本
    consola.info('构建生产版本...')
    await this.execStream('pnpm', ['run', 'build:pro'])
    
    // 4. 检查构建产物
    if (!existsSync(distPath)) {
      throw new Error('构建失败: dist 目录不存在')
    }
    
    consola.success('前端构建完成')
    return true
  }
  catch (error) {
    consola.error('前端构建失败:', error.message)
    return false
  }
}

4. Docker 仓库自动登录

安全地处理 Docker 登录:

async login() {
  consola.start(`正在登录 Docker 仓库: ${this.config.registry}`)
  
  if (!this.config.username || !this.config.password) {
    consola.error('Docker 用户名或密码未配置')
    return false
  }
  
  try {
    // 使用 --password-stdin 避免密码出现在命令行历史中
    const command = `echo "${this.config.password}" | docker login -u "${this.config.username}" --password-stdin "${this.config.registry}"`
    this.exec(command, { silent: true })
    
    consola.success(`Docker 仓库登录成功: ${this.config.registry}`)
    return true
  }
  catch (error) {
    consola.error(`Docker 仓库登录失败: ${this.config.registry}`)
    return false
  }
}

安全要点:

  • ✅ 使用 --password-stdin 传递密码
  • ✅ 密码不出现在命令行历史
  • ✅ 支持从环境变量读取
  • ✅ 日志中不显示敏感信息

5. 配置系统设计

实现灵活的多源配置:

class BuildConfig {
  constructor(options = {}) {
    // 加载配置文件
    const fileConfig = this.loadConfigFile()
    
    // 合并配置 (优先级: 命令行 > 环境变量 > 配置文件 > 默认值)
    this.registry = options.registry
      || process.env.DOCKER_REGISTRY
      || fileConfig.registry
      || 'registry.cn-shanghai.aliyuncs.com'
    
    this.namespace = options.namespace
      || process.env.DOCKER_NAMESPACE
      || fileConfig.namespace
      || 'zhangjian_sh'
    
    // 布尔值配置需要特殊处理
    this.pushImage = this.parseBoolean(
      options.pushImage,
      process.env.PUSH_IMAGE,
      fileConfig.pushImage,
      false  // 默认值
    )
  }
  
  // 解析布尔值
  parseBoolean(...values) {
    for (const value of values) {
      if (value === true || value === false) {
        return value
      }
      if (typeof value === 'string') {
        const lower = value.toLowerCase()
        if (lower === 'true' || lower === '1' || lower === 'yes') {
          return true
        }
        if (lower === 'false' || lower === '0' || lower === 'no') {
          return false
        }
      }
    }
    return false
  }
}

配置文件示例:

{
  "registry": "rregistry.example.com",
  "namespace": "your-namespace",
  "imageName": "your-imageName",
  "version": null,
  "platforms": "linux/amd64,linux/arm64",
  "pushImage": false,
  "skipBuild": false,
  "autoIncrement": true,
  "updatePackageJson": true,
  "autoLogin": true
}

6. 日志系统

使用 consola 实现美观的日志输出:

import consola from 'consola'

// 不同级别的日志
consola.start('检查 Docker 环境...')      // ⏳ 开始执行
consola.success('Docker 检查通过')        // ✓ 成功信息
consola.info('Node.js 版本: v20.19.0')   // ℹ 提示信息
consola.warn('无法获取远程版本')          // ⚠ 警告信息
consola.error('Docker 未运行')           // ✖ 错误信息

// 显示结果框
consola.box({
  title: '🎉 构建完成',
  message: [
    `镜像名称: ${imageInfo.fullImageName}`,
    `版本号: ${imageInfo.version}`,
    `支持平台: ${this.config.platforms}`,
    '',
    '✅ 镜像已推送到仓库'
  ].join('\n'),
  style: {
    borderColor: 'green',
    borderStyle: 'round'
  }
})

输出效果:

[INFO] 🚀 开始 Docker 多平台构建...

[START] 检查 Docker 环境...
[SUCCESS] Docker 检查通过

[START] 获取版本信息...
[INFO] package.json 版本: 3.14.0
[INFO] 镜像仓库最新版本: 3.14.5
[SUCCESS] 新版本号: 3.14.6

╭─────────────────────────────────────────╮
│  🎉 构建完成                             │
│                                         │
│  镜像名称: registry.cn-shanghai...      │
│  版本号: 3.14.6                         │
│  支持平台: linux/amd64,linux/arm64      │
│                                         │
│  ✅ 镜像已推送到仓库                     │
╰─────────────────────────────────────────╯

[SUCCESS] ✨ 构建完成!耗时: 45.23s

最佳实践

1. 使用方式

基础用法

# 构建镜像(不推送)
node buildx/index.js

# 构建并推送
node buildx/index.js --push

# 跳过前端构建
node buildx/index.js --skip-build --push

集成到 package.json

{
  "scripts": {
    "docker:build": "node buildx/index.js",
    "docker:build:push": "node buildx/index.js --push",
    "docker:build:skip": "node buildx/index.js --skip-build --push"
  }
}

然后使用:

pnpm run docker:build:push

2. 版本管理策略

策略 1: 完全自动(推荐)

# 自动查询远程版本,自动递增,自动更新 package.json
pnpm run docker:build:push

适用场景: 日常开发,小版本迭代

策略 2: 手动指定版本

# 发布大版本时手动指定
node buildx/index.js --version 4.0.0 --no-auto-increment --push

适用场景: 重大版本发布

策略 3: 禁用更新 package.json

# 自动递增但不更新 package.json
node buildx/index.js --no-update-package --push

适用场景: 测试环境,不想修改源码

3. CI/CD 集成

GitLab CI/CD

# .gitlab-ci.yml
stages:
  - build

build-docker:
  stage: build
  image: node:20
  services:
    - docker:dind
  before_script:
    - npm install -g pnpm
    - pnpm install
  script:
    - export DOCKER_USERNAME=$CI_REGISTRY_USER
    - export DOCKER_PASSWORD=$CI_REGISTRY_PASSWORD
    - export PUSH_IMAGE=true
    - pnpm run docker:build:push
  only:
    - main
    - tags

GitHub Actions

# .github/workflows/docker-build.yml
name: Docker Build and Push

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Install pnpm
        run: npm install -g pnpm
      
      - name: Build and push
        env:
          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
          DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
          PUSH_IMAGE: true
        run: pnpm run docker:build:push

4. 安全建议

不要硬编码密码

// ❌ 错误做法
const password = 'my-password'

// ✅ 正确做法
const password = process.env.DOCKER_PASSWORD

使用 .gitignore

# .gitignore
buildx/config.json
.env

使用环境变量

# 开发环境
export DOCKER_USERNAME="your-username"
export DOCKER_PASSWORD="your-password"

# 或使用 .env 文件
echo "DOCKER_USERNAME=your-username" >> .env
echo "DOCKER_PASSWORD=your-password" >> .env

5. 故障排查

问题 1: Docker Buildx 不可用

# 检查 Docker 版本
docker version

# 创建 builder
docker buildx create --name multi-platform-builder --use
docker buildx inspect --bootstrap

问题 2: 前端构建失败

# 清理依赖重新安装
rm -rf node_modules pnpm-lock.yaml
pnpm install

# 手动构建测试
pnpm run build:pro

问题 3: 无法推送镜像

# 手动登录测试
docker login registry.cn-shanghai.aliyuncs.com

# 检查镜像名称
docker images | grep your-image

性能优化

1. 构建缓存

Docker Buildx 自动利用层缓存:

# Dockerfile 优化示例
FROM node:20-alpine AS builder

# 先复制依赖文件(变化少)
COPY package.json pnpm-lock.yaml ./
RUN pnpm install

# 再复制源码(变化多)
COPY . .
RUN pnpm run build:pro

# 生产镜像
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html

优化效果:

  • 首次构建:~60 秒
  • 依赖未变化:~10 秒
  • 代码变化:~20 秒

2. 跳过前端构建

已构建时可跳过:

# 完整构建
pnpm run docker:build:push  # ~60 秒

# 跳过前端构建
pnpm run docker:build:skip  # ~20 秒

3. 单平台构建

开发环境只构建单平台:

# 仅构建 amd64(更快)
node buildx/index.js --platform linux/amd64 --push  # ~30 秒

# 生产环境构建多平台
node buildx/index.js --platform linux/amd64,linux/arm64 --push  # ~50 秒

4. 并行构建

Docker Buildx 自动并行构建多平台:

linux/amd64 ─┐
             ├──► 并行执行
linux/arm64 ─┘

性能对比:

  • 单平台:~30 秒
  • 双平台串行:~60 秒
  • 双平台并行:~40 秒(节省 33%)

项目结构

buildx/
├── index.js                    # 主入口文件
├── lib/
│   ├── config.js              # 配置管理
│   ├── version-manager.js     # 版本管理
│   └── docker-registry.js     # 仓库管理
├── config.example.json        # 配置文件示例
├── package.json               # 依赖配置
├── test.js                    # 测试脚本
├── README.md                  # 使用文档
├── QUICKSTART.md              # 快速开始
├── ARCHITECTURE.md            # 架构设计
├── FEATURES.md                # 功能特性
├── EXAMPLES.md                # 使用示例
└── CHANGELOG.md               # 更新日志

技术亮点

1. 模块化设计

采用面向对象设计,职责清晰:

DockerBuilder (主控制器)
  ├── BuildConfig (配置管理)
  ├── VersionManager (版本管理)
  └── DockerRegistry (仓库管理)

每个模块独立可测试,易于维护和扩展。

2. 智能版本管理

自动对比本地和远程版本,避免版本冲突:

package.json: 3.14.0
远程仓库: 3.14.5
─────────────────
取较大值: 3.14.5
自动递增: 3.14.6

3. 多源配置

支持命令行、环境变量、配置文件,优先级清晰:

命令行参数 > 环境变量 > 配置文件 > 默认值

4. 完善的错误处理

每个步骤都有错误检测和友好提示:

try {
  await this.buildFrontend()
}
catch (error) {
  consola.error('前端构建失败:', error.message)
  consola.info('请检查: pnpm install && pnpm run build:pro')
  return false
}

5. 流式输出

使用 spawn 实现实时日志输出:

async execStream(command, args = []) {
  return new Promise((resolve, reject) => {
    const child = spawn(command, args, {
      stdio: 'inherit',  // 实时输出
      shell: true
    })
    
    child.on('close', (code) => {
      code === 0 ? resolve() : reject()
    })
  })
}

实际效果

构建时间对比

场景 传统方式 自动化工具 提升
首次构建 ~90 秒 ~60 秒 33% ↑
增量构建 ~60 秒 ~20 秒 67% ↑
跳过前端 ~30 秒 ~15 秒 50% ↑

操作步骤对比

操作 传统方式 自动化工具
命令数量 8+ 条 1 条
版本管理 手动 自动
登录仓库 手动 自动
错误处理 完善

团队效率提升

  • ✅ 减少 80% 的手动操作
  • ✅ 避免 100% 的版本冲突
  • ✅ 降低 90% 的构建错误
  • ✅ 提升 50% 的构建速度

总结与展望

核心价值

  1. 自动化:一条命令完成所有操作
  2. 智能化:自动版本管理,避免冲突
  3. 标准化:统一构建流程,降低门槛
  4. 可靠性:完善的错误处理和日志
  5. 高效率:利用缓存,提升构建速度

适用场景

  • ✅ 前端项目容器化部署
  • ✅ 多架构支持(x86 + ARM)
  • ✅ CI/CD 自动化构建
  • ✅ 多环境部署管理
  • ✅ 团队协作开发

未来展望

短期计划

  1. 支持更多镜像仓库

    • Harbor
    • Docker Hub
    • 腾讯云 TCR
    • AWS ECR
  2. 增强版本管理

    • 支持 alpha、beta、rc 版本
    • 支持 Git Tag 自动生成版本
    • 支持版本回滚
  3. 构建通知

    • 钉钉通知
    • 企业微信通知
    • 邮件通知

长期规划

  1. 插件系统

    builder.use(new NotificationPlugin())
    builder.use(new ReportPlugin())
    builder.use(new MetricsPlugin())
    
  2. Web 控制台

    • 可视化构建管理
    • 实时日志查看
    • 构建历史记录
  3. 分布式构建

    • 支持多节点并行构建
    • 构建任务队列
    • 资源调度优化

参考资料


💡 提示:本文介绍的工具已在生产环境稳定运行,处理了数千次构建任务。如果你也在寻找一个高效的 Docker 构建解决方案,不妨试试这个工具!

【你可能不知道的开发技巧】一行代码完成小程序的CloudBase鉴权登录

登录鉴权基本概念

登录功能=登录页UI+登录逻辑+会话管理

登录的本质是让小程序知道用户是谁。

3.jpg

一行代码完成CloudBase的鉴权登录,初始化云开发环境即可调用云开发能力。

基于微信原生API的自动鉴权机制,调用云开发服务时系统自动识别当前用户openid并完成身份验证,省去繁琐的手动获取openid步骤。

4.jpg5.jpg

功能优势

  1. 用户行为追踪: 便于分析未注册用户行为数据
  2. 降低用户使用门槛: 提升转化率和用户体验

其他三种常见登录方式

  • 账号密码登录
  • 短信验证码登录
  • 邮箱验证码登录

相比于微信原生方式,CloudBase方便在哪?

  • 无需从零开始构建用户认证系统,CloudBase提供了完整的认证流程。
  • 无缝集成CloudBase资源,安全性有保障。
  • 无需自行维护复杂的登录态token。
  • CloudBase支持自定义登录,业务扩展后能平滑迁移。

基于AI的质量风险管控

导读

线上问题复盘发现质量保障存在测试召回、有效性及排查止损时效性不足等痛点,根源在于保障对象多样演进、线上问题处置复杂。为此我们构建质量风险管控系统,本文分别从风险管理系统的构建思想&实践、风险感知系统的AI效果提升、风险控制系统的智能化建设等维度展开介绍,整体风险管控系统在构建过程效果、使用效果和质量结果等层面均取得较好效果。未来,AI将更深度参与质量风险管控过程,与人工协同构建更智能化的风险管控体系。

01 背景

在线上问题的复盘中,我们总结出质量保障的三大痛点

(1)问题测试召回/感知能力的完备性不足:测试能力缺失导致问题漏检、监控报警缺失导致问题发现滞后;

(2)问题测试召回/感知能力的有效性不足:测试工具不稳定导致测试结果失真、报警配置不合理导致误报/漏报;

(3)问题排查与止损的时效性不足:线上问题定位能力缺失、定位止损慢、止损链路长,导致影响范围扩大。

究其根本,源于以下挑战:

(1)质量保障对象多样、海量且持续演进:我们面对数以万计至百万级的质量保障对象(如服务模块、词表、业务对象等),每类对象对应不同的质量风险与保障策略。同时,这些对象本身还在不断变化,要求质量保障方案具备动态适应能力——即实现对质量保障对象的完整、动态、高效识别与控制,确保在合适的阶段选用最优的质量保障策略组合,以召回潜在风险。

(2)线上问题处置复杂、动态且高度关联:线上系统面临大量动态风险(如变更、数据波动、流量与资源变动等),这些因素持续冲击系统稳定性。因此,我们亟需构建不依赖人、完备且高效的问题感知机制,并打造体系化、智能化的定位与止损能力,从而快速分析线索、实施干预,降低线上问题带来的损失。

为应对上述挑战,我们构建了质量风险管控系统(RMCS),该系统由三部分组成:风险管理系统(RMS-Risk Manage System)-前置消除风险、风险感知系统(ROS-Risk Observe System)-中期发现问题、风险控制系统(RCS-Risk Control System)-后置控制损失。

02 AI的质量风险管控方案

经过多年发展,伴随着AI的发展强大,质量风险管控经过起步阶段、发展阶段的建设积累,已经发展到关键的转型阶段:基于AI的质量风险管控阶段,我们普遍并深入的使用AI能力来解决质量风险管理全流程的问题,提升质量管控的效果和ROI。

图片

△ 基于AI的质量风险管控整体架构

领域知识:把丰富的知识从各类入口、平台、配置以及人脑转移到标准的软件知识图谱中,以结构化知识和非结构化规范知识进行组织,按需转化为实体和关系,从而构建RMCS的丰富、标准、开放的知识图谱生态,实现海量信息的标准化、共享化存储。

RMCS核心能力

  • RMS Agent (AI风险管理):以 AI 为核心,打造具备 “感知 - 决策 - 执行 - 反思” 能力的智能质量风险管理系统,实现 “应拦尽拦”。RMS以开放策略生态思路,灵活管理 “对象质量能力、质量能力风险处置策略”,实现对不同刻画对象能力现状的刻画,驱动质量能力提升,最终通过风险管理应用平台,实现数据、策略、刻画、闭环等环节的统一产品管理。

  • ROS  Agent(AI报警管理):依托领域知识,打造风险实时观测与降噪能力,实现 “应报尽报”。ROS涵盖知识建设、监控创建、维护、评估、降噪及报警跟进等多个环节,覆盖风险管理(如前置监控完备性建设)与控制(如报警有效性、感知后跟进处置)两个阶段,是问题发现后的主要感知手段。

  • RCS  Agent(AI值班人):融合领域模型与领域知识,打造端到端 AI 值班人,具备自主 / 协同式的智能定位与处置能力,实现 “应快尽快”。RCS围绕问题发生到止损全环节,构建报警分类导诊、排查定位、止损等多个环节的智能化控制能力,实现对问题整体损失预期控制,托管全流程风险控制过程。

03 基于AI的质量风险管控核心能力介绍

3.1 RMS Agent (AI做风险管理)

传统质量建设过程的核心痛点包括质量能力缺失、质量能力退化等反复出现的问题,面对庞大且持续变化的质量主题和持续发展的质量保障能力,需要构建不依赖于人刻画和前置风险识别,风险管理系统RMS就是为了解决这种前置风险而产生的, RMS以知识图谱为基础,对质量保障『主体』上全生命周期『质量保障能力』进行持续的合理性风险评估、分发和处理流程管理,牵引『主体』的『质量保障能力』持续发挥预期价值,达到将风险约束在适宜位置/阶段的目的,最终实现3个根本性转变:

  • 从“人治”到“数治”: 将风险管控从依赖专家个人经验和重复劳动的模式,转变为基于全域数据和AI模型进行系统性、自动化治理的模式。

  • 从“孤立”到“协同”: 打破各业务线、各质量阶段之间的信息壁垒,通过统一的风险语言和协作流程,实现跨域风险的联动防控。

  • 从“被动响应”到“主动预防”: 从事后补救的“救火队”模式,转向事中干预、事前预测的“预警机”模式,将风险尽可能约束在萌芽或早期阶段。

RMS核心关注的四大核心痛点和解决思路:

(1)“经验壁垒”与“人力瓶颈”问题: 风险识别、评估、决策高度依赖少数专家的个人经验,难以规模化、标准化和传承,RMS 将专家经验沉淀为可计算、可复用的知识图谱和AI策略模型,让系统具备“专家级”的风险认知和判断能力。

(2)“信息孤岛”与“认知局限”问题:业务系统、质量数据、保障能力等信息分散在不同部门,缺乏全局视角,RMS 通过构建覆盖“主体-对象-能力”的完备知识图谱,打通数据孤岛,形成统一的、相互关联的风险全景视图。。

(3)“响应滞后”与“漏反复”问题: 传统人工巡检和评审方式,风险发现不及时,处理周期长且可能陷入“发现问题-修复-再次发生”的恶性循环,RMS实现7x24小时的自动化风险扫描与监测,并通过策略闭环确保风险被有效分发和处理,防止复发。

(4)“成本高昂”与“灵活性不足”问题: 为每个业务线定制化搭建风控体系成本高、周期长,业务变化时,风控策略难以快速调整,无法适应敏捷开发和快速迭代的需求,RMS 通过中台化、组件化(拼装、插拔式)的架构,提供通用能力的同时,允许业务方低成本、高效率地自定义风控流程和策略,实现“开箱即用”与“灵活定制”的平衡。

RMS旨在从模式上成本上效果上重塑质量风险管理过程,****打破业务间壁垒,最大化降低业务质量经营成本。****整体方案依托软件知识图谱,以一站式质量经营为导向,构建包括实体对象管理、质量能力管理、风险策略管理、风险观测、风险分发处置等通用能力。标准能力支持业务自主拼装、插拔式使用,实现风险从认知到闭环的全流程管理。支持各种质量角色的参与,协同以达到持续提升质量经营水平的目的。

下面是RMS提供的部分核心能力展示,目前RMS接入实体106万,覆盖实体类型115类,建设能力项394个,累计发现风险16万+,并完成了91.46%的风险闭环,直接支撑业务风险前置挖掘召回和闭环。

image.png 基于多实体关系的大事件运营

image.png

风险智能闭环工作台

3.2 ROS  Agent(AI做报警管理)

监控报警建设核心要解决报警完备性、有效性两个问题,即一旦异常发生时,需覆盖全位置、全指标异常并有效感知,同时对异常引发的多维、重复、关联报警进行降噪,减少报警信号的流转干扰。

为此,ROS重点构建了报警自主生成&运维报警智能降噪能力来解决报警完备性和有效性问题。本文从通用逻辑阐述 AI 监控管理方案。

图片

为达到完备和有效的目标,需重点解决以下四大问题:

(1)如何做到完备的覆盖:构建完备的系统与业务知识,抽象所有监控对象并构建不同监控对象关系,结合监控基础知识与大模型,生成完善的监控覆盖方案,其中需要重点关注业务监控基础知识差异,同时使用影响范围、对象分层等作为输入进行方案构建。

(2)如何做到监控项智能生成:依据监控对象、关系、基础知识、数据 / 业务特征及经验,生成含监控对象、策略、关联参数、通知方式等的多维度复杂监控项参数,这里结合时序模型、大模型来综合判断,最终结合监控平台能力完成监控项的生成;监控生成分为完全自主生成(适用于场景明确、准确度高的场景)与协同式生成(需人工确认,用于初始阶段或准确度不足时),两种方式适合于不同成熟度的场景使用。

(3)如何做到异常智能识别:通过规则、时序模型、大模型、动态阈值等机制,判断数据或用例结果是否为问题,不同的监控平台、监控对象、数据特征、业务特征适合不同类型的异常检测策略。

(4)如何进行智能降噪:分析单个报警 、关联报警、多个报警的异常特征、关系及盯盘情况来综合判断是否需要进行报警通知,并结合风险程度、影响范围、时效性等解决无效打扰、报警淹没等问题,平衡质效。

下面是典型的业务&监控平台提供的能力示例如下,通过上述关键问题的解决,结合底层完备/准确的知识构建和场景化的应用产品,监控召回率保持90%+,报警生成比例78%,部分业务监控降噪比例已达到60%。

image.png

报警生成示例

image.png

切流导致的报警降噪(绿色点不通知)示例

3.3 RCS  Agent(AI值班人)

风险控制系统主要解决报警后跟进及时性、排查准确性与效率问题,通过快速找到有效止损线索并止损缩小影响,将问题损失控制在最小范围,会面临以下几个关键问题:

(1)匹配最优跟进人 / 方案:如何结合问题影响面、跟进代价与时效性,明确 AI 或真人跟进的成本与效果。

(2)提供排查线索与止损预案:如何依据业务经验、变更信息、系统知识、历史故障等,匹配最契合排查链路/工具找到正确的线索并从预案库筛选最优止损方案,实现快速止损。

(3)解决跟进过程信息与人员混乱:针对多角色、多团队参与的线上处置场景,尤其长链路业务信息差问题,需要构建端到端事件管理机制,确保及时找对负责人、同步信息,减少干扰与维护成本。

为了解决上述问题,构建了一套统一的RCS建设方案,可实现基于AI的全方位风险控制能力。

图片

方案中有几个关键部分,展开介绍如下:

(1)问题导诊:报警后快速明确风险影响面、跟进方(AI or 真人),提供智能排查结论,按业务特点构建导诊策略(如影响面、风险对象、业务类别等),实现差异化问题处置通路。

(2)端到端事件管理:搭建事件管理产品,覆盖事件感知、建群、排查、止损、总结、跟踪全生命周期,提供流程管理、信息互通等核心能力,同时完成事件信息的统一中心化存储,实现 MEG 线上事件标准化管理。

(3)AI值班人自主处置(常见于慢损问题):对影响小、暂无需真人介入的问题,AI 通过定位工具调度、对话分析、人员地图等能力,完成初步分析、变更确认、标注等工作,确认是线上问题后再转真人跟进。自主处置AI值班人的目标是自主完成问题处置,所以需要建设完善的定位工具调度、单对单对话、自然语言分析、人员地图能力,并能够实现拟人化的信息确认和自主分析。

(4)AI值班人引导处置(常见于快损问题):快损问题需真人与 AI 协同,AI 以助手身份提供线索推荐、工具推荐、止损操作推荐、事件盯盘等支持,且可动态调整策略(如根据损失预估切换止损方式),触达正确人员快速判断,快损事件的关键目标是快速止损,所以无论是触达效率、有损止损动作选择权衡等均需要以综合损失最小快速止损为目标。

(5)高危事件管控中心:针对业务与系统关联复杂的情况,构建全局管控中心与 MEG 高危事件 AI 值班人,与各业务 AI 值班人协同,实现事件信息、工具、线索互通,避免因信息差延误止损。

通过持续的能力建设和数字化构建,线上问题的智能定位覆盖率和准确率稳步增长,同时为了解决问题损失(等级)和MTTR的耦合关系,构建了基于损失速度分桶的损失控制达标率指标,该达标率同样持续提升至93%。AI值班人开始持续在风险控制过程中发挥作用,AI值班人协助率达到96%,端到端协率完成协助率达到40%。

04 总结&展望

随着RMCS能力的建设,质量结果得到了非常有效的控制(如下图)。

图片

(1)从线上问题数量上看,线上问题总数逐年降低,25年对比22年降低比例超过53%,说明我们具备了将问题前置拦截通过风险呼唤前置解决的能力。

(2)从线上问题等级上看,严重问题数量也在持续降低,说明我们具备了快速问题感知和控制的能力,将高损问题转化为低损问题。

展望

目前质量风险管控已经发展了AI转型的重要时期,已经从使用AI解决工具问题变化为使用面向AI构建知识、产品,AI从辅助人慢慢的开始在更多场景可以替代人,因人的投入限制质量保障工作的限制会逐步被突破,质量风险管控后续也可能会变成人和AI更深度协同分析的局面,AI发挥自我学习、24h oncall、智能化的特长完成绝大部份的风险管控,正式员工发挥知识构建、训练AI并构建符合AI的管控产品,最终协同构建更智能化的风险管控目标。

项目级效能提升一站式交付最佳实践

导读

面对研发交付中Feature级项目复杂度攀升、信息分散及跨端协作低效等痛点,传统的Story级管理模式已显乏力。本文详细阐述了一套“项目级效能提升一站式交付最佳实践”,通过构建三大核心体系重塑研发流程:一是通过AI侧边栏与风险管控打造“AI项目管理”,实现信息聚合与决策提效;二是推动“一站式Feature交付”,利用AI自动生成测试方案与搭建环境,实现端到端闭环;三是建立涵盖“重点战役-Feature-Story”的三级数字化度量体系。这套新范式旨在以智能替代人工低效环节,助力团队从“被流程束缚”向“借智能破局”转变,实现研发效能的质的飞跃。

01 背景

在研发交付场景中,Story级别效率持续向好,但端到端 Feature 级项目持续增多,复杂度呈上升趋势。在传统工作模式下,研发团队正遭遇一系列制约效能的痛点:

  • 信息分散、Feature视角管控缺失:研发过程中,需求卡片、Bug列表、测试用例等信息分散于不同空间;以及历史交付流程侧重 story 侧,缺少完整需求视角交付的风险洞察,导致项目无预警延期,管理成本趋增。

  • 多方协作效率低下:联调测试依赖手动触发脚本,环境配置依赖人工协调;多模块联动存在用例交叉冗余、测试评审缓慢;跨产品线借调沟通成本高,各端测试人力重复投入且耦合重,拖慢研发节奏,项目进度风险激增 。

  • Feature 级数字化能力缺失:既缺乏能够精准衡量 Feature 价值与进展的指标度量体系,也缺乏用于支撑分析、优化的标准化数据积累流程,导致 Feature 相关的评估无据可依,数据资产难以沉淀,进一步制约效能提升与问题根因定位。

为有效破解以上痛点,提升产研团队整体效能,我们构建项目级交付新范式:通过 “ AI 项目管理” 打造一站式项目信息与工具服务获取新入口,实现过程风险AI精准管控;通过 “一站式 Feature 交付” 推动单端联调模式向端到端交付模式转变,同时借助 AI 测试提效驱动交付效能跃升;通过 “项目级效能数字化度量” 构建完善的效能评估体系。

  • AI项目管理:借助群聊侧边栏打造一站式项目信息看板,提升风险感知与决策效率;支持产研团队一键获取所需工具、服务,提升工具使用与协同效率;依托AIQA构建全流程管控能力并提效。

  • 一站式Feature交付:通过建设端到端交付能力,释放冗余人力,并将测试方案生成、基础环境搭建等场景与AIQA深度融合,为 Feature 全生命周期提供高效、智能的一站式支撑。

  • 项目级效能数字化度量:构建涵盖重点战役、Feature、Story 三个层级的更全面、立体的洞察体系,借助数据驱动的力量,全方位、深层次评估分析产品开发过程的效能表现与最终成果,为业务决策提供坚实有力数据支撑。

以智能替代人工低效环节,推动团队从 “被流程束缚” 向 “借智能破局” 转变,为效能提升注入新动能,引领团队协作进入智能化新范式,让每一次交付都更高效、更可控!

图片

02 AI项目管理

2.1 项目管理痛点

在研发交付体系中,Feature级项目持续增多,复杂度呈上升趋势,团队项目管理普遍面临四大核心痛点:

  • 信息碎片化带来高昂的把控成本

  • 跨角色协作过程产生的高额隐性成本

  • 分散工具链造成执行效率损耗

  • 项目交付进展、风险纯依赖人工通知确认,交付周期长尾

为破解上述痛点,我们构建融合 “侧边栏 + AIQA过程管控” 的 AI 项目管理体系,实现信息整合、协作提效、工具聚合与交付管控闭环。

2.2 侧边栏应用

侧边栏统一解决方案,通过配置侧边栏框架,灵活定制卡片,以满足业务线各类场景应用,通常涵盖项目概览项目详情工具集合三大核心模块。

  • 项目概览模块:以 PMO 视角为核心,深度整合项目进度、资源占用、风险预警等维度数据,支撑 PMO 实现精准的项目群管控与决策穿透 。

  • 项目详情模块:聚焦产研团队执行视角,整合项目关联卡片、各类文档、bug 记录、上线记录、实验记录及测试环境等信息,保障研发高效协同与质量可控。

  • 工具集合模块:提供了项目管理、联调提效等工具,可根据角色、产品线、项目,提供不同的工具入口,推动研发端到端衔接与操作链路简化。

通过具象化的配置实践,可在实际业务场景中落地,助力各角色高效协作,破解产研团队项目管理痛点 。以下实践案例,包括:项目概览项目详情工具集三大类内容,且支持PC和手机端样式,以及群吊顶和侧边栏位置。

image.png

2.3 过程管控

在项目各环节,AIQA以PMO数字助手的角色,建设基于AIQA的项目进度/风险提醒能力,通过输入框形式进行交互,全流程管控并提效。具体能力包括:AB实验长时间停留、AB实验放量实时、技术方案&打点方案等未评审风险等

  • 项目评审风险提醒:支持技术方案、UI、实验方案、打点方案等提醒

  • AB实验:待启动实验、实验中、评估中等9个阶段的停留时长;单台、放量、灰度等7个阶段的放量提醒

  • 项目进度风险提醒

03 一站式Feature交付

3.1 端到端交付

产研需求常常是包括客户端、前端、服务端,两端以上联动的需求占比较高(>40%),各端分别投入人力测试,测试耦合严重,人力重复投入严重,通过建设交付能力,支撑实现需求测试从「单端测试+多端联调」交付模式,转为「端到端」交付模式,释放冗余人力

例如客户端&服务端联动的需求,通过从功能角度评估客户端case对服务端case覆盖,覆盖的部分由客户端QA端到端测试,未覆盖部分通过夯实自动化能力补充测试或调起服务端QA补充测试,并通过准入/准出能力评估影响面和测试完备度,保证项目质量,释放冗余人力,提升整体测试吞吐。

各阶段支撑能力建设有:

  • 测试前:通过端到端模式识别判断需求是否可以进行端到端的交付,动态自动分配测试人力,基于需求生成端到端测试用例,前端&服务端的环境自动搭建/更新,创建相应的mock环境等

  • 测试中基于代码提交进行风险洞察,以及提供问题定位、mock 能力供前端/客户端的同学更顺畅的完成测试过程,并且在过程中对测试流量进行录制,实时采集覆盖率,供后续测试评估;并在测试过程中阶段性评估测试进度风险,及时发现&通报过程中风险;

  • 测试准出:测试完成后,自动触发服务端的异常测试自动生成后续用于回归的自动化case,以保障服务端的迭代质量,整体完成后根据手工case完成率、bug闭环率、手工测试覆盖率、异常测试结果等多个维度综合进行测试准出评估

图片

3.2 AI测试提效

3.2.1 测试方案自动生成

我们探索测试方案自动生成,目前可以通过分析MRD/PRD/CR,结合历史业务&工具知识经验,自动生成完整的测试方案,包括:

  • 对需求的基本洞察理解(背景概述、分系统/模块升级点)

  • 联调测试范围(涉及系统/模块等)作为环境推荐能力的输入

  • 联调测试用例及可驱动AI测试执行的相关场景参数

  • 测试经验&风险、历史相似项目参考

图片

3.2.2 测试环境LUI搭建

建设LUI交互端到端环境部署能力,支持产研团队各业务线及图搜联调场景调用,端到端环境部署时长保持在小时级

图片

功能点:

  • 聚合用户的部署意图,支持多种prompt,完成LUI环境部署诉求:支持Feature卡片、QA单模块产物、单story卡片、联调CR、Fcnap域名等多种部署意图,聚合部署变更的信息

  • 提供丰富的prompt向量库维护,实现精准的意图识别:prompt维护

  • 基于qs-bot openmanus和mcp框架,实现丰富的工具集,通过动态规划调度工具交付各场景下的端到端测试环境:需求变更信息处理,数据聚合;触发多路复用环境部署;kirin异步轮询部署状态;qsbot回调重新触发动态规划;环境前后端链接与配置管理

  • 聚合部署任务中的工具使用历史,异步回调完成环境部署完成后的智卡推送:聚合单次LUI中动态规划的工具返回信息;完成智卡数据构造并发卡推送给用户

04 项目级效能数字化度量

针对 Story 维度的度量工作,主要聚焦于对研发测试阶段展开持续且深入的洞察,旨在为从事研发测试工作的人员提供切实可行的优化方向与手段。但story维度的度量也存在如下问题:

  • Story 粒度本身存在一定局限性,呈现研发-测试阶段的效率洞察,向左延伸的需求设计阶段,向右延伸的上线实验转全阶段,都不涵括在内,无法代表整体情况;

  • 随着产品复杂程度日益提高,传统以 Story 维度为主的度量方式,无法站在产品需求视角观测交付效率,已难以满足需求;

  • 站在每个组织的重点战役角度,story度量的方式,无法看到战役视角的资源投入和效率瓶颈,无法提供深层次的分析和决策。

为此我们开始建设贯穿重点战役-》feature-》Story的统一三级视角的数字化方案:

图片

4.1 Feature级数字化

围绕从最初的发布规划、设计构思、开发实施、测试验证、正式上线,直至后续的小流量到逐步推全的整个生命周期,实施全方位、系统性的评估与监测,确保 Feature 的每个阶段都能得到有效把控与持续优化。最终实现更加高效地管理和优化产品开发流程,以及进一步提升团队协作效率。

图片

4.2 战役级数字化

重点战役是企业为实现特定战略目标而发起的关键行动,往往需要通过一系列具体的Feature来实现其目标。

协同机制:重点战役的范围在季度初由PMO圈定,在业务拆解功能时在Feature上打上标记,数字化定期采集和处理分析,支持业务进行重点战役的进度监控和项目复盘。

图片

05 总结与展望

立足项目级一站式交付实践,AI 原生思维在研发领域的价值将向更深、更广维度延伸:

  • 从能力深化看,AI 将实现从 “被动响应需求” 到 “主动预测需求” 的升级,通过持续学习团队协作数据、项目交付规律,可提前预判研发各阶段的核心需求,预警潜在风险,让智能服务更具前瞻性

  • 从场景拓展看,AI 与高频协作场景的融合将突破群聊边界,向需求评审、代码联调、线上问题排查等全研发链路渗透,构建 “全场景覆盖、全流程智能” 的研发协同体系,让智能能力随研发动作自然触发

  • 从生态构建看,基于侧边栏的 AI 驱动协作模式,可进一步打通跨团队、跨业务线的数据与工具壁垒,形成可复用、可迭代的研发效能提升方案,推动 AI 原生思维从单一团队实践,升级为业务级研发协同标准

最终,AI 将不再仅是 “提效工具”,更将成为研发团队创新的 “核心引擎”,通过持续解放人工低效环节,让团队聚焦于技术攻坚、产品创新等核心工作,推动研发领域从 “效能提升” 向 “价值创造” 跨越,开启智能化研发的全新阶段。

Dragonboat统一存储LogDB实现分析|得物技术

一、项目概览

Dragonboat 是纯 Go 实现的(multi-group)Raft 库。

为应用屏蔽 Raft 复杂性,提供易于使用的 NodeHost 和状态机接口。该库(自称)有如下特点:

  • 高吞吐、流水线化、批处理;
  • 提供了内存/磁盘状态机多种实现;
  • 提供了 ReadIndex、成员变更、Leader转移等管理端API;
  • 默认使用 Pebble 作为 存储后端。

本次代码串讲以V3的稳定版本为基础,不包括GitHub上v4版本内容。

二、整体架构

三、LogDB 统一存储

LogDB 模块是 Dragonboat 的核心持久化存储层,虽然模块名字有Log,但是它囊括了所有和存储相关的API,负责管理 Raft 协议的所有持久化数据,包括:

Raft状态 (RaftState)

Raft内部状态变更的集合结构

包括但不限于:

  • ClusterID/NodeID: 节点ID
  • RaftState: Raft任期、投票情况、commit进度
  • EntriesToSave:Raft提案日志数据
  • Snapshot:快照元数据(包括快照文件路径,快照大小,快照对应的提案Index,快照对应的Raft任期等信息)
  • Messages:发给其他节点的Raft消息
  • ReadyToReads:ReadIndex就绪的请求

引导信息 (Bootstrap)

type Bootstrap struct {
    Addresses map[uint64]string // 初始集群成员
    Join      bool
    Type      StateMachineType
}

ILogDB的API如下:

type ILogDB interface {


    BinaryFormat() uint32 // 返回支持的二进制格式版本号


    ListNodeInfo() ([]NodeInfo, error) // 列出 LogDB 中所有可用的节点信息


    // 存储集群节点的初始化配置信息,包括是否加入集群、状态机类型等
    SaveBootstrapInfo(clusterID uint64, nodeID uint64, bootstrap pb.Bootstrap) error


    // 获取保存的引导信息
    GetBootstrapInfo(clusterID uint64, nodeID uint64) (pb.Bootstrap, error)


    // 原子性保存 Raft 状态、日志条目和快照元数据
    SaveRaftState(updates []pb.Update, shardID uint64) error


    // 迭代读取指定范围内的连续日志条目
    IterateEntries(ents []pb.Entry, size uint64, clusterID uint64, nodeID uint64, 
                   low uint64, high uint64, maxSize uint64) ([]pb.Entry, uint64, error)


    // 读取持久化的 Raft 状态
    ReadRaftState(clusterID uint64, nodeID uint64, lastIndex uint64) (RaftState, error)


    // 删除指定索引之前的所有条目, 日志压缩、快照后清理旧日志
    RemoveEntriesTo(clusterID uint64, nodeID uint64, index uint64) error


    // 回收指定索引之前条目占用的存储空间
    CompactEntriesTo(clusterID uint64, nodeID uint64, index uint64) (<-chan struct{}, error)


    // 保存所有快照元数据
    SaveSnapshots([]pb.Update) error


    // 删除指定的快照元数据 清理过时或无效的快照
    DeleteSnapshot(clusterID uint64, nodeID uint64, index uint64) error


    // 列出指定索引范围内的可用快照
    ListSnapshots(clusterID uint64, nodeID uint64, index uint64) ([]pb.Snapshot, error)


    // 删除节点的所有相关数据
    RemoveNodeData(clusterID uint64, nodeID uint64) error


    // 导入快照并创建所有必需的元数据
    ImportSnapshot(snapshot pb.Snapshot, nodeID uint64) error
}

3.1索引键

存储的底层本质是一个KVDB (pebble or rocksdb),由于业务的复杂性,要统一各类业务key的设计方法,而且要降低空间使用,所以有了如下的key设计方案。

龙舟中key分为3类:

其中,2字节的header用于区分各类不同业务的key空间。

entryKeyHeader       = [2]byte{0x10x1}  // 普通日志条目
persistentStateKey   = [2]byte{0x20x2}  // Raft状态
maxIndexKey          = [2]byte{0x30x3}  // 最大索引记录
nodeInfoKey          = [2]byte{0x40x4}  // 节点元数据
bootstrapKey         = [2]byte{0x50x5}  // 启动配置
snapshotKey          = [2]byte{0x60x6}  // 快照索引
entryBatchKey        = [2]byte{0x70x7}  // 批量日志

在key的生成中,采用了useAsXXXKey和SetXXXKey的方式,复用了data这个二进制变量,减少GC。

type Key struct {
    data []byte  // 底层字节数组复用池
    key  []byte  // 有效数据切片
    pool *sync.Pool // 似乎并没有什么用
}




func (k *Key) useAsEntryKey() {
    k.key = k.data
}


type IReusableKey interface {
    SetEntryBatchKey(clusterID uint64, nodeID uint64, index uint64)
    // SetEntryKey sets the key to be an entry key for the specified Raft node
    // with the specified entry index.
    SetEntryKey(clusterID uint64, nodeID uint64, index uint64)
    // SetStateKey sets the key to be an persistent state key suitable
    // for the specified Raft cluster node.
    SetStateKey(clusterID uint64, nodeID uint64)
    // SetMaxIndexKey sets the key to be the max possible index key for the
    // specified Raft cluster node.
    SetMaxIndexKey(clusterID uint64, nodeID uint64)
    // Key returns the underlying byte slice of the key.
    Key() []byte
    // Release releases the key instance so it can be reused in the future.
    Release()
}


func (k *Key) useAsEntryKey() {
    k.key = k.data
}


// SetEntryKey sets the key value to the specified entry key.
func (k *Key) SetEntryKey(clusterID uint64, nodeID uint64, index uint64) {
    k.useAsEntryKey()
    k.key[0] = entryKeyHeader[0]
    k.key[1] = entryKeyHeader[1]
    k.key[2]0
    k.key[3]0
    binary.BigEndian.PutUint64(k.key[4:], clusterID)
    // the 8 bytes node ID is actually not required in the key. it is stored as
    // an extra safenet - we don't know what we don't know, it is used as extra
    // protection between different node instances when things get ugly.
    // the wasted 8 bytes per entry is not a big deal - storing the index is
    // wasteful as well.
    binary.BigEndian.PutUint64(k.key[12:], nodeID)
    binary.BigEndian.PutUint64(k.key[20:], index)
}

3.2变量复用IContext

IContext的核心设计目的是实现并发安全的内存复用机制。在高并发场景下,频繁的内存分配和释放会造成较大的GC压力,通过IContext可以实现:

  • 键对象复用:通过GetKey()获取可重用的IReusableKey
  • 缓冲区复用:通过GetValueBuffer()获取可重用的字节缓冲区
  • 批量操作对象复用:EntryBatch和WriteBatch的复用
// IContext is the per thread context used in the logdb module.
// IContext is expected to contain a list of reusable keys and byte
// slices that are owned per thread so they can be safely reused by the
// same thread when accessing ILogDB.
type IContext interface {
    // Destroy destroys the IContext instance.
    Destroy()
    // Reset resets the IContext instance, all previous returned keys and
    // buffers will be put back to the IContext instance and be ready to
    // be used for the next iteration.
    Reset()
    // GetKey returns a reusable key.
    GetKey() IReusableKey // 这就是上文中的key接口
    // GetValueBuffer returns a byte buffer with at least sz bytes in length.
    GetValueBuffer(sz uint64) []byte
    // GetWriteBatch returns a write batch or transaction instance.
    GetWriteBatch() interface{}
    // SetWriteBatch adds the write batch to the IContext instance.
    SetWriteBatch(wb interface{})
    // GetEntryBatch returns an entry batch instance.
    GetEntryBatch() pb.EntryBatch
    // GetLastEntryBatch returns an entry batch instance.
    GetLastEntryBatch() pb.EntryBatch
}








type context struct {
    size    uint64
    maxSize uint64
    eb      pb.EntryBatch
    lb      pb.EntryBatch
    key     *Key
    val     []byte
    wb      kv.IWriteBatch
}


func (c *context) GetKey() IReusableKey {
    return c.key
}


func (c *context) GetValueBuffer(sz uint64) []byte {
    if sz <= c.size {
        return c.val
    }
    val := make([]byte, sz)
    if sz < c.maxSize {
        c.size = sz
        c.val = val
    }
    return val
}


func (c *context) GetEntryBatch() pb.EntryBatch {
    return c.eb
}


func (c *context) GetLastEntryBatch() pb.EntryBatch {
    return c.lb
}


func (c *context) GetWriteBatch() interface{} {
    return c.wb
}


func (c *context) SetWriteBatch(wb interface{}) {
    c.wb = wb.(kv.IWriteBatch)
}

3.3存储引擎封装IKVStore

IKVStore 是 Dragonboat 日志存储系统的抽象接口,它定义了底层键值存储引擎需要实现的所有基本操作。这个接口让 Dragonboat 能够支持不同的存储后端(如 Pebble、RocksDB 等),实现了存储引擎的可插拔性。

type IKVStore interface {
    // Name is the IKVStore name.
    Name() string
    // Close closes the underlying Key-Value store.
    Close() error


    // 范围扫描 - 支持前缀遍历的迭代器
    IterateValue(fk []byte,
            lk []byte, inc bool, op func(key []byte, data []byte) (bool, error)) error
    
    // 查询操作 - 基于回调的内存高效查询模式
    GetValue(key []byte, op func([]byte) error) error
    
    // 写入操作 - 单条记录的原子写入
    SaveValue(key []byte, value []byte) error


    // 删除操作 - 单条记录的精确删除
    DeleteValue(key []byte) error
    
    // 获取批量写入器
    GetWriteBatch() IWriteBatch
    
    // 原子提交批量操作
    CommitWriteBatch(wb IWriteBatch) error
    
    // 批量删除一个范围的键值对
    BulkRemoveEntries(firstKey []byte, lastKey []byte) error
    
    // 压缩指定范围的存储空间
    CompactEntries(firstKey []byte, lastKey []byte) error
    
    // 全量压缩整个数据库
    FullCompaction() error
}


type IWriteBatch interface {
    Destroy()                 // 清理资源,防止内存泄漏
    Put(key, value []byte)    // 添加写入操作
    Delete(key []byte)        // 添加删除操作
    Clear()                   // 清空批处理中的所有操作
    Count() int               // 获取当前批处理中的操作数量
}

openPebbleDB是Dragonboat 中 Pebble 存储引擎的初始化入口,负责根据配置创建一个完整可用的键值存储实例。

// KV is a pebble based IKVStore type.
type KV struct {
    db       *pebble.DB
    dbSet    chan struct{}
    opts     *pebble.Options
    ro       *pebble.IterOptions
    wo       *pebble.WriteOptions
    event    *eventListener
    callback kv.LogDBCallback
    config   config.LogDBConfig
}


var _ kv.IKVStore = (*KV)(nil)




// openPebbleDB
// =============
// 将 Dragonboat 的 LogDBConfig → Pebble 引擎实例
func openPebbleDB(
        cfg  config.LogDBConfig,
        cb   kv.LogDBCallback,   // => busy通知:busy(true/false)
        dir  string,             // 主数据目录
        wal  string,             // WAL 独立目录(可空)
        fs   vfs.IFS,            // 文件系统抽象(磁盘/memfs)
) (kv.IKVStore, error) {
    
    //--------------------------------------------------
    // 2️⃣ << 核心调优参数读入
    //--------------------------------------------------
    blockSz      := int(cfg.KVBlockSize)                    // 数据块(4K/8K…)
    writeBufSz   := int(cfg.KVWriteBufferSize)              // 写缓冲
    bufCnt       := int(cfg.KVMaxWriteBufferNumber)         // MemTable数量
    l0Compact    := int(cfg.KVLevel0FileNumCompactionTrigger) // L0 层文件数量触发压缩的阈值
    l0StopWrites := int(cfg.KVLevel0StopWritesTrigger)
    baseBytes    := int64(cfg.KVMaxBytesForLevelBase)
    fileBaseSz   := int64(cfg.KVTargetFileSizeBase)
    cacheSz      := int64(cfg.KVLRUCacheSize)
    levelMult    := int64(cfg.KVTargetFileSizeMultiplier)  // 每层文件大小倍数
    numLevels    := int64(cfg.KVNumOfLevels)
    
    
    //--------------------------------------------------
    // 4️⃣ 构建 LSM-tree 层级选项 (每层无压缩)
    //--------------------------------------------------
    levelOpts := []pebble.LevelOptions{}
    sz := fileBaseSz
    for lvl := 0; lvl < int(numLevels); lvl++ {
        levelOpts = append(levelOpts, pebble.LevelOptions{
            Compression:    pebble.NoCompression, // 写性能优先
            BlockSize:      blockSz,
            TargetFileSize: sz,                 // L0 < L1 < … 呈指数增长
        })
        sz *= levelMult
    }
    
    //--------------------------------------------------
    // 5️⃣ 初始化依赖:LRU Cache + 读写选项
    //--------------------------------------------------
    cache := pebble.NewCache(cacheSz)    // block缓存
    ro    := &pebble.IterOptions{}       // 迭代器默认配置
    wo    := &pebble.WriteOptions{Sync: true// ❗fsync强制刷盘
    
    opts := &pebble.Options{
        Levels:                      levelOpts,
        Cache:                       cache,
        MemTableSize:                writeBufSz,
        MemTableStopWritesThreshold: bufCnt,
        LBaseMaxBytes:               baseBytes,
        L0CompactionThreshold:       l0Compact,
        L0StopWritesThreshold:       l0StopWrites,
        Logger:                      PebbleLogger,
        FS:                          vfs.NewPebbleFS(fs),
        MaxManifestFileSize:         128 * 1024 * 1024,
        // WAL 目录稍后条件注入
    }
    
    kv := &KV{
        dbSet:    make(chan struct{}),          // 关闭->初始化完成信号
        callback: cb,                           // 上层 raft engine 回调
        config:   cfg,
        opts:     opts,
        ro:       ro,
        wo:       wo,
    }
    
    event := &eventListener{
        kv:      kv,
        stopper: syncutil.NewStopper(),
    }
    
    // => 关键事件触发
    opts.EventListener = pebble.EventListener{
        WALCreated:    event.onWALCreated,
        FlushEnd:      event.onFlushEnd,
        CompactionEnd: event.onCompactionEnd,
    }
    
    //--------------------------------------------------
    // 7️⃣ 目录准备
    //--------------------------------------------------
    if wal != "" {
        fs.MkdirAll(wal)        // 📁 为 WAL 单独磁盘预留
        opts.WALDir = wal
    }
    fs.MkdirAll(dir)            // 📁 主数据目录
    
    //--------------------------------------------------
    // 8️⃣ 真正的数据库实例化
    //--------------------------------------------------
    pdb, err := pebble.Open(dir, opts)
    if err != nil { return nil, err }
    
    //--------------------------------------------------
    // 9️⃣ 🧹 资源整理 & 启动事件
    //--------------------------------------------------
    cache.Unref()               // 去除多余引用,防止泄露
    kv.db = pdb
    
    // 🔔 手动触发一次 WALCreated 确保反压逻辑进入首次轮询
    kv.setEventListener(event)  // 内部 close(kv.dbSet)
    
    return kv, nil
}

其中eventListener是对pebble 内存繁忙的回调,繁忙判断的条件有两个:

  • 内存表大小超过阈值(95%)
  • L0 层文件数量超过阈值(L0写入最大文件数量-1)


func (l *eventListener) notify() {
    l.stopper.RunWorker(func() {
        select {
        case <-l.kv.dbSet:
            if l.kv.callback != nil {
                memSizeThreshold := l.kv.config.KVWriteBufferSize *
                    l.kv.config.KVMaxWriteBufferNumber * 19 / 20
                l0FileNumThreshold := l.kv.config.KVLevel0StopWritesTrigger - 1
                m := l.kv.db.Metrics()
                busy := m.MemTable.Size >= memSizeThreshold ||
                    uint64(m.Levels[0].NumFiles) >= l0FileNumThreshold
                l.kv.callback(busy)
            }
        default:
        }
    })
}

3.4日志条目存储DB

db结构体是Dragonboat日志数据库的核心管理器,提供Raft日志、快照、状态等数据的持久化存储接口。是桥接了业务和pebble存储的中间层。

// db is the struct used to manage log DB.
type db struct {
    cs      *cache       // 节点信息、Raft状态信息缓存
    keys    *keyPool     // Raft日志索引键变量池
    kvs     kv.IKVStore  // pebble的封装
    entries entryManager // 日志条目读写封装
}


// 这里面的信息不会过期,叫寄存更合适
type cache struct {
    nodeInfo       map[raftio.NodeInfo]struct{}
    ps             map[raftio.NodeInfo]pb.State
    lastEntryBatch map[raftio.NodeInfo]pb.EntryBatch
    maxIndex       map[raftio.NodeInfo]uint64
    mu             sync.Mutex
}
  • 获取一个批量写容器

实现:

func (r *db) getWriteBatch(ctx IContext) kv.IWriteBatch {
    if ctx != nil {
        wb := ctx.GetWriteBatch()
        if wb == nil {
            wb = r.kvs.GetWriteBatch()
            ctx.SetWriteBatch(wb)
        }
        return wb.(kv.IWriteBatch)
    }
    return r.kvs.GetWriteBatch()
}

降低GC压力

  • 获取所有节点信息

实现:

func (r *db) listNodeInfo() ([]raftio.NodeInfo, error) {
    fk := newKey(bootstrapKeySize, nil)
    lk := newKey(bootstrapKeySize, nil)
    fk.setBootstrapKey(00)
    lk.setBootstrapKey(math.MaxUint64, math.MaxUint64)
    ni := make([]raftio.NodeInfo, 0)
    op := func(key []byte, data []byte) (boolerror) {
        cid, nid := parseNodeInfoKey(key)
        ni = append(ni, raftio.GetNodeInfo(cid, nid))
        return truenil
    }
    if err := r.kvs.IterateValue(fk.Key(), lk.Key(), true, op); err != nil {
        return []raftio.NodeInfo{}, err
    }
    return ni, nil
}
  • 保存集群状态

实现:

type Update struct {
    ClusterID uint64  // 集群ID,标识节点所属的Raft集群
    NodeID    uint64  // 节点ID,标识集群中的具体节点


    State  // 包含当前任期(Term)、投票节点(Vote)、提交索引(Commit)三个关键持久化状态


    EntriesToSave []Entry    // 需要持久化到稳定存储的日志条目
    CommittedEntries []Entry // 已提交位apply的日志条目
    MoreCommittedEntries bool  // 指示是否还有更多已提交条目等待处理


    Snapshot Snapshot  // 快照元数据,当需要应用快照时设置


    ReadyToReads []ReadyToRead  // ReadIndex机制实现的线性一致读


    Messages []Message  // 需要发送给其他节点的Raft消息


    UpdateCommit struct {
        Processed         uint64  // 已推送给RSM处理的最后索引
        LastApplied       uint64  // RSM确认已执行的最后索引
        StableLogTo       uint64  // 已稳定存储的日志到哪个索引
        StableLogTerm     uint64  // 已稳定存储的日志任期
        StableSnapshotTo  uint64  // 已稳定存储的快照到哪个索引
        ReadyToRead       uint64  // 已准备好读的ReadIndex请求索引
    }
}




func (r *db) saveRaftState(updates []pb.Update, ctx IContext) error {
      // 步骤1:获取写入批次对象,用于批量操作提高性能
      // 优先从上下文中获取已存在的批次,避免重复创建
      wb := r.getWriteBatch(ctx)
      
      // 步骤2:遍历所有更新,处理每个节点的状态和快照
      for _, ud := range updates {
          // 保存 Raft 的硬状态(Term、Vote、Commit)
          // 使用缓存机制避免重复保存相同状态
          r.saveState(ud.ClusterID, ud.NodeID, ud.State, wb, ctx)
          
          // 检查是否有快照需要保存
          if !pb.IsEmptySnapshot(ud.Snapshot) {
              // 快照索引一致性检查:确保快照索引不超过最后一个日志条目的索引
              // 这是 Raft 协议的重要约束,防止状态不一致
              if len(ud.EntriesToSave) > 0 {
                  lastIndex := ud.EntriesToSave[len(ud.EntriesToSave)-1].Index
                  if ud.Snapshot.Index > lastIndex {
                      plog.Panicf("max index not handled, %d, %d",
                          ud.Snapshot.Index, lastIndex)
                  }
              }
              
              // 保存快照元数据到数据库
              r.saveSnapshot(wb, ud)
              
              // 更新节点的最大日志索引为快照索引
              r.setMaxIndex(wb, ud, ud.Snapshot.Index, ctx)
          }
      }
      
      // 步骤3:批量保存所有日志条目
      // 这里会调用 entryManager 接口的 record 方法,根据配置选择批量或单独存储策略
      r.saveEntries(updates, wb, ctx)
      
      // 步骤4:提交写入批次到磁盘
      // 只有在批次中有实际操作时才提交,避免不必要的磁盘 I/O
      if wb.Count() > 0 {
          return r.kvs.CommitWriteBatch(wb)
      }
      return nil
  }
  
  
  • 保存引导信息

实现:

func (r *db) saveBootstrapInfo(clusterID uint64,
    nodeID uint64, bs pb.Bootstrap) error {
    wb := r.getWriteBatch(nil)
    r.saveBootstrap(wb, clusterID, nodeID, bs)
    return r.kvs.CommitWriteBatch(wb) // 提交至Pebble
}


func (r *db) saveBootstrap(wb kv.IWriteBatch,
    clusterID uint64, nodeID uint64, bs pb.Bootstrap) {
    k := newKey(maxKeySize, nil)
    k.setBootstrapKey(clusterID, nodeID) // 序列化集群节点信息
    data, err := bs.Marshal()
    if err != nil {
        panic(err)
    }
    wb.Put(k.Key(), data)
}
  • 获取Raft状态

实现:

func (r *db) getState(clusterID uint64, nodeID uint64) (pb.State, error) {
    k := r.keys.get()
    defer k.Release()
    k.SetStateKey(clusterID, nodeID)
    hs := pb.State{}
    if err := r.kvs.GetValue(k.Key(), func(data []byte) error {
        if len(data) == 0 {
            return raftio.ErrNoSavedLog
        }
        if err := hs.Unmarshal(data); err != nil {
            panic(err)
        }
        return nil
    }); err != nil {
            return pb.State{}, err
    }
    return hs, nil
}

3.5对外存储API实现

龙舟对ILogDB提供了实现:ShardedDB,一个管理了多个pebble bucket的存储单元。

var _ raftio.ILogDB = (*ShardedDB)(nil)
// ShardedDB is a LogDB implementation using sharded pebble instances.
type ShardedDB struct {
    completedCompactions uint64             // 原子计数器:已完成压缩操作数
    config               config.LogDBConfig // 日志存储配置
    ctxs                 []IContext         // 分片上下文池,减少GC压力
    shards               []*db              // 核心:Pebble实例数组
    partitioner          server.IPartitioner // 智能分片策略器
    compactionCh         chan struct{}      // 压缩任务信号通道
    compactions          *compactions       // 压缩任务管理器
    stopper              *syncutil.Stopper  // 优雅关闭管理器
}
  • 初始化过程

实现:

// 入口函数:创建并初始化分片日志数据库
OpenShardedDB(config, cb, dirs, lldirs, batched, check, fs, kvf):


    // ===阶段1:安全验证===
    if 配置为空 then panic
    if check和batched同时为true then panic


    // ===阶段2:预分配资源管理器===
    shards := 空数组
    closeAll := func(all []*db) { //出错清理工具
        for s in all {
            s.close()
        }
    }


    // ===阶段3:逐个创建分片===
    loop i := 0 → 分片总数:
        datadir := pathJoin(dirs[i], "logdb-"+i)  //数据目录
        snapdir := ""                           //快照目录(可选)
        if lldirs非空 {
            snapdir = pathJoin(lldirs[i], "logdb-"+i)
        }


        shardCb := {shard:i, callback:cb}      //监控回调
        db, err := openRDB(...)                //创建实际数据库实例
        if err != nil {                        //创建失败
            closeAll(shards)                   //清理已创建的
            return nil, err
        }
        shards = append(shards, db)


    // ===阶段5:核心组件初始化===
    partitioner := 新建分区器(execShards数量, logdbShards数量)
    instance := &ShardedDB{
        shards:      shards,
        partitioner: partitioner,
        compactions: 新建压缩管理器(),
        compactionCh: 通道缓冲1,
        ctxs:       make([]IContext, 执行分片数),
        stopper:    新建停止器()
    }


    // ===阶段6:预分配上下文&启动后台===
    for j := 0 → 执行分片数:
        instance.ctxs[j] = 新建Context(saveBufferSize)


    instance.stopper.RunWorker(func() {        //后台压缩协程
        instance.compactionWorkerMain()
    })


    return instance, nil                      //构造完成
    

  • 保存集群状态

实现:

func (s *ShardedDB) SaveRaftState(updates []pb.Update, shardID uint64error {
    if shardID-1 >= uint64(len(s.ctxs)) {
        plog.Panicf("invalid shardID %d, len(s.ctxs): %d", shardID, len(s.ctxs))
    }
    ctx := s.ctxs[shardID-1]
    ctx.Reset()
    return s.SaveRaftStateCtx(updates, ctx)
}


func (s *ShardedDB) SaveRaftStateCtx(updates []pb.Update, ctx IContext) error {
    if len(updates) == 0 {
        return nil
    }
    pid := s.getParititionID(updates)
    return s.shards[pid].saveRaftState(updates, ctx)
}

以sylas为例子,我们每个分片都是单一cluster,所以logdb只使用了一个分片,龙舟设计初衷是为了解放多cluster的吞吐,我们暂时用不上,tindb可以考虑

四、总结

LogDB是Dragonboat重要的存储层实现,作者将Pebble引擎包装为一组通用简洁的API,极大方便了上层应用与存储引擎的交互成本。

其中包含了很多Go语言的技巧,例如大量的内存变量复用设计,展示了这个库对高性能的极致追求,是一个十分值得学习的优秀工程案例。

往期回顾

1. 从数字到版面:得物数据产品里数字格式化的那些事

2. 一文解析得物自建 Redis 最新技术演进

3. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

4. RN与hawk碰撞的火花之C++异常捕获|得物技术

5. 得物TiDB升级实践

文 /酒米

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

百度大数据成本治理实践

导读

本文概述了在业务高速发展和降本增效的背景下百度MEG(移动生态事业群组)大数据成本治理实践方案,主要包含当前业务面临的主要问题、计算数据成本治理优化方案、存储数据成本治理优化方案、数据成本治理成果以及未来治理方向的一个思路探讨,为业界提供可参考的治理经验。

01 背景

随着百度各业务及产品的快速发展,海量的离线数据成本在持续地增长。在此背景下,通过大数据治理技术来帮助业务降本增效,实现业务的可持续增长变得至关重要。我们通过对当前资源现状、管理现状以及成本现状三个角度进行分析:

  • 资源现状:各个产品线下业务类型繁多,涉及的离线AFS(百度公司分布式文件存储:Appendonly File Storage)存储账号和EMR(百度公司全托管计算集群:E-MapReduce EMR+)队列数量非常多,成百上千,什么时候启动治理,采用什么手段治理,并没有明确规划,且各业务间缺少统一的治理标准。

  • 管理现状:针对离线资源的使用参差不齐,存储账号和计算队列资源的管理和使用较为混乱,有的使用率高,有的使用率低;此外,业务间的离线作业管理并不统一且不完全规范,没有完善的流程机制以及规范来对离线资源以及作业进行管理管控,并且计算任务的执行效率较低,整体运维难度较大。

  • 成本现状:MEG各个产品线离线计算资源达数千万核,存储资源达数千PB,各产品线的离线计算和存储资源成本每年可达数亿元,随着业务的增长,如果不进行成本治理和优化,离线资源成本还会持续增加。

整体来说,目前主要面临数据散乱、资源浪费、成本增加等问题。基于以上存在的问题,我们通过构建统一的治理标准,并利用大数据资源管理平台搭建各产品线下的的离线存储资源视图、计算资源视图、任务视图以及成本视图,基于引擎能力对存储和计算进一步优化,帮助MEG下各产品线下的业务持续进行数据成本治理,接下来将具体阐述我们在大数据成本治理过程的实践方案。

图片

△ 数据成本治理现状

02 数据成本治理实践方案

2.1 数据成本治理总体框架


针对目前存在的问题,我们主要围绕数据资产度量、平台化能力以及引擎赋能三个方面构建数据成本治理总体框架,实现对计算和存储两大方向的治理,来达到降本增效的目的,具体如下图所示,接下来将进行具体地介绍。

图片

△ 数据成本治理总体框架

2.1.1 数据资产健康度量

为了对当前各个业务的计算和存储资源进行合理的评估和治理,我们采用统一的标准:健康分来进行衡量,而健康分计算的数据指标来源依赖于离线数据采集服务,该服务通过对当前计算队列,计算任务,存储账号,数据表等元数据信息进行例行采集,然后再进一步对于采集的数据进行分析和挖掘,形成一个个计算治理项和存储治理项,比如计算治理项可包含:使用率不均衡的计算队列个数、长耗时高资源消耗的计算任务、数据倾斜的任务以及无效任务等;存储治理项可包含1年/2年/N年未访问的冷数据目录、存储目录生命周期异常、inode占比过低以及未认领目录等。通过产生的数据治理项信息汇聚形成计算健康分和存储健康分两大类,如下:

  • 计算健康分:基于队列使用平均水位+队列使用均衡程度+计算治理项进行加权计算获取。

  • 存储健康分:基于存储账号使用平均水位+存储账号使用峰值+冷数据占比+治理项进行加权计算获取。

最终,通过统一规范的健康分来对当前各产品线下业务所属的数据资产进行度量,指导业务进行规范化治理。

2.1.2 平台化能力

此外,为了完成对当前产品线下离线计算和存储资源的全生命周期管理,我们通过搭建大数据资源管理平台,完成对各个产品线的离线计算资源和存储资源的接入,并基于平台能力为业务构建统一的计算视图、存储视图以及离线成本视图,整合离线计算任务需要的存储和计算资源,并将各类工具平台化,帮助业务快速发现和解决各类数据成本治理问题,具体如下:

  • 计算视图:包含各个计算队列资源使用概览和计算治理项详情信息,并提供计算任务注册、管控、调度执行以及算力优化全生命周期管理的能力。

  • 存储视图:汇聚了当前所有存储账号资源使用详情以及各类存储治理项信息,并提供给用户关于存储目录清理、迁移以及冷数据挖掘相关的存储管理以及治理能力。

  • 成本视图:构建各个产品线下关于离线存储和计算资源总成本使用视图,通过总成本使用情况,更直观地展示治理成果。

2.1.3 引擎赋能

在实际离线大数据业务场景中,很多业务接口人对于大数据计算或者存储引擎的原理和特性不是非常熟悉,缺乏或者没有调优意识,通常在任务提交时没有根据任务的实际数据规模、计算复杂度以及集群资源状况进行针对性的参数调整,这种情况就会使得任务执行效率无法达到最优,且计算和存储资源不能得到充分的应用,进而影响业务迭代效率。针对上述计算和存储资源浪费的问题,我们结合大数据引擎能力,来实现对于计算和存储进一步地优化,助力业务提效,为业务的持续发展提供有力的支持。主要包含以下两个场景:

  • 计算场景:结合任务运行历史信息以及机器学习算法模型能力,建立一套完善的智能调参机制,对于提交的任务参数进行动态调整,最大程度保障任务在较优的参数下执行,进一步提升任务执行效率,并高效利用当前计算资源。

  • 存储场景:针对海量的存储数据,我们通过不同类型数据进行深入的分析和特征挖掘,实现了对存储数据智能压缩的能力,从而在不影响业务数据写入和查询的前提下,完成对现有数据存储文件的压缩,帮助业务节约存储资源和成本。

03 计算&存储数据成本治理优化

3.1 计算成本治理

在计算成本治理方向,我们主要基于平台和引擎能力,通过管理管控,混合调度以及智能调参三大方面对现有的计算资源和计算任务进行治理和优化。

3.1.1 管理管控

在MEG离线大数据场景下,主要涉及对上千EMR计算队列、以及上万Hadoop和Spark两大类型的计算任务管理。

  • 一方面,我们针对各个业务的计算队列和计算任务的管理,通过平台能力实现了从计算资源的注册接入,到计算队列和任务数据的采集,再到离线数据分析和挖掘,最终形成如使用率不均衡的计算队列、长耗时高资源消耗的计算任务、数据倾斜的任务、无效任务以及相似任务等多个计算治理项,并基于统一规范的健康分机制来对业务计算资产进行度量,指导业务对计算进行治理。

  • 另一方面,在离线混部场景,可能会存在部分用户对于任务使用不规范,影响离线例行任务或者造成资源浪费,我们针对Hadoop和Spark不同任务类型,分别建立了任务提交时和运行时管控机制,并结合业务实际场景,实现了并发限制、基本参数调优,队列资源限制以及僵尸任务等30+管控策略,对于天级上万的任务进行合理的管理管控,并及时挖掘和治理相关异常任务。目前运行的管控策略已经覆盖多个产品线下离线EMR计算队列上千万核,每天任务触发各种管控次数20万+。

通过对计算资源全生命周期的管理和管控,我们可以及时有效地发现可治理的队列或者任务,并推进业务进行治理。

图片

△ 任务管理管控流程

3.1.2 混合调度

通过对于平台接入的队列资源使用情况以及任务执行情况的深度分析,我们发现当前各个业务使用的计算资源存在以下几个问题:

1. 不同产品线业务特点不同,存在Hadoop和Spark两种类型计算任务,并且Hadoop任务CPU使用较多、内存使用较低,而Spark任务CPU使用较低,内存使用较高。

2. 有些队列整体资源使用率不高,但是存在部分时间段资源使用很满,不同队列资源使用波峰不完全一致,有的高峰在夜间,有的高峰在白天。

3. 存在队列碎片化问题,一些小队列不适合提交大作业且部分使用率不高。

为解决上述问题,我们建设Hadoop和Spark混合调度机制。针对公司不同业务来源的任务,基于Hadoop调度引擎以及Spark调度引擎完成各自任务的智能化调度,并通过调度策略链在多个候选队列中选择最优队列,最终实现任务提交到EMR计算集群上进行执行。具体流程如下:

  • 任务提交:针对不同产品线下的业务提交的Hadoop或者Spark任务,服务端会通过不同任务类型基于优先级、提交时间以及轮数进行全局加权排序,排序后的每个任务会分发到各自的任务调度池中,等待任务调度引擎拉取提交。

  • 任务调度:该阶段,调度引擎中不同任务类型的消费线程,会定时拉取对应任务调度池中的任务,按照FIFO的策略,多线程进行消费调度。在调度过程中,每个任务会依次通过通用调度策略链和专有调度策略链来获取该任务最优提交队列,其中,通用和专有调度策略主要是计算队列资源获取、候选队列过滤、队列排序(数据输入输出地域,计算地域)、队列资源空闲程度以及高优任务保障等20+策略。比如某任务调度过程中,请求提交的队列是A,调度过程中存在三个候选队列A、B、C,其中候选队列A使用率很高,B使用率中等且存储和计算地域相差较远,C使用率低且距离近,最终通过智能调度可分配最优队列C进行提交。

  • 任务执行:通过调度引擎获取到最优队列的任务,最终会提交到对应的EMR计算集群队列上进行执行,进而实现各个队列的使用率更加均衡,并提高低频使用队列的资源使用率。

图片

△ 任务混合调度流程

3.1.3 智能调参

在数据中心业务场景,多以Spark任务为主,天级提交的Spark任务5万+,但这些任务执行过程中,会存在计算资源浪费的情况,具有一定的优化空间,我们通过前期数据分析,发现主要存在以下两类问题:

1. 用户没有调优意识,或者是缺乏调优经验,会造成大量任务资源配置不合理,资源浪费严重,比如并发和内存资源配置偏大,但实际可以继续调低,如case示例1所示;

2. 在Spark计算引擎优化器中, 只有RBO(Rule-Based Optimizer)和CBO(Cost-Based Optimizer)优化器, 前者基于硬规则,后者基于执行成本来优化查询计划,但对于例行任务, 只有RBO和CBO会忽略一些能优化的输入信息,任务性能存在一定的瓶颈,如case示例2所示。

图片

△ 任务参数配置case示例

针对第一类问题,我们实现了对Spark任务基本参数智能调优的能力,在保证任务SLA的情况下,结合模型训练的方式,来支持对例行任务长期调优并降低任务资源消耗。每轮任务例行会推荐一组参数并获取其对应性能,通过推荐参数、运行并获取性能、推荐参数的周期性迭代,在多轮训练迭代后,提供一组满足任务调优目标并且核时最少的近似最优参数,其中涉及的参数主要有spark.executor.instances, spark.executor.cores, spark.executor.memory这三类基本参数。具体实现流程如下:

1. 任务提交流:任务提交过程中,会从调优服务的Web Controller模块获取当前生成的调优参数并进行下发;

2. 结果上报流:通过任务状态监控,在任务执行完成后,调优服务的Backend模块会定时同步更新任务实际运行配置和执行耗时等执行历史数据信息到数据库中;

3. 模型训练流:调优服务的Backend模块定时拉取待训练任务进行数据训练,通过与模型交互,加载历史调优模型checkpoint,基于最新样本数据进行迭代训练,生成新的训练模型checkpoint以及下一轮调优参数,并保存到数据库中。

4. 任务SLA保障:通过设置运行时间上限、超时兜底、限制模型调优范围,以及任务失败兜底等策略来保障任务运行时间以及任务执行的稳定性。

最终,通过任务提交流程、结果上报流程、训练流程实现任务运行时需要的并发和内存基本参数的自动化调优,并基于运行时间保障和任务稳定性保障策略,确保任务的稳定性,整体流程框架如下图。

图片

△ 基本参数智能调优流程

针对第二类问题,我们构建HBO(History Based Optimization)智能调优模块实现对复杂参数场景的任务自动调优能力。首先,通过性能数据收集器完成对运行完成的Spark任务History的详情数据采集和AMP任务画像,然后在任务执行计划阶段和提交阶段,基于任务历史执行的真实运行统计数据来优化未来即将执行的任务性能,从而弥补执行之前预估不精确的问题,具体如下:

  • 执行计划调优阶段:主要进行Join算法动态调整、Join数据倾斜调整、聚合算法动态调整以及Join顺序重排等调优;

  • 任务提交阶段:基于任务运行特点智能添加或者改写当前提交的Spark任务运行参数,比如Input输入、合并小文件读、Output输出、拆分大文件写、Shuffle分区数动态调整以及大shuffle开启Kryo Serialization等参数,从而实现对运行参数的调优;

通过数据采集反馈和动态调参,不断循环,进而完成对于复杂参数场景的智能调优能力,让任务在执行资源有限的条件下,跑得更稳健,更快。整体实现流程图如下:

图片

△ HBO智能调优流程

3.2 存储成本治理


在百度MEG大数据离线场景下,底层存储主要是使用AFS,通过梳理我们发现目前针对离线使用的各个存储账号,缺乏统一管理和规范,主要存在以下几个核心问题:

  • AFS存储账号多且无归属:离散账号繁多,涉及目录数量多且大部分无Quota限制甚至找不到相关负责人,缺乏统一管理和规范;

  • AFS存储不断增加:不少业务对于数据存储缺少优化治理措施,且存在很多历史的无用数据,长期存放,导致数据只增不减;

  • 安全风险:各个账号使用过程中,数据随意读写甚至跨多个账户读写,安全无保障,并且缺少监控报警。

3.2.1 存储生命周期管理

针对上述问题,我们基于平台能力构建存储一站式治理能力,将存储资源的全生命周期管理分为五层:接入层、服务层、存储层、执行层以及用户层,通过建立存储资源使用规范,并基于采集的相关存储元数据,深度分析业务的离线AFS存储账号使用现状,将用户存储相关的问题充分挖掘和暴露出来,针对各种问题提供简单易用的通用化工具来帮助用户快速进行治理和解决,整体实现了各个集群存储账号的存储数据接入,采集,挖掘和分析,自动清理,监控预警全生命周期的管理。整体流程架构图如下:

图片

△ 存储生命周期管理流程

  • 接入层:通过建立规范的存储资源管理机制,比如存储的接入和申请规范、目录的创建规范、使用规范、利用率考核规范(Quota回收规范)以及冷数据的处理规范等通用化的规范,进而来完成用户从存储资源的接入-申请(扩容)、审核、交付、资源的例行审计的整体流程。

  • 服务层:基于离线服务完成对各集群存储目录&存储冷数据Quota采集,然后进一步对数据进行深入分析和挖掘,包含但不限于冷数据、异常目录使用、存储变化趋势以及成本数据等分析。

  • 存储层:建立账号、产品线、目录、任务、负责人以及账号的基本使用信息的元数据存储,通过Mysql进行存储,确保每个集群存储账号有对应归属;对于各个集群目录数据使用详情信息,选择Table(百度公司大规模分布式表格系统)进行存储。

  • 执行层:基于存储管理规范,对于各个集群存储账号进行每天例行的存储自动清理,数据转储和压缩,并提供完善的存储使用监控报警机制。

  • 用户层:通过平台,为用户构建不同维度的AFS存储现状概览视图,以及整合现有数据,对于各个集群的存储账号或目录进行分析,提供优化建议、存储工具以及API接口,帮助业务快速进行存储相关治理和存储相关问题的解决。

3.2.2 存储基础治理

在AFS存储资源的生命周期管理过程中,我们主要基于服务层和执行层为用户提供一套基础的存储AFS账号数据基础治理能力。通过离线解析Quota数据和冷数据目录相关的基础数据,完成对其计算、分析、聚合等处理,实现存储趋势变化、成本计算、异常目录分析、冷数据分析、数据治理项和治理建议等多方面能力支持。之后,用户便可结合存储数据全景视图分析和相关建议,进行存储路径配置、转储集群目录配置、压缩目录配置以及监控账号配置等多维度配置。基于用户的配置,通过后台离线服务定时执行,完成对用户存储的数据清理、空间释放和监控预警,保障各个业务存储账号的合理使用以及治理优化。

图片

△ AFS存储数据基础治理流程

3.2.3 智能压缩

平台侧管理的MEG相关AFS存储数据上千PB,存在一部分数据,是没有进行相应的压缩或者压缩格式设置的并不是非常合理,我们通过结合业务实际使用情况,针对业务存储数据进行智能压缩,同时不影响数据读写效率,进一步优化降低业务存储成本,主要实现方案流程如下图。由于业务场景不同,我们采用不同的压缩方案。

  • 针对数仓表存储数据场景:首先是通过对采集的数仓表元数据信息进行数据画像,完成表字段存储占比和数据分布情况分析,之后基于自动存储优化器,实现对数仓表分区数据读取、压缩规则应用以及分区数据重写,最终完成对数仓表数据的自动压缩,在保证数仓表读写效率的前提下,进一步提升数据压缩效率,降低存储数据成本。其中压缩规则应用主要包含:可排序字段获取、重排序优化、ZSTD压缩格式、PageSize大小以及压缩Leve调整等规则。

  • 针对非数据仓表存储数据场景:在该场景下的存储数据,一般是通过任务直接写入AFS,写入方式各种各样,因此,需要直接对AFS存储数据进行分析和挖掘。我们首先对这部分数据进行冷热分层,将其分为冷数据、温数据以及热数据,并挖掘其中可进行压缩和压缩格式可进一步优化的数据,以及压缩配置可进一步调整的任务;之后,通过自动存储优化器,针对增量热数据,基于例行写入任务历史画像选择合适的压缩参数进行调优,并记录压缩效果;针对存量温冷数据,定期执行离线压缩任务进行自动压缩;最终我们对热数据进行压缩提醒,温冷数据进行自动压缩,从而实现该类型存储数据的压缩智能优化。

图片

△ 智能压缩流程

04 治理成果

通过数据成本治理,我们取得了一些不错的优化和实践效果,主要包含数据开发和成本优化方面以及治理资产两大方面:

1. 数据开发和成本优化

  • 数据开发提效:基于离线资源的全生命周期管理,计算和存储资源交付效率从月级或者周级缩短至天级,效率大幅提升,进而降低数据开发周期,此外基于混合调度和智能调参等能力,任务排队情况大幅降低,数据产出时效性平均提升至少一倍,大幅提高数据开发效率。

  • 计算成本优化:实现了MEG下上千个队列使用更加均衡,并完成了千万核EMR计算队列资源平均使用率提升30%+,增量供给日常业务需求数百万核资源的同时,优化退订数百万核计算资源,年化成本可降低数千万元。

  • 存储运维提效:通过利用存储数据基础治理等能力,完成了对上千个AFS存储账号管理、无用数据挖掘和清理,以及监控预警等,使得存储账号的运维更加可控,效率大幅提升。

  • 存储成本优化:实现了对MEG下上千PB存储资源整体使用率平均提升20%+,增量供给日常业务需求数百PB资源的同时,优化退订数百PB存储资源,年化成本同样降低数千万元。

2. 治理资产

  • 数据开发规范:逐步完善了资源交付规范、计算任务开发规范、存储和计算资源使用规范、以及数据质量和安全规范等多种规范流程。

  • 计算&存储资源成本:形成各个产品线下关于计算资源、存储资源以及成本使用详情的概览视图,对于资源使用和成本变化趋势清晰可见。

  • 数据任务资产:基于任务历史画像,构建任务从提交到运行再到完成的全生命周期的执行详情数据概览视图,帮助业务高效进行任务管理。

  • 数据治理项:通过数据挖掘和分析形成的计算任务,计算队列和存储账号相关的治理项详情数据看板,助力业务快速发现可治理的数据问题。

05 未来规划

目前,通过标准化、平台化以及引擎化的技术能力,进一步完成了对MEG下离线存储和计算资源管理和数据成本治理,并取得一定治理成果,但数据成本治理作为一个长期且持续的一项工作,我们将持续完善和挖掘数据成本治理技术方案,并结合治理过程中的经验、流程和标准,实现更规范、更智能化的治理能力。

一文解析得物自建 Redis 最新技术演进

一、前 言

自建 Redis 上线 3 年多以来,一直围绕着技术架构、性能提升、降低成本、自动化运维等方面持续进行技术演进迭代,力求为公司业务提供性能更高、成本更低的分布式缓存集群,通过自动化运维方式提升运维效率。

本文将从接入方式、同城双活就近读、Redis-server 版本与能力、实例架构与规格、自动化运维等多个方面分享一下自建 Redis 最新的技术演进。

二、规模现状

随着公司业务增长,自建 Redis 管理的 Redis 缓存规模也一直在持续增长,目前自建 Redis 总共管理 1000+集群,内存总规格 160T,10W+数据节点,机器数量数千台,其中内存规格超过 1T 的大容量集群数十个,单个集群最大访问 QPS 接近千万。

三、技术演进介绍

3.1 自建Redis系统架构

下图为自建Redis系统架构示意图:

自建 Redis 架构示意图

自建Redis集群由Redis-server、Redis-proxy、ConfigServer 等核心组件组成。

  • Redis-server 为数据存储组件,支持一主多从,主从多可用区部署,提供高可用、高性能的服务;
  • Redis-proxy 为代理组件,业务通过 proxy 可以像使用单点实例一样访问 Redis 集群,使用更简单,并且在Redis-proxy 上提供同区优先就近读、key 维度或者命令维度限流等高级功能;
  • ConfigServer 为负责 Redis 集群高可用的组件。

自建 Redis 接入方式支持通过域名+LB、service、SDK 直连(推荐)等多种方式访问 Redis 集群。

自建 Redis 系统还包含一个功能完善的自动化运维平台,其主要功能包括:

  • Redis 集群实例从创建、proxy 与 server 扩缩容、到实例下线等全生命周期自动化运维管理能力;
  • 业务需求自助申请工单与工单自动化执行;
  • 资源(包含 ECS、LB)精细化管理与自动智能分配能力、资源报表统计与展示;
  • ECS 资源定期巡检、自动均衡与节点智能调度;
  • 集群大 key、热 key 等诊断与分析,集群数据自助查询。

下面将就一些重要的最新技术演进进行详细介绍。

3.2 接入方式演进

自建 Redis 提升稳定性的非常重要的一个技术演进就是自研 DRedis SDK,业务接入自建 Redis 方式从原有通过域名+LB 的方式访问演进为通过 DRedis SDK 连接 proxy 访问。

LB接入问题

在自建 Redis 初期,为了方便业务使用,使用方式保持与云 Redis 一致,通过 LB 对 proxy 做负载均衡,业务通过域名(域名绑定集群对应 LB)访问集群,业务接入简单,像使用一个单点 Redis 一样使用集群,并且与云 Redis 配置方式一致,接入成本低。

随着自建 Redis 规模增长,尤其是大流量业务日渐增多,通过 LB 接入方式的逐渐暴露出很多个问题,部分问题还非常棘手:

  • 自建 Redis 使用的单个 LB 流量上限为5Gb,阈值比较小,对于一些大流量业务单个 LB 难以承接其流量,需要绑定多个LB,增加了运维复杂度,而且多个 LB 时可能会出现流量倾斜问题;
  • LB组件作为访问入口,可能会受到网络异常流量攻击,导致集群访问受损;
  • 由于Redis访问均是TCP连接,LB摘流业务会有秒级报错。

DRedis接入

自建Redis通过自研DRedis SDK,通过SDK直连 proxy,不再强依赖 LB,彻底解决 LB 瓶颈和稳定性风险问题,同时,DRedis SDK 默认优先访问同可用区 proxy,天然支持同城双活就近读。

DRedis SDK系统设计图如下所示:

Redis-proxy 启动并且获取到集群拓扑信息后,自动注册到注册中心;可通过管控白屏化操作向配置中心配置集群使用的 proxy 分组与权重、就近读规则等信息;DRedis SDK 启动后,从配置中心获取到 proxy 分组与权重、就近读规则,从注册中心获取到 proxy 节点信息,然后与对应 proxy 节点建立连接;应用通过 DRedis SDK 访问数据时,DRedis SDK 通过加权轮询算法获取一个 proxy 节点(默认优先同可用区)及对应连接,进行数据访问。

DRedis SDK并且对原生 RESP 协议进行了增强,添加了一部分自定义协议,支持业务灵活开启就近读能力,对于满足就近读规则的 key 访问、或者通过注解指定的就近读请求,DRedis SDK通过自定义协议信息,通知 proxy 在执行对应请求时,优先访问同可用区 server 节点。

DRedis SDK 目前支持 Java、Golang、C++(即将上线)三种开发语言。

  • Java SDK 基于 Redisson 客户端二次开发,后续还会新增基于 Jedis 二次开发版本,供业务灵活选择,并且集成到 fusion 框架中
  • Golang SDK 基于 go-Redis v9 进行二次开
  • C++ SDK 基于 brpc 二次开发

DRedis 接入优势

业务通过 DRedis SDK 接入自建 Redis,在稳定性、性能等方面都能得到大幅提升,同时能降低使用成本。

社区某应用升级后,业务 RT 下降明显,如下图所示:

DRedis 接入现状

DRedis SDK目前在公司内部大部分业务域的应用完成升级。

Java 和 Golang 应用目前接入上线超过300+

3.3 同城双活就近读

自建 Redis 同城双活采用中心写就近读的方案实现,可以降低业务多区部署时访问 Redis RT。

同城双活就近读场景下,业务访问 Redis 时,需要 SDK 优先访问同可用区proxy,proxy 优先访问同可用区 server节点,其中proxy优先访问同区 server 节点由 proxy 实现,但是在自研 DRedis SDK 之前,LB 无法自动识别应用所在同区的 proxy 并自动路由,因此需要借助service 的同区就近路由能力,同城双活就近读需要通过容器 proxy+service 接入。

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy。

service接入问题

目前,自建 Redis server 和 proxy 节点基本都是部署在 ECS 上,并且由于 server 节点主要消耗内存,而 proxy 节点主要消耗 CPU,因此默认采用 proxy + server 节点混部的方式,充分利用机器的 CPU 和内存,降低成本。

而为了支持同城双活就近读,需要在容器环境部署 proxy,并创建 service,会带来如下问题:

  • 运维割裂,运维复杂度增加,除了需要运维 ECS 环境部署节点,额外增加了容器环境部署方式。
  • 成本增加,容器环境 proxy 需要独立机器部署,无法与 server 节点混部,造成成本增加。
  • RT上升,节点 CPU 更高,从实际使用效果来看,容器环境 proxy 整体的 CPU 和响应 RT 都明显高于 ECS 环境部署的节点。
  • 访问不均衡,service 接入时,会出现连接和访问不均衡现象。
  • 无法定制化指定仅仅少量特定key 或者 key 前缀、指定请求开启就近读。

DRedis接入

自建 Redis 自研 DRedis SDK 设计之初便考虑了同城双活就近读需求,DRedis 访问 proxy 时,默认优先访问同区proxy;当同可用区可用 proxy 数量小于等于1个时,启用调用保护,DRedis会主动跨区访问其他可用区 proxy 节点。

通过service接入方式支持同城双活就近读,是需要在 proxy 上统一开启就近读配置,开启后,对全局读请求均生效,所有读请求都默认优先同区访问。

由于 Redis 主从复制为异步复制,主从复制可能存在延迟,理论上在备可用区可能存在读取到的不是最新数据

某些特定业务场景下,业务可能在某些场景能够接受就近读,但是其他一些场景需要保证强一致性,无法接受就近读,通过 service 接入方式时无法灵活应对这种场景。

DRedis SDK 提供了两种方式供这种场景下业务使用:

  • 支持指定 key 精确匹配或者 key 前缀匹配的方式,定向启用就近读。
  • Java 支持通过声明式注解(@NearRead)指定某次请求采用就近读;Golang 新增 80 个类似 xxxxNearby 读命令,支持就近读。

使用以上两种方式指定特定请求使用就近读时,无需 proxy 上统一配置同区优先就近读。默认情况下,所有读请求访问主节点,业务上对 RT 要求高、一致性要求低的请求可以通过以上两种方式指定优先同区就近读。

3.4 Redis-server版本与能力

在自建Redis 初期,由于业务在前期使用云Redis产品时均是使用Redis4.0 版本,因此自建 Redis 初期也是选择 Redis4.0 版本作为主版本,随着 Redis 社区新版本发布,结合当前业界使用的主流版本,自建Redis也新增了 Redis6.2 版本,并且将 Redis6.2 版本作为新集群默认版本。

不管是 Redis4.0 还是 Redis6.2 版本,均支持了多线程特性、实时热 key 统计能力、水平扩容异步迁移 slot 能力,存量集群随着日常资源均衡迁移调度,集群节点版本会自动升级到同版本的最新安装包。

  • 多线程特性

Redis6.2 版本支持 IO 多线程,在 Redis 处理读写业务请求数据时使用多线程处理,提高 IO 处理能力,自建 Redis 将多线程能力也移植到了 Redis4.0 版本,测试团队测试显示,开启多线程,读写性能提升明显。

多线程版本 VS 普通版本

多线程版本 VS 云产品5.0版本

  • 实时热 key 统计

自建 Redis4.0 和 Redis6.2 版本均支持 Redis 服务端实时热 key 统计能力,管控台白屏化展示,方便快速排查热 key 导致的集群性能问题。方案详细可阅读《基于Redis内核的热key统计实现方案》

  • 水平扩容异步迁移

自建 Redis 支持水平扩容异步数据迁移,解决大 key 无法迁移或者迁移失败的稳定性问题,支持多 key 并发迁移,几亿 key 数据在默认配置下水平扩容时间从平均 4 小时缩短到 10 分钟性能提升 20 倍,对业务RT影响下降 90% 以上

算法某实例 2.5 亿 key 水平扩容花费时间和迁移过程对业务 RT 影响

3.5 实例架构与规格

Redis单点主备模式

自建 Redis 实例默认均采用集群架构,但是通过 proxy 代理屏蔽集群架构细节,集群架构对业务透明,业务像使用一个单点 Redis 实例一样使用 Redis 集群。

但是集群架构下,由于底层涉及多个分片,不同 key 可能存在在不同分片,并且随着水平扩容,key所在分片可能会发生变化,因此,集群架构下,对于一些多 key 命令(如 eval、evalsha、BLPOP等)要求命令中所有 key 必须属于同一个slot。因此集群架构下,部分命令访问与单点还是有点差异。

实际使用中,有少数业务由于依赖了一些开源的三方组件,其中可能由于存储非常少量的数据,所以使用到 Redis 单点主备模式实例,因此,考虑到这种场景,自建 Redis 在集群架构基础上,也支持了Redis 单点主备模式可供选择。

一主多从规格

自建 Redis 支持一主多从规格用于跨区容灾,提供更快的 HA 效率,当前支持一主一从(默认),一主两从、一主三从 3 种副本规格,支持配置读写分离策略提升系统性能(一主多从规格下,开启读写分离,可以有多个分片承接读流量)

一主一从

一主两从

一主三从

  • 一主一从时默认主备可用区各部署一个副本(master在主可用区)
  • 一主两从时默认主可用区部署一主一从,备可用区部署一从副本
  • 一主三从时默认主可用区部署一主一从,备可用区部署两从副本

3.6 proxy限流

为了应对异常突发流量导致的业务访问性能下降,自建 Redis-proxy 支持限流能力

有部分业务可能存在特殊的已知大key,业务中正常逻辑也不会调用查询大 key 全量数据命令,如 hgetall、smembers 等,查询大 key 全量数据会导致节点性能下降,极端情况下会导致节点主从切换,因此,自建Redis 也支持配置命令黑名单,在特定的集群,禁用某些特定的命令

  • 支持 key 维度限流,指定 key 访问 QPS 阈值
  • 支持命令维度限流,指定命令访问 QPS 阈值
  • 支持命令黑名单,添加黑名单后,该实例禁用此命令

3.7 自动化运维

自建 Redis 系统还包含一个功能完善的自动化运维平台,一直以来,自建Redis一直在完善系统自动化运维能力,通过丰富的自动化运维能力,实现集群全生命周期自动化管理,资源管理与智能调度,故障自动恢复等,提高资源利用率、降低成本,提高运维效率。

  • 资源池自动化均衡调度

自建 Redis 资源池支持按内存使用率自动化均衡调度、按内存分配率自动化均衡调度、按 CPU 使用率均衡调度、支持指定机器凌晨迁移调度(隐患机器提前维护)等功能,均衡资源池中所有资源的负载,提高资源利用率。

  • 集群自动部署与下线

当业务提交集群申请工单审批通过后,判断是否支持自建,如符合自建则自动化进行集群部署和部署结果校验,校验集群可用性后自动给业务交付集群信息,整个过程高效快速。

业务提交集群下线工单后,自动检测是否满足下线条件,比如是否存在访问连接,如满足下线条件,则自动释放 proxy 资源,保留 7 天后自动回收 server 节点资源,在7 天内,如果存在特殊业务仍在使用的情况,还支持快速恢复使用。

  • 资源管理

对 ECS 机器资源和 LB 资源进行打标,根据特殊业务需要做不同资源池的隔离调度,支持在集群部署与扩容时,资源自动智能化分配。

  • 集群扩缩容

自建 Redis 支持 server 自动垂直扩容,业务申请集群时,可以选择是否开启自动扩容,如果开启自动扩容,当集群内存使用率达到80%时,系统会自动进行垂直扩容,对业务完全无感,快速应对业务容量上涨场景。

ecs-proxy,docker-proxy扩容,server节点的扩缩容也支持工单自动化操作,业务提交工单后,系统自动执行。

  • 工单自动化

当前80%以上的运维场景已完成工单自动化,如 Biz 申请、创建实例、密码申请、权限申请、删除key、实例升降配,集群下线等均完成工单自动化。业务提单审批通过后自动校验执行,执行完成后自动发送工单执行结果通知。

  • 告警自动化处理

系统会自动检测机器宕机事件,如发现机器宕机重启,会自动拉起机器上所有节点,快速恢复故障,提高运维效率。

关于自建 Redis 自动化运维能力提升详细设计细节,后续会专门分享,敬请期待。

四、总结

本文详细介绍了自建 Redis 最新技术演进,详细介绍了自研 DRedis SDK优势与目前使用现状,以及 DRedis 在同城双活就近读场景下,可以更精细化的控制部分请求采用优先同区就近读。

介绍了自建 Redis 目前支持最新的 Redis6.2版本,以及在 Redis4.0 和 Redis6.2 版本均支持多线程 IO 能力、实时热 key 统计能力、水平扩容异步迁移能力。自建 Redis 除了支持集群架构,也支持单点主备架构实例申请,同时支持一主多从副本规格,可以提供可靠性和读请求能力(读写分离场景下)。自建 Redis-proxy 也支持多种限流方式,包括 key 维度、命令维度等。

自建 Redis 自动化运维平台支持强大的自动化运维能力,提高资源利用率,降低成本,提高运维效率。

自建 Redis 经过长期的技术迭代演进,目前支持的命令和功能上完全对比云 Redis,同时,自建 Redis 拥有其他一些特色的能力与优势,比如不再依赖LB、支持自动垂直扩容、支持同区优先就近读等。

往期回顾

1. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

2. RN与hawk碰撞的火花之C++异常捕获|得物技术

3. 得物TiDB升级实践

4. 得物管理类目配置线上化:从业务痛点到技术实现

5. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

文 /竹径

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

一、序 言

在分布式系统中,网络请求的可靠性直接决定了服务质量。想象一下,当你的支付系统因第三方API超时导致订单状态不一致,或因瞬时网络抖动造成用户操作失败,这些问题往往源于HTTP客户端缺乏完善的超时控制和重试策略。Golang标准库虽然提供了基础的HTTP客户端实现,但在高并发、高可用场景下,我们需要更精细化的策略来应对复杂的网络环境。

二、超时控制的风险与必要性

2024年Cloudflare的网络报告显示,78%的服务中断事件与不合理的超时配置直接相关。当一个HTTP请求因目标服务无响应而长时间阻塞时,不仅会占用宝贵的系统资源,更可能引发级联故障——大量堆积的阻塞请求会耗尽连接池资源,导致新请求无法建立,最终演变为服务雪崩。超时控制本质上是一种资源保护机制,通过设定合理的时间边界,确保单个请求的异常不会扩散到整个系统。

超时配置不当的两大典型风险:

  • DoS攻击放大效应:缺乏连接超时限制的客户端,在遭遇恶意慢响应攻击时,会维持大量半开连接,迅速耗尽服务器文件描述符。
  • 资源利用率倒挂:当ReadTimeout设置过长(如默认的0表示无限制),慢请求会长期占用连接池资源。Netflix的性能数据显示,将超时时间从30秒优化到5秒后,连接池利用率提升了400% ,服务吞吐量增长2.3倍。

三、超时参数示例

永远不要依赖默认的http.DefaultClient,其Timeout为0(无超时)。生产环境必须显式配置所有超时参数,形成防御性编程习惯。

以下代码展示如何通过net.Dialer配置连接超时和keep-alive策略:

transport := &http.Transport{
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,  // TCP连接建立超时
        KeepAlive: 30 * time.Second, // 连接保活时间
        DualStack: true,             // 支持IPv4/IPv6双栈
    }).DialContext,
    ResponseHeaderTimeout: 5 * time.Second, // 等待响应头超时
    MaxIdleConnsPerHost:   100,             // 每个主机的最大空闲连接
}
client := &http.Client{
    Transport: transport,
    Timeout:   10 * time.Second, // 整个请求的超时时间
}

四、基于context的超时实现

context.Context为请求超时提供了更灵活的控制机制,特别是在分布式追踪和请求取消场景中。与http.Client的超时参数不同,context超时可以实现请求级别的超时传递,例如在微服务调用链中传递超时剩余时间。

4.1 上下文超时传递

如图所示,context通过WithTimeout或WithDeadline创建超时上下文,在请求过程中逐级传递。当父context被取消时,子context会立即终止请求,避免资源泄漏。

4.2 带追踪的超时控制

func requestWithTracing(ctx context.Context) (*http.Response, error) {
    // 从父上下文派生5秒超时的子上下文
    ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
    defer cancel() // 确保无论成功失败都取消上下文
    
    req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
    if err != nil {
        return nil, fmt.Errorf("创建请求失败: %v", err)
    }
    
    // 添加分布式追踪信息
    req.Header.Set("X-Request-ID", ctx.Value("request-id").(string))
    
    client := &http.Client{
        Transport: &http.Transport{
            DialContext: (&net.Dialer{
                Timeout: 2 * time.Second,
            }).DialContext,
        },
        // 注意: 此处不设置Timeout,完全由context控制
    }
    
    resp, err := client.Do(req)
    if err != nil {
        // 区分上下文取消和其他错误
        if ctx.Err() == context.DeadlineExceeded {
            return nil, fmt.Errorf("请求超时: %w", ctx.Err())
        }
        return nil, fmt.Errorf("请求失败: %v", err)
    }
    return resp, nil
}

关键区别:context.WithTimeout与http.Client.Timeout是叠加关系而非替代关系。当同时设置时,取两者中较小的值。

五、重试策略

网络请求失败不可避免,但盲目重试可能加剧服务负载,甚至引发惊群效应。一个健壮的重试机制需要结合错误类型判断、退避算法和幂等性保证,在可靠性和服务保护间取得平衡。

5.1 指数退避与抖动

指数退避通过逐渐增加重试间隔,避免对故障服务造成二次冲击。Golang实现中需加入随机抖动,防止多个客户端同时重试导致的波峰效应

以下是简单的重试实现示例:

type RetryPolicy struct {
    MaxRetries    int
    InitialBackoff time.Duration
    MaxBackoff    time.Duration
    JitterFactor  float64 // 抖动系数,建议0.1-0.5
}


// 带抖动的指数退避
func (rp *RetryPolicy) Backoff(attempt int) time.Duration {
    if attempt <= 0 {
        return rp.InitialBackoff
    }
    // 指数增长: InitialBackoff * 2^(attempt-1)
    backoff := rp.InitialBackoff * (1 << (attempt - 1))
    if backoff > rp.MaxBackoff {
        backoff = rp.MaxBackoff
    }
    // 添加抖动: [backoff*(1-jitter), backoff*(1+jitter)]
    jitter := time.Duration(rand.Float64() * float64(backoff) * rp.JitterFactor)
    return backoff - jitter + 2*jitter // 均匀分布在抖动范围内
}


// 通用重试执行器
func Retry(ctx context.Context, policy RetryPolicy, fn func() error) error {
    var err error
    for attempt := 0; attempt <= policy.MaxRetries; attempt++ {
        if attempt > 0 {
            // 检查上下文是否已取消
            select {
            case <-ctx.Done():
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            default:
            }
            
            backoff := policy.Backoff(attempt)
            timer := time.NewTimer(backoff)
            select {
            case <-timer.C:
            case <-ctx.Done():
                timer.Stop()
                return fmt.Errorf("重试被取消: %w", ctx.Err())
            }
        }
        
        err = fn()
        if err == nil {
            return nil
        }
        
        // 判断是否应该重试
        if !shouldRetry(err) {
            return err
        }
    }
    return fmt.Errorf("达到最大重试次数 %d: %w", policy.MaxRetries, err)
}

5.2 错误类型判断

盲目重试所有错误不仅无效,还可能导致数据不一致。shouldRetry函数需要精确区分可重试错误类型:

func shouldRetry(err error) bool {
    // 网络层面错误
    var netErr net.Error
    if errors.As(err, &netErr) {
        // 超时错误和临时网络错误可重试
        return netErr.Timeout() || netErr.Temporary()
    }
    
    // HTTP状态码判断
    var respErr *url.Error
    if errors.As(err, &respErr) {
        if resp, ok := respErr.Response.(*http.Response); ok {
            switch resp.StatusCode {
            case 429, 500, 502, 503, 504:
                return true // 限流和服务器错误可重试
            case 408:
                return true // 请求超时可重试
            }
        }
    }
    
    // 应用层自定义错误
    if errors.Is(err, ErrRateLimited) || errors.Is(err, ErrServiceUnavailable) {
        return true
    }
    
    return false
}

行业最佳实践:Netflix的重试策略建议:对5xx错误最多重试3次,对429错误使用Retry-After头指定的间隔,对网络错误使用指数退避(初始100ms,最大5秒)。

六、幂等性保证

重试机制的前提是请求必须是幂等的,否则重试可能导致数据不一致(如重复扣款)。实现幂等性的核心是确保多次相同请求产生相同的副作用,常见方案包括请求ID机制和乐观锁。

6.1 请求ID+Redis实现

基于UUID请求ID和Redis的幂等性检查机制,可确保重复请求仅被处理一次:

type IdempotentClient struct {
    redisClient *redis.Client
    prefix      string        // Redis键前缀
    ttl         time.Duration // 幂等键过期时间
}


// 生成唯一请求ID
func (ic *IdempotentClient) NewRequestID() string {
    return uuid.New().String()
}


// 执行幂等请求
func (ic *IdempotentClient) Do(req *http.Request, requestID string) (*http.Response, error) {
    // 检查请求是否已处理
    key := fmt.Sprintf("%s:%s", ic.prefix, requestID)
    exists, err := ic.redisClient.Exists(req.Context(), key).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等检查失败: %v", err)
    }
    if exists == 1 {
        // 返回缓存的响应或标记为重复请求
        return nil, fmt.Errorf("请求已处理: %s", requestID)
    }
    
    // 使用SET NX确保只有一个请求能通过检查
    set, err := ic.redisClient.SetNX(
        req.Context(),
        key,
        "processing",
        ic.ttl,
    ).Result()
    if err != nil {
        return nil, fmt.Errorf("幂等锁失败: %v", err)
    }
    if !set {
        return nil, fmt.Errorf("并发请求冲突: %s", requestID)
    }
    
    // 执行请求
    client := &http.Client{/* 配置 */}
    resp, err := client.Do(req)
    if err != nil {
        // 请求失败时删除幂等标记
        ic.redisClient.Del(req.Context(), key)
        return nil, err
    }
    
    // 请求成功,更新幂等标记状态
    ic.redisClient.Set(req.Context(), key, "completed", ic.ttl)
    return resp, nil
}

关键设计:幂等键的TTL应大于最大重试周期+业务处理时间。例如,若最大重试间隔为30秒,处理耗时5秒,建议TTL设置为60秒,避免重试过程中键过期导致的重复处理。

6.2 业务层幂等策略

对于写操作,还需在业务层实现幂等逻辑:

  • 更新操作:使用乐观锁(如UPDATE ... WHERE version = ?)
  • 创建操作:使用唯一索引(如订单号、外部交易号)
  • 删除操作:采用"标记删除"而非物理删除

七、性能优化

高并发场景下,HTTP客户端的性能瓶颈通常不在于网络延迟,而在于连接管理和内存分配。通过合理配置连接池和复用资源,可显著提升吞吐量。

7.1 连接池配置

http.Transport的连接池参数优化对性能影响巨大,以下是经过生产验证的配置:

func NewOptimizedTransport() *http.Transport {
    return &http.Transport{
        // 连接池配置
        MaxIdleConns:        1000,  // 全局最大空闲连接
        MaxIdleConnsPerHost: 100,   // 每个主机的最大空闲连接
        IdleConnTimeout:     90 * time.Second, // 空闲连接超时时间
        
        // TCP配置
        DialContext: (&net.Dialer{
            Timeout:   2 * time.Second,
            KeepAlive: 30 * time.Second,
        }).DialContext,
        
        // TLS配置
        TLSHandshakeTimeout: 5 * time.Second,
        TLSClientConfig: &tls.Config{
            InsecureSkipVerify: false,
            MinVersion:         tls.VersionTLS12,
        },
        
        // 其他优化
        ExpectContinueTimeout: 1 * time.Second,
        DisableCompression:    false, // 启用压缩
    }
}

Uber的性能测试显示,将MaxIdleConnsPerHost从默认的2提升到100后,针对同一API的并发请求延迟从85ms降至12ms,吞吐量提升6倍。

7.2 sync.Pool内存复用

频繁创建http.Request和http.Response会导致大量内存分配和GC压力。使用sync.Pool复用这些对象可减少90%的内存分配:

var requestPool = sync.Pool{
    New: func() interface{} {
        return &http.Request{
            Header: make(http.Header),
        }
    },
}


// 从池获取请求对象
func AcquireRequest() *http.Request {
    req := requestPool.Get().(*http.Request)
    // 重置必要字段
    req.Method = ""
    req.URL = nil
    req.Body = nil
    req.ContentLength = 0
    req.Header.Reset()
    return req
}


// 释放请求对象到池
func ReleaseRequest(req *http.Request) {
    requestPool.Put(req)
}

八、总结

HTTP请求看似简单,但它连接着整个系统的"血管"。忽视超时和重试,就像在血管上留了个缺口——平时没事,压力一来就大出血。构建高可靠的网络请求需要在超时控制、重试策略、幂等性保证和性能优化之间取得平衡。

记住,在分布式系统中,超时和重试不是可选功能,而是生存必需。

扩展资源:

往期回顾

  1. RN与hawk碰撞的火花之C++异常捕获|得物技术

  2. 得物TiDB升级实践

  3. 得物管理类目配置线上化:从业务痛点到技术实现

  4. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  5. RAG—Chunking策略实战|得物技术

文 /梧

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物TiDB升级实践

一、背 景

得物DBA自2020年初开始自建TiDB,5年以来随着NewSQL数据库迭代发展、运维体系逐步完善、产品自身能力逐步提升,接入业务涵盖了多个业务线和关键场景。从第一套TIDB v4.0.9 版本开始,到后来v4.0.11、v5.1.1、v5.3.0,在经历了各种 BUG 踩坑、问题调试后,最终稳定在 TIDB 5.3.3 版本。伴随着业务高速增长、数据量逐步增多,对 TiDB 的稳定性及性能也带来更多挑战和新的问题。为了应对这些问题,DBA团队决定对 TiDB 进行一次版本升级,收敛版本到7.5.x。本文基于内部的实践情况,从架构、新特性、升级方案及收益等几个方向讲述 TiDB 的升级之旅。

二、TiDB 架构

TiDB 是分布式关系型数据库,高度强兼容 MySQL 协议和 MySQL 生态,稳定适配 MySQL 5.7 和MySQL 8.0常用的功能及语法。随着版本的迭代,TiDB 在弹性扩展、分布式事务、强一致性基础上进一步针对稳定性、性能、易用性等方面进行优化和增强。与传统的单机数据库相比,TiDB具有以下优势:

  • 分布式架构,拥有良好的扩展性,支持对业务透明灵活弹性的扩缩容能力,无需分片键设计以及开发运维。
  • HTAP 架构支撑,支持在处理高并发事务操作的同时,对实时数据进行复杂分析,天然具备事务与分析物理隔离能力。
  • 支持 SQL 完整生态,对外暴露 MySQL 的网络协议,强兼容 MySQL 的语法/语义,在大多数场景下可以直接替换 MySQL。
  • 默认支持自愈高可用,在少数副本失效的情况下,数据库本身能够自动进行数据修复和故障转移,对业务无感。
  • 支持 ACID 事务,对于一些有强一致需求的场景友好,满足 RR 以及 RC 隔离级别,可以在通用开发框架完成业务开发迭代。

我们使用 SLB 来实现 TiDB 的高效负载均衡,通过调整 SLB 来管理访问流量的分配以及节点的扩展和缩减。确保在不同流量负载下,TiDB 集群能够始终保持稳定性能。在 TiDB 集群的部署方面,我们采用了单机单实例的架构设计。TiDB Server 和 PD Server 均选择了无本地 SSD 的机型,以优化资源配置,并降低开支。TiKV Server则配置在本地 SSD 的机型上,充分利用其高速读写能力,提升数据存储和检索的性能。这样的硬件配置不仅兼顾了系统的性能需求,又能降低集群成本。针对不同的业务需求,我们为各个组件量身定制了不同的服务器规格,以确保在多样化的业务场景下,资源得到最佳的利用,进一步提升系统的运行效率和响应速度。

三、TiDB v7 版本新特性

新版本带来了更强大的扩展能力和更快的性能,能够支持超大规模的工作负载,优化资源利用率,从而提升集群的整体性能。在 SQL 功能方面,它提升了兼容性、灵活性和易用性,从而助力复杂查询和现代应用程序的高效运行。此外,网络 IO 也进行了优化,通过多种批处理方法减少网络交互的次数,并支持更多的下推算子。同时,优化了Region 调度算法,显著提升了性能和稳定性。

四、TiDB升级之旅

4.1 当前存在的痛点

  • 集群版本过低:当前 TiDB 生产环境(现网)最新版本为 v5.3.3,目前官方已停止对 4.x 和 5.x 版本的维护及支持,TiDB 内核最新版本为 v8.5.3,而被用户广泛采用且最为稳定的版本是 v7.5.x。
  • TiCDC组件存在风险:TiCDC 作为增量数据同步工具,在 v6.5.0 版本以前在运行稳定性方面存在一定问题,经常出现数据同步延迟问题或者 OOM 问题。
  • 备份周期时间长:集群每天备份时间大于8小时,在此期间,数据库备份会导致集群负载上升超过30%,当备份时间赶上业务高峰期,会导致应用RT上升。
  • 集群偶发抖动及BUG:在低版本集群中,偶尔会出现基于唯一键查询的慢查询现象,同时低版本也存在一些影响可用性的BUG。比如在 TiDB v4.x 的集群中,TiKV 节点运行超过 2 年会导致节点自动重启。

4.2 升级方案:升级方式

TiDB的常见升级方式为原地升级和迁移升级,我们所有的升级方案均采用迁移升级的方式。

原地升级

  • 优势:方式较为简单,不需要额外的硬件,升级过程中集群仍然可以对外提供服务。
  • 劣势:该升级方案不支持回退、并且升级过程会有长时间的性能抖动。大版本(v4/v5 原地升级到 v7)跨度较大时,需要版本递增升级,抖动时间翻倍。

迁移升级

  • 优势:业务影响时间较短、可灰度可回滚、不受版本跨度的影响。
  • 劣势:搭建新集群将产生额外的成本支出,同时,原集群还需要部署TiCDC组件用于增量同步。

4.3 升级方案:集群调研

4.4 升级方案:升级前准备环境

4.5 升级方案:升级前验证集群

4.6 升级方案:升级中流量迁移

4.7 升级方案:升级后销毁集群

五、升级遇到的问题

5.1 v7.5.x版本查询SQL倾向全表扫描

表中记录数 215亿,查询 SQL存在合理的索引,但是优化器更倾向走全表扫描,重新收集表的统计信息后,执行计划依然为全表扫描。

走全表扫描执行60秒超时KILL,强制绑定索引仅需0.4秒。

-- 查询SQL
SELECT
  *
FROM
  fin_xxx_xxx
WHERE
  xxx_head_id = 1111111111111111
  AND xxx_type = 'XX0002'
  AND xxx_user_id = 11111111
  AND xxx_pay_way = 'XXX00000'
  AND is_del IN ('N', 'Y')
LIMIT
  1;


-- 涉及索引
KEY `idx_xxx` (`xxx_head_id`,`xxx_type`,`xxx_status`),

解决方案:

  • 方式一:通过 SPM 进行 SQL 绑定。
  • 方式二:调整集群参数 tidb_opt_prefer_range_scan,将该变量值设为 ON 后,优化器总是偏好区间扫描而不是全表扫描。

asktug.com/t/topic/104…

5.2 v7.5.x版本聚合查询执行计划不准确

集群升级后,在新集群上执行一些聚合查询或者大范围统计查询时无法命中有效索引。而低版本v4.x、5.x集群,会根据统计信息选择走合适的索引。

v4.0.11集群执行耗时:12秒,新集群执行耗时2分32.78秒

-- 查询SQL
select 
    statistics_date,count(1) 
from 
    merchant_assessment_xxx 
where 
    create_time between '2025-08-20 00:00:00' and '2025-09-09 00:00:00' 
group by 
    statistics_date order by statistics_date;


-- 涉及索引
KEY `idx_create_time` (`create_time`)

解决方案:

方式一:调整集群参数tidb_opt_objective,该变量设为 determinate后,TiDB 在生成执行计划时将不再使用实时统计信息,这会让执行计划相对稳定。

asktug.com/t/topic/104…

六、升级带来的收益

版本升级稳定性增强:v7.5.x 版本的 TiDB 提供了更高的稳定性和可靠性,高版本改进了SQL优化器、增强的分布式事务处理能力等,加快了响应速度和处理大量数据的能力。升级后相比之前整体性能提升40%。特别是在处理复杂 SQL 和多索引场景时,优化器的性能得到了极大的增强,减少了全表扫描的发生,从而显著降低了 TiKV 的 CPU 消耗和 TiDB 的内存使用。

应用平均RT提升44.62%

原集群RT(平均16.9ms)

新集群RT(平均9.36ms)

新集群平均RT提升50%,并且稳定性增加,毛刺大幅减少

老集群RT(平均250ms)

新集群RT(平均125ms)

提升TiCDC同步性能:新版本在数据同步方面有了数十倍的提升,有效解决了之前版本中出现的同步延迟问题,提供更高的稳定性和可靠性。当下游需要订阅数据至数仓或风控平台时,可以使用TiCDC将数据实时同步至Kafka,提升数据处理的灵活性与响应能力。

缩短备份时间:数据库备份通常会消耗大量的CPU和IO资源。此前,由于备份任务的结束时间恰逢业务高峰期,经常导致应用响应时间(RT)上升等问题。通过进行版本升级将备份效率提升了超过50%。

高压缩存储引擎:新版本采用了高效的数据压缩算法,能够显著减少存储占用。同时,通过优化存储结构,能够快速读取和写入数据,提升整体性能。相同数据在 TiDB 中的存储占用空间更低,iDB 的3副本数据大小仅为 MySQL(主实例数据大小)的 55%。

完善的运维体验:新版本引入更好的监控工具、更智能的故障诊断机制和更简化的运维流程,提供了改进的 Dashboard 和 Top SQL 功能,使得慢查询和问题 SQL 的识别更加直观和便捷,降低 DBA 的工作负担。

更秀更实用的新功能:TiDB 7.x版本提供了TTL定期自动删除过期数据,实现行级别的生命周期控制策略。通过为表设置 TTL 属性,TiDB 可以周期性地自动检查并清理表中的过期数据。此功能在一些场景可以有效节省存储空间、提升性能。TTL 常见的使用场景:

  • 定期删除验证码、短网址记录
  • 定期删除不需要的历史订单
  • 自动删除计算的中间结果

docs.pingcap.com/zh/tidb/v7.…

七、选择 TiDB 的原因

我们不是为了使用TiDB而使用,而是去解决一些MySQL无法满足的场景,关系型数据库我们还是优先推荐MySQL。能用分库分表能解决的问题尽量选择MySQL,毕竟运维成本相对较低、数据库版本更加稳定、单点查询速度更快、单机QPS性能更高这些特性是分布式数据库无法满足的。

  • 非分片查询场景:上游 MySQL 采用了分库分表的设计,但部分业务查询无法利用分片。通过自建 DTS 将 MySQL 数据同步到 TiDB 集群,非分片/聚合查询则使用 TiDB 处理,能够在不依赖原始分片结构的情况下,实现高效的数据查询和分析。
  • 分析 SQL 多场景:业务逻辑比较复杂,往往存在并发查询和分析查询的需求。通过自建 DTS 将 MySQL 数据同步到 TiDB,复杂查询在TiDB执行、点查在MySQL执行。TiDB支持水平扩展,其分布式计算和存储能力使其能够高效处理大量的并发查询请求。既保障了MySQL的稳定性,又提升了整体的查询能力。
  • 磁盘使用大场景:在磁盘使用率较高的情况下,可能会出现 CPU 和内存使用率低,但磁盘容量已达到 MySQL 的瓶颈。TiDB 能够自动进行数据分片和负载均衡,将数据分布在多个节点上, 缓解单一节点的磁盘压力,避免了传统 MySQL 中常见的存储瓶颈问题,从而提高系统的可扩展性和灵活性。
  • 数据倾斜场景:在电商业务场景上,每个电商平台都会有一些销量很好的头部卖家,数据量会很大。即使采取了进行分库分表的策略,仍难以避免大卖家的数据会存储在同一实例中,这样会导致热点查询和慢 SQL 问题,尽管可以通过添加索引或进一步分库分表来优化,但效果有限。采用分布式数据库能够有效解决这一问题。可以将数据均匀地分散存储在多个节点上,在查询时则能够并发执行,从而将流量分散,避免热点现象的出现。随着业务的快速发展和数据量的不断增长,借助简单地增加节点,即可实现水平扩展,满足海量数据及高并发的需求。

八、总结

综上所述,在本次 TiDB 集群版本升级到 v7.5.x 版本过程中,实现了性能和稳定性提升。通过优化的查询计划和更高效的执行引擎,数据读取和写入速度显著提升,大幅度降低了响应延迟,提升了在高并发操作下的可靠性。通过直观的监控界面和更全面的性能分析工具,能够更快速地识别和解决潜在问题,降低 DBA 的工作负担。也为未来的业务扩展和系统稳定性提供了强有力的支持。

后续依然会持续关注 TiDB 在 v8.5.x 版本稳定性、性能以及新产品特性带来应用开发以及运维人效收益进展。目前 TiDB 内核版本 v8.5.x 已经具备多模数据库 Data + AI 能力,在JSON函数、ARRAY 索引以及 Vector Index 实现特性。同时已经具备 Resource Control 资源管理能力,适合进行多业务系统数据归集方案,实现数据库资源池化多种自定义方案。技术研究方面我们数据库团队会持续投入,将产品最好的解决方案引入现网环境。

往期回顾

  1. 得物管理类目配置线上化:从业务痛点到技术实现

  2. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  3. RAG—Chunking策略实战|得物技术

  4. 告别数据无序:得物数据研发与管理平台的破局之路

  5. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

文 /岱影

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

得物管理类目配置线上化:从业务痛点到技术实现

一、引言

在电商交易领域,管理类目作为业务责权划分、统筹、管理核心载体,随着业务复杂性的提高,其规则调整频率从最初的 1 次 / 季度到多次 / 季度,三级类目的规则复杂度也呈指数级上升。传统依赖数仓底层更新的方式暴露出三大痛点:

  • 行业无法自主、快速调管理类目;
  • 业务管理类目规则调整,不支持校验类目覆盖范围是否有重复/遗漏,延长交付周期;
  • 规则变更成功后、下游系统响应滞后,无法及时应用最新类目规则。

本文将从技术视角解析 “管理类目配置线上化” 项目如何通过全链路技术驱动,将规则迭代周期缩短至 1-2 天。

二、业务痛点与技术挑战:为什么需要线上化?

2.1 效率瓶颈:手工流程与

高频迭代的矛盾

问题场景:业务方需线下通过数仓提报规则变更,经数仓开发、测试、BI需要花费大量精力校验确认,一次类目变更需 3-4 周左右时间才能上线生效,上线时间无法保证。

技术瓶颈:数仓离线同步周期长(T+1),规则校验依赖人工梳理,无法应对 “商品类目量级激增”。

2.2 质量风险:规则复杂度与

校验能力的失衡

典型问题:当前的管理类目映射规则,依赖业务收集提报,但从实际操作看管理三级类目映射规则提报质量较差(主要原因为:业务无法及时校验提报规则是否准确,是否穷举完善,是否完全无交叉),存在大量重复 / 遗漏风险。

2.3 系统耦合:底层变更对

下游应用的多米诺效应

连锁影响:管理类目规则变更会需同步更新交易后台、智能运营系统、商运关系工作台等多下游系统,如无法及时同步,可能会影响下游应用如商运关系工作台的员工分工范围的准确性,影响商家找人、资质审批等场景应用。

三、技术方案:从架构设计到核心模块拆解

3.1 分层架构:解耦业务与数据链路

3.2 核心模块技术实现

规则生命周期管理: 规则操作流程

提交管理类目唯一性校验规则

新增:id为空,则为新增

删除:当前db数据不在提交保存列表中

更新:名称或是否兜底类目或规则改变则发生更新【其中如果只有名称改变则只触发审批,不需等待数据校验,业务规则校验逻辑为将所有规则包含id,按照顺序排序拼接之后结果是否相等】

多级类目查询

构建管理类目树

/**
 * 构建管理类目树
 */
public List<ManagementCategoryDTO> buildTree(List<ManagementCategoryEntity> managementCategoryEntities) {
    Map<Long, ManagementCategoryDTO> managementCategoryMap = new HashMap<>();
    for (ManagementCategoryEntity category : managementCategoryEntities) {
        ManagementCategoryDTO managementCategoryDTO = ManagementCategoryMapping.convertEntity2DTO(category);
        managementCategoryMap.put(category.getId(), managementCategoryDTO);
    }
    
    // 找到根节点
    List<ManagementCategoryDTO> rootNodes = new ArrayList<>();
    for (ManagementCategoryDTO categoryNameDTO : managementCategoryMap.values()) {
        //管理一级类目 parentId是0
        if (Objects.equals(categoryNameDTO.getLevel(), ManagementCategoryLevelEnum.FIRST.getId()) && Objects.equals(categoryNameDTO.getParentId(), 0L)) {
            rootNodes.add(categoryNameDTO);
        }
    }
    // 构建树结构
    for (ManagementCategoryDTO node : managementCategoryMap.values()) {
        if (node.getLevel() > ManagementCategoryLevelEnum.FIRST.getId()) {
            ManagementCategoryDTO parentNode = managementCategoryMap.get(node.getParentId());
            if (parentNode != null) {
                parentNode.getItems().add(node);
            }
        }
    }
    return rootNodes;
}

填充管理类目规则



/**
 * 填充规则信息
 */
private void populateRuleData
(List<ManagementCategoryDTO> managementCategoryDTOS, List<ManagementCategoryRuleEntity> managementCategoryRuleEntities) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || CollectionUtils.isEmpty(managementCategoryRuleEntities)) {
        return;
    }
    List<ManagementCategoryRuleDTO> managementCategoryRuleDTOS =managementCategoryMapping.convertRuleEntities2DTOS(managementCategoryRuleEntities);
    // 将规则集合按 categoryId 分组
    Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap = managementCategoryRuleDTOS.stream()
            .collect(Collectors.groupingBy(ManagementCategoryRuleDTO::getCategoryId));
    // 递归填充规则到树结构
    fillRulesRecursively(managementCategoryDTOS, rulesByCategoryIdMap);


}


/**
 * 递归填充规则到树结构
 */
private static void fillRulesRecursively
(List<ManagementCategoryDTO> managementCategoryDTOS, Map<Long, List<ManagementCategoryRuleDTO>> rulesByCategoryIdMap) {
    if (CollectionUtils.isEmpty(managementCategoryDTOS) || MapUtils.isEmpty(rulesByCategoryIdMap)) {
        return;
    }
    for (ManagementCategoryDTO node : managementCategoryDTOS) {
        // 获取当前节点对应的规则列表
        List<ManagementCategoryRuleDTO> rules = rulesByCategoryIdMap.getOrDefault(node.getId(), new ArrayList<>());
        node.setRules(rules);
        // 递归处理子节点
        fillRulesRecursively(node.getItems(), rulesByCategoryIdMap);
    }
}

状态机驱动:管理类目生命周期管理

超时机制 :基于时间阈值的流程阻塞保护

其中,为防止长时间运营处于待确认规则状态,造成其他规则阻塞规则修改,定时判断待确认规则状态持续时间,当时间超过xxx时间之后,则将待确认状态改为长时间未操作,放弃变更状态,并飞书通知规则修改人。

管理类目状态变化级联传播策略

类目生效和失效状态为级联操作。规则如下:

  • 管理二级类目有草稿状态时,不允许下挂三级类目的编辑;
  • 管理三级类目有草稿状态时,不允许对应二级类目的规则编辑;
  • 类目生效失效状态为级联操作,上层修改下层级联修改状态,如果下层管理类目存在草稿状态,则自动更改为放弃更改状态。

规则变更校验逻辑

当一次提交,可能出现的情况如下。一次提交可能会产生多个草稿,对应多个审批流程。

新增管理类目规则:

  • 一级管理类目可以直接新增(点击新增一级管理类目)
  • 二级管理类目和三级管理类目不可同时新增
  • 三级管理类目需要在已有二级类目基础上新增

只有名称修改触发直接审批,有规则修改需要等待数仓计算结果之后,运营提交发起审批。

交互通知中心:飞书卡片推送

  • 变更规则数据计算结果依赖数仓kafka计算结果回调。
  • 基于飞书卡片推送数仓计算结果,回调提交审批和放弃变更事件。

飞书卡片:

卡片结果

卡片操作结果

审批流程:多维度权限控制与飞书集成

提交审批的四种情况:

  • 名称修改
  • 一级类目新增
  • 管理类目规则修改
  • 生效失效变更

审批通过,将草稿内容更新到管理类目表中,将管理类目设置为生效中。

审批驳回,清空草稿内容。

审批人分配机制:多草稿并行审批方案

一次提交可能会产生多个草稿,对应多个审批流程。

审批逻辑

public Map<String, List<String>> buildApprover(
        ManagementCategoryDraftEntity draftEntity,
        Map<Long, Set<String>> catAuditorMap,
        Map<String, String> userIdOpenIdMap,
        Integer hasApprover) {
    
    Map<String, List<String>> nodeApprover = new HashMap<>();


    // 无审批人模式,直接查询超级管理员
    if (!Objects.equals(hasApprover, ManagementCategoryUtils.HAS_APPROVER_YES)) {
        nodeApprover.put(ManagementCategoryApprovalField.NODE_SUPER_ADMIN_AUDIT,
                queryApproverList(0L, catAuditorMap, userIdOpenIdMap));
        return nodeApprover;
    }
    
    Integer level = draftEntity.getLevel();
    Integer draftType = draftEntity.getType();
    boolean isEditOperation = ManagementCategoryDraftTypeEnum.isEditOp(draftType);
    
    // 动态构建审批链(支持N级类目)
    List<Integer> approvalChain = buildApprovalChain(level);
    for (int i = 0; i < approvalChain.size(); i++) {
        int currentLevel = approvalChain.get(i);
        Long categoryId = getCategoryIdByLevel(draftEntity, currentLevel);
        
        // 生成节点名称(如:NODE_LEVEL2_ADMIN_AUDIT)
        String nodeKey = String.format(
                ManagementCategoryApprovalField.NODE_LEVEL_X_ADMIN_AUDIT_TEMPLATE,
                currentLevel
        );
        
        // 编辑操作且当前层级等于提交层级时,添加本级审批人 【新增的管理类目没有还没有对应的审批人】
        if (isEditOperation && currentLevel == level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
        
        // 非本级审批人(上级层级)
        if (currentLevel != level) {
            addApprover(nodeApprover, nodeKey, categoryId, catAuditorMap, userIdOpenIdMap);
        }
    }
    
    return nodeApprover;
}


private List<Integer> buildApprovalChain(Integer level) {
    List<Integer> approvalChain = new ArrayList<>();
    if (level == 3) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 2) {
        approvalChain.add(2); // 管二审批人
        approvalChain.add(1); // 管一审批人
    } else if (level == 1) {
        approvalChain.add(1); // 管一审批人
        approvalChain.add(0); // 超管
    }
    return approvalChain;
}

3.3 数据模型设计

3.4 数仓计算逻辑

同步数据方式

方案一:

每次修改规则之后通过调用SQL触发离线计算

优势:通过SQL调用触发计算,失效性较高

劣势:ODPS 资源峰值消耗与SQL脚本耦合问题

  • 因为整个规则修改是三级类目维度,如果同时几十几百个类目触发规则改变,会同时触发几十几百个离线任务。同时需要大量ODPS 资源;
  • 调用SQL方式需要把当前规则修改和计算逻辑的SQL一起调用计算。

方案二:

优势:同时只会产生一次规则计算

劣势:实时性受限于离线计算周期

  • 实时性取决于离线规则计算的定时任务配置和离线数据同步频率,实时性不如直接调用SQL性能好
  • 不重不漏为当前所有变更规则维度

技术决策:常态化迭代下的最优解

考虑到管理类目规则平均变更频率不高,且变更时间点较为集中(非紧急场景占比 90%),故选择定时任务方案实现:

  • 资源利用率提升:ODPS 计算资源消耗降低 80%,避免批量变更时数百个任务同时触发的资源峰值;
  • 完整性保障:通过全量维度扫描确保规则校验无遗漏,较 SQL 触发方案提升 20% 校验覆盖率;
  • 可维护性优化:减少 SQL 脚本与业务逻辑的强耦合,维护成本降低 80%。

数据取数逻辑

生效中规则计算

草稿+生效中规格计算

如果是新增管理类目,直接参与计算。

如果是删除管理类目,需要将该删除草稿中对应的生效管理类目排除掉。

如果是更新:需要将草稿中的管理类目和规则替换生效中对应的管理类目和规则。

数仓实现

数据流程图

四、项目成果与技术价值

预期效率提升:从 “周级” 到 “日级” 的跨越

  • 管理一级 / 二级类目变更开发零成本,无需额外人力投入
  • 管理三级类目变更相关人力成本降低 100%,无需额外投入开发资源
  • 规则上线周期压缩超 90%,仅需 1 - 2 天即可完成上线

质量保障:自动化校验替代人工梳理

  • 规则重复 / 遗漏检测由人工梳理->自动化计算
  • 下游感知管理类目规则变更由人工通知->实时感知

技术沉淀:规则模型化能力

沉淀管理类目规则配置模型,支持未来四级、五级多级管理类目快速适配。

五、总结

未来优化方向:

  1. 规则冲突预警:基于AI预测高风险规则变更,提前触发校验
  2. 接入flink做到实时计算管理类目和对应商品关系

技术重构的本质是 “释放业务创造力”

管理类目配置线上化项目的核心价值,不仅在于技术层面的效率提升,更在于通过自动化工具链,让业务方从 “规则提报的执行者” 转变为 “业务策略的设计者”。当技术架构能够快速响应业务迭代时,企业才能在电商领域的高频竞争中保持创新活力。

往期回顾

  1. 大模型如何革新搜索相关性?智能升级让搜索更“懂你”|得物技术

  2. RAG—Chunking策略实战|得物技术

  3. 告别数据无序:得物数据研发与管理平台的破局之路

  4. 从一次启动失败深入剖析:Spring循环依赖的真相|得物技术

  5. Apex AI辅助编码助手的设计和实践|得物技术

文 /维山

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌