BvBeJ的技术与生活记录
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}; }; 这个实现不复杂,但已经体现了两个关键点: ...
Docker 镜像安全与瘦身:从能跑到适合上线
背景 上一篇聊了 Docker 多阶段构建,重点是镜像体积和构建效率。 但把镜像做小,只解决了一半问题。真正要上线时,还得继续问几个更现实的问题: 镜像里有没有不该带进去的工具 容器是不是还在用 root 运行 依赖包有没有已知漏洞 基础镜像是不是长期没人维护 很多项目的 Dockerfile 确实“能跑”,但离“适合上线”还差不少。 小镜像通常也更安全 这不是绝对规律,但大体成立。 原因很简单: 装得越多,攻击面越大 多余工具越多,漏洞概率越高 调试方便的环境,往往也更容易被滥用 所以镜像瘦身和安全加固,在很多时候是同一个方向上的事情。 不要默认用 root 运行 很多 Dockerfile 最容易忽略的一点,就是进程默认是 root。 FROM alpine:3.20 WORKDIR /app COPY myapp /app/myapp ENTRYPOINT ["/app/myapp"] 这份文件能跑,但容器里的进程权限过大。 更稳妥的方式是显式创建低权限用户: FROM alpine:3.20 RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app COPY myapp /app/myapp USER appuser ENTRYPOINT ["/app/myapp"] 这样即使应用被利用,攻击者拿到的默认权限也会更低。 基础镜像要尽量克制 基础镜像选型很影响最终的安全边界。 常见选择大概是: ubuntu / debian:通用,但内容更多 alpine:体积小,适合简单运行时 distroless:更克制,适合生产环境 scratch:最小,但调试和兼容性要求更高 如果你的程序是静态编译的 Go 服务,通常可以考虑 scratch 或 distroless。 ...
Go gRPC 服务治理:超时、重试、熔断怎么配合
背景 很多团队从 REST 切到 gRPC 后,第一感受通常都不错: 接口定义清晰 代码生成省心 性能和序列化效率更好 但线上跑久了会发现,真正决定服务质量的不是 protobuf 文件写得多漂亮,而是这些问题处理得怎么样: 超时怎么设 失败要不要重试 下游抖动时怎么自保 连接和并发要怎么控 这些内容不处理好,gRPC 只是让调用更快地失败而已。 超时必须从调用入口就带上 Go 里最好的习惯之一,就是把超时放进 context.Context。 func (s *OrderService) GetUser(ctx context.Context, userID string) (*pb.User, error) { callCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond) defer cancel() return s.userClient.GetUser(callCtx, &pb.GetUserRequest{ UserId: userID, }) } 为什么一定要带超时? 因为不带超时的 RPC,本质上就是把失败时间交给网络、内核和对端服务决定。你无法控制,也无法稳定预期。 线上更糟的是,请求可能层层调用: API -> Order Service -> User Service -> Profile Service 如果每一层都没有明确 deadline,慢请求会像雪球一样越滚越大。 重试不是默认开启就完事 很多人一看到失败就想自动重试,但重试最危险的地方在于:如果失败原因是过载,重试可能会让故障更严重。 适合重试的场景通常是: 短暂网络抖动 连接瞬时中断 明显的临时性错误 不适合盲目重试的场景: 已经超时很久的请求 非幂等写操作 下游明显处于过载状态 一个更稳的客户端封装通常像这样: func callWithRetry(ctx context.Context, fn func(context.Context) error) error { var lastErr error backoffs := []time.Duration{50 * time.Millisecond, 100 * time.Millisecond, 200 * time.Millisecond} for _, backoff := range backoffs { if err := fn(ctx); err == nil { return nil } else { lastErr = err } select { case <-time.After(backoff): case <-ctx.Done(): return ctx.Err() } } return lastErr } 这里最重要的不是代码本身,而是策略: ...
Go 服务监控进阶:从指标采集到 SLI / SLO 告警
背景 很多团队的监控体系,起点都差不多: 接了 Prometheus 做了几个 Grafana 大盘 报警规则也写了一堆 结果线上一出问题,还是有两个老问题: 真故障没及时报 不重要的抖动疯狂报 本质原因通常不是“监控没接”,而是指标设计和告警语义不对。 尤其在 Go 微服务里,写 metrics 很容易,写出真正有业务意义的 metrics 并不容易。 先区分:监控数据不等于告警指标 你当然可以采很多数据,但不是所有数据都适合直接拿来告警。 比如这些指标: goroutine 数量 GC 次数 进程 RSS 请求总量 它们都很有价值,但更适合作为排障上下文,而不是一上来就触发 Pager。 真正适合做核心告警的,通常是离用户体验更近的信号。 这就是 SLI / SLO 的意义。 什么是 SLI / SLO 简单说: SLI:你用什么指标衡量服务质量 SLO:这个质量要达到什么目标 以一个 HTTP API 为例,最常见的两个 SLI 是: 可用性 成功请求比例是不是足够高。 延迟 请求耗时是不是在可接受范围内。 例如: 30 天内成功率不低于 99.9% 95% 的请求延迟低于 200ms 这两个目标就比“CPU 超过 80% 告警”更接近真实业务体验。 Go 服务里的基础指标 假设你已经有一个标准 HTTP 中间件,可以记录请求总量和耗时。 ...
Kubernetes 零中断发布:不仅是 RollingUpdate 那么简单
背景 很多团队第一次把服务上到 Kubernetes 时,会觉得滚动发布已经帮我们解决了“不中断发布”的问题。 实际上,RollingUpdate 只是开始,不是答案。 线上真正导致发布抖动的,往往是这些细节: 新 Pod 还没准备好就被加进流量 老 Pod 收到 SIGTERM 后立刻退出 长连接请求被中途切断 readiness 和 liveness 配置混乱 ingress、service、应用本身三层状态不同步 想做到真正意义上的零中断,得把整条链路串起来看。 先理解滚动发布到底做了什么 Deployment 默认使用 RollingUpdate 策略,大致过程是: 拉起新 Pod 等新 Pod Ready 逐步减少旧 Pod 直到新版本全部替换完成 一个常见配置如下: strategy: type: RollingUpdate rollingUpdate: maxUnavailable: 0 maxSurge: 1 这表示: 发布期间不允许可用实例减少 每次最多多起一个新 Pod 它能降低抖动概率,但不能保证应用层面的请求一定不受影响。 Readiness Probe 决定流量什么时候进来 如果只配了 liveness,没有配 readiness,基本等于告诉集群: “只要进程还活着,就可以接流量。” 这在很多服务里是错的。 比如一个 API 服务启动后还要做这些事情: 加载配置 预热缓存 建立数据库连接池 初始化路由和依赖客户端 在这些动作完成之前,进程虽然活着,但根本不适合接请求。 readinessProbe: httpGet: path: /ready port: 8080 initialDelaySeconds: 3 periodSeconds: 5 timeoutSeconds: 1 failureThreshold: 3 livenessProbe: httpGet: path: /health port: 8080 initialDelaySeconds: 10 periodSeconds: 10 这两个探针不要混用: ...
Rust Tokio 背压控制:异步系统别只会拼命 spawn
背景 刚开始写 Tokio 程序时,很多人都会觉得异步特别轻: 一个请求一个 task 来一个任务就 tokio::spawn channel 一接就处理 代码看起来很流畅,吞吐也不错。 但一到高负载场景,问题很快就出来了: 任务堆积越来越多 内存不断上涨 下游数据库或 HTTP 依赖被打爆 延迟从毫秒飙到秒级 这时候根问题通常不是 Tokio 不够快,而是系统没有背压。 什么是背压 背压的本质是:当下游处理不过来时,上游必须感知并减速。 如果没有这层机制,异步系统就很容易变成“把问题排队排到内存里”。 一个最典型的错误写法: loop { let job = accept_job().await; tokio::spawn(async move { process_job(job).await; }); } 这段代码的意思其实是: 来多少任务都收 能不能处理完以后再说 如果生产速度持续高于消费速度,系统一定会失控。 最简单的背压:有界 channel 相比无脑 spawn,更稳妥的起点通常是 bounded channel。 use tokio::sync::mpsc; #[tokio::main] async fn main() { let (tx, mut rx) = mpsc::channel::<Job>(1024); tokio::spawn(async move { while let Some(job) = rx.recv().await { process_job(job).await; } }); loop { let job = accept_job().await; if tx.send(job).await.is_err() { break; } } } struct Job; async fn accept_job() -> Job { Job } async fn process_job(_job: Job) {} mpsc::channel(1024) 的关键不是“1024 这个数字”,而是它有上限。 ...
Rust 异步数据库访问:连接池、超时与稳定性
背景 Rust 写后端服务时,数据库访问通常是绕不开的一层。 很多人刚开始用 sqlx 或 tokio-postgres 时,会把关注点放在: 能不能异步查询 类型映射是否方便 宏检查 SQL 是否好用 这些当然重要,但线上跑起来以后,更现实的问题通常是: 连接池应该开多大 请求等连接要等多久 数据库抖动时怎么避免把整个服务拖死 这些问题不处理好,异步只能让你“更高效地把数据库打爆”。 先建立一个基本事实 异步不是无限并发。 你的 Tokio 任务可以很多,但数据库连接永远是稀缺资源。无论是 PostgreSQL、MySQL 还是其他关系型数据库,都不可能让应用无限开连接而没有代价。 所以数据库访问的第一原则不是“尽快发查询”,而是: 连接数可控 排队时间可控 查询超时可控 以 sqlx 为例初始化连接池 use sqlx::postgres::PgPoolOptions; use std::time::Duration; async fn create_pool(database_url: &str) -> Result<sqlx::PgPool, sqlx::Error> { PgPoolOptions::new() .max_connections(32) .min_connections(4) .acquire_timeout(Duration::from_secs(2)) .idle_timeout(Duration::from_secs(300)) .max_lifetime(Duration::from_secs(1800)) .connect(database_url) .await } 这几个参数都很关键: max_connections:连接池上限 min_connections:最小保活连接数 acquire_timeout:拿连接最多等多久 max_lifetime:连接多久轮换一次 尤其是 acquire_timeout,它能防止高峰时请求无止境排队。 连接池大小不是越大越好 很多人看到连接池耗尽,就第一时间把池子调大。 这有时能缓一口气,但经常只是把压力继续往数据库推。 连接池大小应该结合几个因素来定: ...
Vue3 性能优化:从响应式细节到页面加载
背景 前端性能这件事,经常有两个极端: 一种是完全不管,页面卡了再说 另一种是上来就讲虚拟列表、SSR、代码分割,结果项目里真正拖慢页面的点根本不在那 Vue3 本身已经做了不少优化,但框架快,不代表业务代码就一定快。 真正影响体验的,通常还是这些问题: 不必要的响应式开销 大列表重复渲染 首屏加载资源过大 watch 写得太随意,副作用失控 这篇文章只聊实战里最常见、最值回票价的优化点。 先判断瓶颈在哪 优化前先确认问题类型。通常分三类: 首屏慢 JS 包太大、资源太多、接口太慢。 交互卡 某个状态变更引起大面积重渲染。 长列表卡 DOM 数量过多,滚动和 patch 开销都很高。 这三类问题的解决手段完全不同。不要把“页面卡”都归因到 Vue 响应式。 不要把所有东西都塞进 reactive 很多项目里常见这种写法: const state = reactive({ tableData: [], chartInstance: null, editor: null, wsConnection: null, filters: { keyword: '', status: 'all', }, }) 看起来统一,实际上问题不少。 像图表实例、编辑器对象、WebSocket 连接这种第三方对象,本来就不是拿来做细粒度响应式追踪的。把它们塞进深层响应式对象里,只会增加代理成本,还可能带来奇怪副作用。 更合理的拆法是: import { reactive, shallowRef, markRaw } from 'vue' const filters = reactive({ keyword: '', status: 'all', }) const tableData = shallowRef<User[]>([]) const chartInstance = shallowRef<any>(null) const editor = shallowRef<any>(null) function initChart(el: HTMLDivElement) { chartInstance.value = markRaw(createChart(el)) } 这里的思路很明确: 业务表单状态,用 reactive 大数组、外部实例,用 shallowRef 不希望被代理的对象,用 markRaw 这不是“写法偏好”,而是直接影响更新成本。 ...
Docker 多阶段构建:让你的镜像小而美
从一个真实案例说起 之前帮一个项目 Dockerize,初始镜像 800MB,每次部署慢得让人怀疑人生。后来用多阶段构建优化完,8MB,部署时间从 3 分钟变成 10 秒。 这篇文章讲讲多阶段构建的原理和实战技巧。 为什么镜像那么大 看个典型 Dockerfile: FROM golang:1.22 WORKDIR /app COPY . . RUN go mod download RUN go build -o myapp . ENTRYPOINT ["./myapp"] 问题在哪? golang:1.22 镜像本身就 800MB+,因为包含了完整的编译工具链 你的源代码、依赖缓存、编译中间产物全在里面 运行一个简单程序,凭什么需要 gcc、git、make ? 多阶段构建:原理很简单 Dockerfile 可以有多个 FROM 指令,每个阶段是独立的。前面的阶段可以复制文件到后面,最终镜像只包含最后一个阶段的内容。 # 第一阶段:构建 FROM golang:1.22 AS builder WORKDIR /app COPY . . RUN go mod download RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp . # 第二阶段:运行 FROM alpine:3.19 RUN addgroup -S appgroup && adduser -S appuser -G appgroup WORKDIR /app COPY --from=builder /app/myapp . USER appuser ENTRYPOINT ["./myapp"] 关键点: ...