BvBeJ的技术与生活记录
TypeScript 运行时校验:为什么我用 Zod 兜底
编译期类型不是防弹衣 TypeScript 很强,但它无法保证运行时数据一定符合类型。只要数据来自网络、表单、消息队列,就需要校验。 一个常见坑 type User = { id: string; age: number } function handleUser(u: User) { return u.age + 1 } 如果后端把 age 传成字符串,编译不会报错,运行才炸。 用 Zod 建立输入防线 import { z } from "zod" const UserSchema = z.object({ id: z.string().min(1), age: z.number().int().nonnegative(), }) type User = z.infer<typeof UserSchema> function parseUser(input: unknown): User { return UserSchema.parse(input) } 好处是: 校验规则集中管理 报错信息可读 类型与校验规则同步 在 API 层统一拦截 建议在 BFF 或网关层就完成 parse,业务层只接收已验证的数据。这样能显著减少线上脏数据导致的连锁异常。 小结 TS 负责编译期,Zod 负责运行时。两者结合,才能让类型安全真正落地。
Vue3 + TypeScript 组件模式:从能用到可维护
痛点 项目初期组件写得很快,半年后常见症状是: props 越加越多 事件命名混乱 业务逻辑散在模板与组件里 要解决这些问题,关键是建立稳定的组件边界。 用 defineProps 和 defineEmits 明确契约 const props = defineProps<{ modelValue: string disabled?: boolean }>() const emit = defineEmits<{ (e: "update:modelValue", val: string): void (e: "submit"): void }>() 契约清楚后,重构成本会明显降低。 分离容器组件与展示组件 容器组件负责数据获取、状态管理 展示组件只关心渲染和交互事件 这能避免“万能组件”不断膨胀。 组合式函数复用逻辑 export function usePagination() { const page = ref(1) const pageSize = ref(20) const setPage = (v: number) => (page.value = v) return { page, pageSize, setPage } } 把重复逻辑提炼到 composable,比 mixin 更直观也更可控。 结语 Vue3 + TS 的上限很高,但前提是组件契约、职责边界、逻辑复用这三件事先做好。代码会更稳,也更容易协作。
Docker BuildKit 提速:缓存策略的正确打开方式
慢构建的根因 很多项目 Docker 构建慢,不是机器不够快,而是缓存命中策略有问题: 频繁变动的文件放在前面导致层失效 依赖下载每次重来 多阶段构建没有复用中间层 先调整 Dockerfile 顺序 典型 Node 项目可参考: FROM node:20-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html 先拷贝依赖清单再安装,能最大化命中缓存层。 用 BuildKit 缓存挂载 RUN --mount=type=cache,target=/root/.cache/go-build \ --mount=type=cache,target=/go/pkg/mod \ go build -o app ./cmd/server 这类挂载尤其适合 Go/Rust 的依赖与编译缓存。 CI 中的实践 使用远程缓存导入导出 主分支构建作为缓存热源 避免无意义的 --no-cache 小结 镜像构建优化不是玄学,核心就三点: 层次顺序 缓存挂载 CI 缓存复用 把这三点做好,开发反馈速度会提升一个量级。
Kubernetes 探针与优雅退出:避免滚动发布抖动
常见事故模式 发布时最容易看到这几类问题: Pod 刚启动就被打流量,依赖还没就绪 应用短暂卡顿被 liveness 误杀 Pod 被删时连接直接断开,导致错误尖峰 三类探针分工 startupProbe:启动阶段保护期 readinessProbe:是否可以接收流量 livenessProbe:进程是否需要重启 不要用 liveness 去做复杂业务检查,它更适合检测“进程是否活着”。 终止流程要完整 示例配置: spec: terminationGracePeriodSeconds: 30 containers: - name: api lifecycle: preStop: exec: command: ["/bin/sh", "-c", "sleep 8"] 应用内部还需要做到: 收到 SIGTERM 后停止接收新请求 等待在途请求结束 关闭连接池与后台任务 一个经验值 如果入口网关或 Service Mesh 更新端点需要几秒,preStop 这几秒很关键。它给控制面收敛时间,避免流量打到即将退出的 Pod。 结语 高可用不是靠单个参数,而是探针策略和退出流程共同生效。发布稳定性通常是这些细节堆出来的。
C++ 性能优化:从缓存友好的数据布局开始
先问一个问题 同样是 O(n),为什么有的循环快很多? 答案常常不在算法复杂度,而在 CPU cache 命中率。现代 CPU 的瓶颈常常是内存访问,不是算术指令。 AoS 与 SoA 常见结构: struct Particle { float x, y, z; float vx, vy, vz; int alive; }; std::vector<Particle> particles; 如果你只更新位置,实际上每次还会把速度和状态也加载进缓存。更好的方式是 SoA: struct Particles { std::vector<float> x, y, z; std::vector<float> vx, vy, vz; std::vector<int> alive; }; 这样 CPU 读取的数据更“纯”,预取更有效。 减少伪共享 多线程里,两个线程写不同变量也可能互相拖慢,因为它们落在同一 cache line。 struct alignas(64) Counter { std::atomic<uint64_t> value; }; 用对齐把热点写入隔离开,通常能明显降低抖动。 少做指针追逐 链表、树这类结构在理论上优雅,但在缓存层面很吃亏。工程上更常见的折中是: 用连续数组表示节点池 索引代替裸指针 批量遍历而不是随机跳转 实践建议 先用 profiler 看 cache miss,再改代码 热路径优先考虑连续内存 把“数据怎么放”当成接口设计的一部分 小结 性能优化不是玄学。对于 C++,缓存友好的数据布局往往比微观语法技巧更值回票价。
Go Worker Pool 与背压:不靠拍脑袋定并发数
为什么会积压 很多服务写了 worker pool,但上线后仍然: 高峰时队列暴涨 平均延迟还行,P99 很差 CPU 没打满却开始超时 这通常是“入队速率 > 出队速率”的背压问题。 基础模型 type Job struct { ID string } func startPool(ctx context.Context, n int, jobs <-chan Job) { for i := 0; i < n; i++ { go func() { for { select { case <-ctx.Done(): return case job, ok := <-jobs: if !ok { return } handle(job) } } }() } } 结构不复杂,难点在参数选择。 三个观测指标 吞吐:每秒处理多少请求 延迟:P50/P95/P99 队列深度:channel 长度趋势 如果队列长期接近上限,说明处理能力不足或外部依赖抖动。 调优顺序 固定业务流量,先找单 worker 处理能力 逐步增加 worker,观察 P99 与 CPU 变化 达到拐点后停止扩容,避免锁竞争和上下文切换过量 设置拒绝策略,不让队列无限增长 一个实用策略 快速失败:队列满时直接返回可重试错误 分级队列:高优先级任务单独通道 限时执行:每个任务绑定 context 超时 总结 worker pool 不是“越大越好”。并发数本质是资源预算,必须和外部依赖能力、延迟目标一起设计。
Go Context 取消链路:别让 goroutine 泄漏
为什么这个问题总会出现 线上服务一旦有重试、聚合查询、异步回调,就很容易出现 goroutine 泄漏: 上游请求已经结束,下游协程还在跑 超时只控制了入口,没有传到内部依赖 背景任务没有退出信号 context.Context 不是万能药,但它是 Go 服务里最基础的生命周期约束。 三条硬规则 请求入口创建 context,内部只传递不重建 外部依赖调用必须接收 context 子协程要么监听 ctx.Done(),要么有明确退出条件 一个最小可用结构 func handler(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 800*time.Millisecond) defer cancel() user, err := userSvc.Get(ctx, "u-1001") if err != nil { http.Error(w, err.Error(), http.StatusGatewayTimeout) return } _ = json.NewEncoder(w).Encode(user) } 关键点在于:超时预算从入口建立,并传给每一层。 扇出场景的取消联动 func aggregate(ctx context.Context, id string) (Profile, error) { g, ctx := errgroup.WithContext(ctx) var base BaseInfo var score CreditScore g.Go(func() error { v, err := queryBase(ctx, id) if err != nil { return err } base = v return nil }) g.Go(func() error { v, err := queryScore(ctx, id) if err != nil { return err } score = v return nil }) if err := g.Wait(); err != nil { return Profile{}, err } return Profile{Base: base, Score: score}, nil } errgroup.WithContext 的价值是:任一分支失败,其他分支自动收到取消信号。 ...
Rust 与 C++ FFI 实战:先稳住边界再谈性能
场景 很多团队不是从零重写,而是把热点模块逐步迁到 Rust。最现实的路径是: C++ 主程序继续跑 Rust 提供一个动态库 双方通过 C ABI 通信 核心原则很简单:跨语言只传 C 兼容类型。 先定义稳定接口 #[repr(C)] pub struct CalcResult { pub code: i32, pub value: i64, } #[no_mangle] pub extern "C" fn calc_sum(a: i64, b: i64) -> CalcResult { CalcResult { code: 0, value: a + b } } #[repr(C)] 保证结构体布局可预测 extern "C" 保证调用约定一致 #[no_mangle] 让符号名可被 C++ 链接 字符串内存谁分配谁释放 跨边界最容易出问题的是字符串: Rust 分配、C++ 释放,或者反过来 不同分配器混用导致崩溃 推荐做法:统一由一侧分配和释放,并显式提供 free 函数。 错误处理不要 panic 穿透 FFI 层应该“防炸”: #[no_mangle] pub extern "C" fn safe_div(a: i64, b: i64, out: *mut i64) -> i32 { if out.is_null() { return -2; } if b == 0 { return -1; } unsafe { *out = a / b; } 0 } 返回错误码是最朴素、也最稳的方式。 ...
C++ 内存池实践:高频对象分配的性能优化
背景 写 C++ 服务或者引擎代码时,经常会碰到一种情况:CPU 看着不高,但延迟就是压不下去。 最后一 profile,热点不在算法,也不在锁,而是在 operator new 和 operator delete。 这类问题在下面几种场景里尤其常见: 网络服务里大量创建短生命周期请求对象 游戏引擎里频繁分配小型组件 消息队列消费者持续构造临时 buffer 如果对象大小固定,或者分布相对集中,内存池通常是很直接的一刀。 为什么默认分配器会成为瓶颈 通用分配器要解决的问题很多: 不同尺寸的内存申请 跨线程竞争 碎片整理 对齐要求 这些能力都很重要,但它们也意味着额外开销。 如果你的场景很单一,比如“每次都申请一个 256 字节的请求对象”,那继续走通用分配器其实是在为用不到的能力买单。 一个简单的固定块内存池 先看一个足够说明问题的版本。 #include <cstddef> #include <new> #include <vector> class MemoryPool { public: MemoryPool(std::size_t blockSize, std::size_t blockCount) : block_size_(blockSize) { data_.resize(blockSize * blockCount); for (std::size_t i = 0; i < blockCount; ++i) { void* ptr = data_.data() + i * blockSize; free_list_.push_back(ptr); } } void* allocate() { if (free_list_.empty()) { throw std::bad_alloc(); } void* ptr = free_list_.back(); free_list_.pop_back(); return ptr; } void deallocate(void* ptr) { free_list_.push_back(ptr); } private: std::size_t block_size_; std::vector<char> data_; std::vector<void*> free_list_; }; 思路很直接: ...
C++ 无锁队列:从 CAS 到内存序
背景 只要做过高并发服务、游戏引擎或者低延迟组件,迟早会碰到一个问题:锁太重了。 典型场景包括: 生产者线程持续推消息 消费者线程高频拉取任务 临界区很短,但锁竞争很激烈 延迟指标对尾部抖动非常敏感 这时候很多人第一反应是“上无锁队列”。 方向没错,但无锁代码最危险的地方在于:看起来能跑,不代表一定正确。 尤其在 C++ 里,只会用 compare_exchange_weak 还不够,真正决定正确性的往往是内存序。 无锁不等于没有同步 先澄清一个常见误区: mutex 是同步 原子变量也是同步 无锁结构只是把同步方式从“阻塞锁”换成了“原子操作 + 内存可见性约束”。 也就是说,你不是不需要同步了,而是需要更精确地控制同步。 一个最简单的 SPSC 环形队列 先从单生产者、单消费者模型说起。这个模型更适合作为理解内存序的起点。 #include <atomic> #include <array> #include <cstddef> template <typename T, std::size_t N> class SpscQueue { public: bool push(const T& value) { const auto tail = tail_.load(std::memory_order_relaxed); const auto next = (tail + 1) % N; if (next == head_.load(std::memory_order_acquire)) { return false; } buffer_[tail] = value; tail_.store(next, std::memory_order_release); return true; } bool pop(T& value) { const auto head = head_.load(std::memory_order_relaxed); if (head == tail_.load(std::memory_order_acquire)) { return false; } value = buffer_[head]; head_.store((head + 1) % N, std::memory_order_release); return true; } private: std::array<T, N> buffer_{}; std::atomic<std::size_t> head_{0}; std::atomic<std::size_t> tail_{0}; }; 这个实现不复杂,但已经体现了两个关键点: ...