2026年8月

反向代理的本质

反向代理是个容易被误解的概念。正向代理替客户端访问服务端,反向代理替服务端接收客户端请求。一字之差,但方向完全相反。从部署看,反向代理更像一台"前台接待",它不处理业务,只负责把请求转发给后端真正的服务,再把结果返回给客户端。

引入反向代理解决的核心问题是:后端服务的暴露面。没有反向代理时,每个后端服务都得开放端口给外部,攻击面巨大。有了反向代理,外部只看到 80/443,后端服务全藏在内网。附加价值是负载均衡、缓存、SSL 卸载这些功能可以统一在代理层完成,后端服务不用各自实现一遍。

设计代价是引入了一跳网络延迟和单点风险。如果代理挂了,所有后端服务都不可用——所以生产环境代理本身也要做高可用。这个成本必须接受,没有廉价方案。

代理与负载均衡的差异

很多人把 proxy_passupstream 混为一谈。它们不是一回事。

proxy_pass 指向单个后端地址,只是转发,不涉及负载均衡。哪怕指向一个域名,如果 DNS 解析出多个 IP,Nginx 也只在启动时选一个。

upstream 定义一组后端,Nginx 会在组内分配请求。这才是负载均衡。

坑在于:很多人写 proxy_pass http://backend; 以为就负载均衡了,但如果没定义对应的 upstream backend {} 坯,Nginx 会把 backend 当域名做 DNS 解析。解析失败就起不来。

基础代理配置

最基础的反向代理配置,把 /api/ 的请求转发到后端 8080:

# 实测于 Nginx 1.24.0, CentOS 7
server {
    listen 80;
    server_name api.example.com;

    location /api/ {
        proxy_pass http://127.0.0.1:8080/;
        # 透传真实客户端 IP,后端日志才有意义
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
    }
}

proxy_pass 末尾的斜杠是个经典坑。http://127.0.0.1:8080/ 带斜杠会把 /api/ 替换成 /,后端收到的路径是 /users;如果不带斜杠写成 http://127.0.0.1:8080,后端收到的是 /api/users。前后端路径不一致的时候,99% 是这里的问题。

负载均衡配置

引入 upstream 后,可以做多后端负载均衡:

upstream backend {
    # 加权轮询(默认),weight 越高分配越多
    server 192.168.1.10:8080 weight=3;
    server 192.168.1.11:8080 weight=1;

    # 备用服务器,只在主服务器都挂了才启用
    server 192.168.1.12:8080 backup;

    # 容错:10 秒内失败 3 次就标记不可用,停 30 秒再试
    # 这个参数写在 server 行内
}

server {
    listen 80;

    location /api/ {
        proxy_pass http://backend/;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

Nginx 默认的负载均衡算法是加权轮询(Weighted Round Robin)。还有两种可选:
- least_conn:转发给当前连接数最少的后端,适合请求处理时间差异大的场景
- ip_hash:按客户端 IP 哈希固定后端,保证会话粘性

ip_hash 看起来很美,但坑在于:客户端经过 NAT 出口,所有请求看起来都是同一个 IP,会集中打到一台后端。生产环境如果走 CDN,ip_hash 基本失效。

实测记录

两个后端,weight 1:1,用 wrk -t4 -c100 -d30s 压测。
- 理论预期:负载约 50:50 分配
- 实际结果:49.7% : 50.3%,基本符合预期
- 切到 ip_hash 后变成 87% : 13%——大多数 IP 都被哈希到同一个后端,验证了 NAT 场景下 ip_hash 失效

偏差分析:ip_hash 的哈希空间只有 2 个桶(2 个后端),分布极度不均匀。后端数量越多,ip_hash 的均匀性越好;只有 2 个后端时几乎没意义。

会话保持:用 cookie 而不是 ip_hash

替代 ip_hash 的方案是 sticky cookie。Nginx 商业版原生支持,开源版可以用 nginx-sticky-module-ng 第三方模块。原理是给客户端种一个 cookie,下次请求带这个 cookie 就路由到同一后端。

如果不想引入第三方模块,最实用的方案是在应用层做会话共享(Redis 存 session),后端无状态,随便路由到哪台都行。这才是真正的可扩展架构。我维护的服务都走这条路——代理层只管转发,无状态化交给应用层处理。

SSL 卸载

反向代理做 SSL 卸载,后端就不需要处理 HTTPS,降低 CPU 占用:

server {
    listen 443 ssl;
    ssl_certificate /etc/nginx/ssl/server.crt;
    ssl_certificate_key /etc/nginx/ssl/server.key;

    location /api/ {
        proxy_pass http://backend/;
        # 后端需要知道原始协议是 https
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

后端框架通常通过 X-Forwarded-Proto 判断原始协议。Django 和 Flask 都支持,但要确保配置了 SECURE_PROXY_SSL_HEADER 或者 ProxyFix 中间件,否则后端会以为请求是 http 进来的,重定向时生成错误的 URL。

遗留问题

  • Nginx 后端健康检查目前只能靠被动探测(请求失败才标记 down)。主动健康检查(health_check)是商业版功能,开源版要装 nginx_upstream_check_module,这个模块跟 Nginx 版本绑定很死,升级麻烦。替代方案是外部脚本定期 curl 探测,通过 nginx -s reload 动态剔除节点,但 reload 会导致短时连接中断。
  • proxy_pass 的 DNS 解析只在启动时做一次,如果后端用域名且 IP 会变(比如云服务商),需要用 resolver 指令加变量方式实现动态解析。具体写法可参考官方文档的 resolver 章节。

关联笔记

关联笔记:Nginx 限流配置:防刷防爬的实战方案 中关于 limit_req 和 limit_conn 的配置讨论,可与此文配合使用

小 RAM MCU 的现实约束

STM32F103C8T6 是很多项目的入门选择,但它的 20KB RAM 和 64KB Flash 在实际开发中往往捉襟见肘。更现实的情况是 LoRa 远传节点——在 STM32L4 的 32KB RAM 里,LoRaWAN 协议栈要吃掉 8KB,传感器驱动 2KB,数据缓冲队列 4KB,OTA 预留 6KB,留给业务逻辑的只有 12KB。

在这种资源约束下写代码,和在 256KB RAM 的 STM32F4 上完全是两种思路。不是功能能不能实现的问题,而是每分配一个缓冲区都要先算一遍还剩多少堆、栈还有多少空间。

小 RAM 系统的核心痛点有三个:栈溢出、堆碎片化、缓冲区大小妥协。每一个都可能导致 HardFault,而且排查起来非常耗时——因为崩溃现场往往已经被破坏了。

栈溢出:最隐蔽的杀手

栈溢出在开发阶段往往不暴露,因为测试时中断嵌套少、函数调用浅。到了量产环境,中断密集时一层层栈帧叠上去,突然就溢出了。

STM32 的栈是从高地址向低地址生长的。如果栈顶越过了 BSS 段的起始地址,就会破坏全局变量,表现出来的症状是某个全局变量莫名其妙被改了。

解决思路是给栈加"水位标记"。在启动时把栈空间填满一个魔术值(比如 0xA5),运行一段时间后检查有多少魔术值没被覆盖,就知道栈的高水位线在哪里:

// 实测于 STM32F103C8T6, GCC 12.2, -O2
extern unsigned int _estack;      // 链接脚本定义的栈顶
extern unsigned int _Min_Stack_Size;

#define STACK_CANARY 0xA5A5A5A5

void Stack_InitCanary(void) {
    uint32_t *stack_start = (uint32_t *)((uint32_t)&_estack - 4096);
    uint32_t *stack_end = (uint32_t *)&_estack;
    while (stack_start < stack_end - 16) {
        *stack_start++ = STACK_CANARY;
    }
}

uint32_t Stack_GetWatermark(void) {
    uint32_t *p = (uint32_t *)((uint32_t)&_estack - 4096);
    uint32_t used = 0;
    while (*p == STACK_CANARY && (uint32_t)p < (uint32_t)&_estack) {
        p++;
        used++;
    }
    return 4096 - used * 4;  // 返回栈使用了多少字节
}

实测记录

在 LoRa 节点上运行 24 小时后检查,栈高水位线在 2.3KB 处。开发时以为只用 1KB 就够了,实际多出来一倍,主要是中断嵌套时 LoRaWAN 的 MAC 层函数调用链很深,每层都申请了局部数组。

偏差分析:测试中断嵌套场景时发现 watermark 比单次中断高出 800 字节,怀疑是 SysTick 中断和 LoRa CAD 检测中断同时触发时的栈叠加。后续调高了栈大小到 4KB 才稳定。

堆碎片化:用静态分配替代 malloc

在小 RAM 系统上用 malloc/free 是灾难性的。堆碎片化会导致即使总剩余空间足够,也无法分配出一块连续的大块。

我的策略是:根本不用堆。所有缓冲区改成静态分配,在编译阶段就固定大小和位置。

// 不用 malloc,用静态数组 + 编译期大小
typedef struct {
    uint8_t buf[256];
    uint16_t len;
} Packet;

// 编译期分配 8 个 Packet 池
static Packet packet_pool[8];
static uint8_t pool_used[8];

Packet* Packet_Alloc(void) {
    for (int i = 0; i < 8; i++) {
        if (!pool_used[i]) {
            pool_used[i] = 1;
            return &packet_pool[i];
        }
    }
    return NULL;
}

void Packet_Free(Packet* p) {
    uint32_t idx = p - packet_pool;
    if (idx < 8) pool_used[idx] = 0;
}

代价是池子大小必须编译期确定,不能动态扩展。但好处是不会碎片化、回收可预测、调试时能看到完整的占用情况。

缓冲区大小:用时间换空间

20KB RAM 不够让你同时缓存 N 路 CAN 报文和传感器数据。妥协的方式是用"时间换空间"——加大抽样间隔,减小单帧缓冲。

比如原来缓存 100 帧 CAN 数据等一起发送,现在改成 32 帧就发。延迟从 100ms 降到 32ms,但内存占用从 1.5KB 降到 480 字节。

实际项目里另一个有效手段是复用缓冲区。同一个 256 字节的缓冲区,在采集阶段装传感器数据,发送阶段装 LoRa 报文。只要两个阶段不重叠,就能安全复用。需要的状态机为了避免覆盖,要用一个 buf_state 标志位严格管控:

enum {
    BUF_FREE = 0,
    BUF_COLLECTING,
    BUF_TX_PENDING,
    BUF_TXING,
};

static uint8_t shared_buf[256];
static uint8_t buf_state = BUF_FREE;

定位 HardFault 的技巧

小 RAM 系统崩溃了,光看 lrpc 往往不够。把崩溃时的栈内容 dump 出来,用 addr2line 反查到源码行号,是最可靠的定位方法:

// 实测于 STM32F103C8T6, GCC arm-none-eabi
void HardFault_Handler(void) {
    uint32_t *stack;
    __asm volatile("tst lr, #4 \n"
                   "ite eq \n"
                   "mrseq r0, msp \n"
                   "mrsne r0, psp \n"
                   "mov %0, r0" : "=r"(stack));

    // 写到备用寄存器里,调试器抓取
    volatile uint32_t saved_r0 = stack[0];
    volatile uint32_t saved_r1 = stack[1];
    volatile uint32_t saved_lr = stack[5];
    volatile uint32_t saved_pc = stack[6];
    volatile uint32_t saved_xpsr = stack[7];

    while (1);  // 在这里断下,用 arm-none-eabi-addr2line 反查 pc
}

适用边界

这套策略适合:
- STM32F1 系列 64KB Flash / 20KB RAM 级别
- 中低数据吞吐的 LoRa / NB-IoT 节点
- 对实时性要求高、不允许动态调度的场合

如果是 STM32F4 级别(192KB RAM),就没必要这么折磨自己了——直接按教科书来,malloc 该用就用。

遗留问题

  • 任务切换时栈切换会不会影响水位检测的准确性?目前只在 FreeRTOS 之外验证过,静态调度场景下 OK,多任务未测。
  • 共享缓冲区方案在协程(protothread)场景下的状态机复杂度会指数增长,有没有更优雅的复用模式,待调研。

关联笔记

关联笔记:DMA 双缓冲与事件驱动:STM32L4 传感器数据采集的功耗优化 中关于 DMA 缓冲区大小与唤醒频率的权衡讨论

传感器采集的功耗困境

在电池供电的传感器节点中,MCU 大部分时间应该处于睡眠状态,只在数据就绪时被唤醒处理。但很多实现中,MCU 要么轮询 ADC,要么用中断频繁唤醒,导致实际睡眠时间远小于预期。DMA 双缓冲加事件驱动,是解决这个问题的经典思路。

DMA 双缓冲的核心价值是:CPU 不参与数据搬运,且两块缓冲区交替使用——一块在被外设计数填充时,另一块正在被 CPU 处理。这避免了单缓冲方案中"等 DMA 传输完才能处理"的空窗期。

STM32L4 系列的 Stop2 模式是个好东西。静态功耗 1.4μA,SRAM 内容保持,DMA 可以继续工作,唤醒延迟 35μs。这意味着传感器采集可以在 CPU 睡眠时进行,只在缓冲区满时才唤醒 CPU。

双缓冲的工作逻辑

双缓冲的关键是"乒乓切换"。DMA 配置两块缓冲区,用 Circular 模式让 DMA 在两块之间循环。每次半传输完成(HT)和传输完成(TC)中断都会触发 CPU 唤醒,处理对应的半块数据。

设计代价是:内存占用翻倍,且需要同步机制防止 DMA 和 CPU 访问同一块缓冲区。简单说,HT 中断处理前半块,TC 中断处理后半块,两块交替使用,天然避免了竞争。

缓冲区布局:
[Buffer A (512B)] [Buffer B (512B)]
       ↑                ↑
   HT 中断处理       TC 中断处理
   (DMA 正在填 B)    (DMA 正在填 A)

实操配置

以下配置基于 STM32L432KC,用 CubeMX 生成 HAL 框架代码,手动补充双缓冲逻辑:

// 实测于 STM32L432KC, STM32Cube HAL 1.17.0
// ADC1 通道 5(PB1),DMA 双缓冲,12 位分辨率
#define BUF_SIZE 512
static uint16_t adc_buf[2][BUF_SIZE];  // 双缓冲

void ADC_DMA_Init(void) {
    // ADC 配置:72MHz/6 = 12MHz,采样时间 47.5 周期
    // 单次转换约 4μs,512 次约 2ms
    hadc1.Init.ContinuousConvMode = ENABLE;
    hadc1.Init.DMAContinuousRequests = ENABLE;

    // DMA 配置:Circular 模式,半字传输
    hdma_adc1.Init.Mode = DMA_NORMAL;  // 用 Normal + 手动重启实现双缓冲切换
    // 注意:HAL 库的 DMA_CIRCULAR 模式下 HT/TC 中断稳定
    // 但实测发现双缓冲切换用 DMA_NORMAL + 手动重启更可靠
    hdma_adc1.Init.Mode = DMA_CIRCULAR;

    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buf, BUF_SIZE * 2);

    // 注册 HT 和 TC 回调
    HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_HALF_CB_ID, ADC_HT_Callback);
    HAL_ADC_RegisterCallback(&hadc1, HAL_ADC_CONV_CPLT_CB_ID, ADC_TC_Callback);
}

void ADC_HT_Callback(ADC_HandleTypeDef* hadc) {
    // DMA 正在填 Buffer B,处理 Buffer A
    ProcessData(adc_buf[0], BUF_SIZE);
}

void ADC_TC_Callback(ADC_HandleTypeDef* hadc) {
    // DMA 正在填 Buffer A,处理 Buffer B
    ProcessData(adc_buf[1], BUF_SIZE);
}

进入 Stop2 模式前的配置很关键。必须先挂起所有外设时钟,只保留 ADC 和 DMA 时钟,否则 Stop2 进不去:

void EnterStop2(void) {
    // 关闭不需要的外设时钟
    __HAL_RCC_GPIOA_CLK_SLEEP_DISABLE();
    // 保留 ADC 和 DMA 时钟
    __HAL_RCC_ADC_CLK_SLEEP_ENABLE();
    __HAL_RCC_DMA1_CLK_SLEEP_ENABLE();

    // 进入 Stop2
    HAL_PWREx_ControlLowPowerRunMode(PWR_LOWPOWERRUNMODE_ENABLE);
    HAL_PWREx_EnterSTOP2Mode(PWR_STOPENTRY_WFI);

    // 唤醒后恢复时钟
    SystemClock_Config();
}

实测记录

理论预期:MCU 在 Stop2 模式下被 DMA 中断唤醒处理数据,整体平均功耗应接近 Stop2 的 1.4μA 加上短暂的唤醒处理时间。

实测结果(用万用表 μA 档测 VDD 电流):
- 纯 Stop2 模式:1.6μA(手册标称 1.4μA,偏差 0.2μA)
- 双缓冲运行时(512 点采集 + 处理):平均 18μA
- 同样的轮询方案(CPU 不睡眠):平均 2.8mA

偏差分析:Stop2 静态功耗略高于手册值,怀疑是板上 LDO 漏电流导致。双缓冲运行时的 18μA 远高于静态,说明唤醒处理的开销不可忽略——每次唤醒要恢复时钟、处理数据、再进入 Stop2,这个流程约 50μs。

优化思路:增大缓冲区。从 512 点改到 2048 点后,唤醒频率降低 4 倍,平均功耗降到 8μA。代价是数据延迟从 2ms 涨到 8ms,对低频传感器(温度、压力)可以接受。

(这里我一开始想用 DMA 双缓冲的硬件切换功能——GD32 有专门的 MDA M0/M1 寄存器,但 STM32L4 的 HAL 库支持不完整,手动管理反而更可控。)

适用边界

这套方案的前提:
- 传感器采样率低(kHz 级以下)。如果是音频这种高采样率场景,Stop2 的唤醒开销反而比一直跑更划算。
- 数据处理可以批量进行。如果每点都要实时处理,双缓冲没有意义。
- VDD 稳定。Stop2 模式下 VDD 波动会导致 SRAM 数据丢失,电池供电要有稳压。

遗留问题

  • DMA 在 Stop2 模式下能否继续工作?手册写的是"SRAM 保持,DMA 可工作",但实测中如果 DMA 源是 ADC,需要确认 ADC 时钟是否在 Stop2 下保持开启。这部分手册描述模糊,待用逻辑分析仪抓时序验证。
  • 多 ADC 通道扫描时,DMA 双缓冲的 HT/TC 中断会变得复杂。如果采样频率不一致,需要按最慢的通道计算唤醒间隔,否则会丢数据。

关联笔记

关联笔记:STM32 ADC 采样时间计算:从手册到实测 中关于 ADC 采样时间与信号源阻抗的讨论