第29章 架构演进:从单机到分布式、集群消息队列、事件溯源、CQRS模式

说实话,做嵌入式消息队列这么多年,我见过太多项目死在架构选型上。一开始跑得好好的单机程序,等业务量上来,突然就崩了。这时候才想起来改架构,代价往往翻倍。

今天咱们聊聊架构演进这条路。从最简单的单机消息队列,一路走到分布式、事件溯源、CQRS。嗯,这条路我走过好几遍,踩过的坑不少,今天一并倒出来。

29.1 单机消息队列:起点

先看最基础的。单机消息队列,说白了就是进程内部的消息传递。我早期做的一个车载娱乐系统,用的就是这种模式。

// 单机消息队列核心结构
typedef struct {
    uint8_t* buffer;
    uint32_t head;
    uint32_t tail;
    uint32_t capacity;
    osMutexId_t mutex;
    osSemaphoreId_t sem;
} MessageQueue_t;

// 发送消息
int32_t MQ_Send(MessageQueue_t* mq, const void* data, uint32_t len) {
    osMutexAcquire(mq->mutex, OS_WAIT_FOREVER);
    
    // 检查空间
    uint32_t available = (mq->head - mq->tail - 1) % mq->capacity;
    if (available < len) {
        osMutexRelease(mq->mutex);
        return -ENOBUFS;
    }
    
    // 写入环形缓冲区
    for (uint32_t i = 0; i < len; i++) {
        mq->buffer[mq->head] = ((uint8_t*)data)[i];
        mq->head = (mq->head + 1) % mq->capacity;
    }
    
    osMutexRelease(mq->mutex);
    osSemaphoreRelease(mq->sem);
    return len;
}

这种结构,在单核MCU上跑得挺欢。但有个致命问题——它扛不住分布式。

单机队列的局限:

  • 内存共享,跨进程就废了
  • 没有持久化,掉电全丢
  • 无法水平扩展

29.2 分布式消息队列:跨过那道坎

为什么要分布式?我举个例子。之前做的一个物联网网关项目,一开始只接50个设备,单机队列够用。后来客户要求支持5000个设备,单机直接跪了。

分布式消息队列的核心,就是把消息分散到多个节点上处理。这里有个关键概念——分区

// 分布式消息分区路由
typedef struct {
    uint32_t partition_id;
    uint32_t node_id;
    char endpoint[64];  // "tcp://192.168.1.100:5555"
} PartitionInfo_t;

// 一致性哈希路由
uint32_t RouteToPartition(const char* key, uint32_t partitions) {
    uint32_t hash = 0;
    while (*key) {
        hash = hash * 31 + *key++;
    }
    return hash % partitions;
}

// 发送到分布式队列
int32_t DistributedMQ_Send(const char* topic, 
                           const void* data, 
                           uint32_t len) {
    // 计算目标分区
    uint32_t partition = RouteToPartition(topic, g_partition_count);
    
    // 找到对应节点
    PartitionInfo_t* node = &g_partitions[partition];
    
    // 通过网络发送
    return SendToNode(node->endpoint, data, len);
}

这里要注意,分布式不是简单地把代码复制几份。我曾经犯过一个错——以为多部署几个实例就完事了。结果消息乱飞,消费者重复处理,数据全乱套。

分布式队列的坑:

  • 网络分区:节点挂了怎么办?
  • 消息顺序:跨分区怎么保证顺序?
  • 重复消费:至少一次还是恰好一次?

29.3 集群消息队列:抱团取暖

集群和分布式有啥区别?我个人理解:分布式是架构模式,集群是部署方式。集群强调的是多个节点协同工作,对外像一个整体。

看一个简单的集群协调器设计:

// 集群节点状态
typedef enum {
    NODE_LEADER,
    NODE_FOLLOWER,
    NODE_CANDIDATE
} NodeRole_t;

// Raft协议简化版
typedef struct {
    NodeRole_t role;
    uint64_t current_term;
    uint64_t voted_for;
    uint64_t commit_index;
    
    // 心跳超时
    uint32_t election_timeout;
    osTimerId_t heartbeat_timer;
} RaftNode_t;

// 选主逻辑(简化)
void Raft_StartElection(RaftNode_t* node) {
    node->role = NODE_CANDIDATE;
    node->current_term++;
    node->voted_for = node->node_id;
    
    // 广播投票请求
    RequestVoteRequest req = {
        .term = node->current_term,
        .candidate_id = node->node_id
    };
    
    // 收集投票...
    uint32_t votes = 1;  // 自己投自己
    for (int i = 0; i < g_cluster_size; i++) {
        if (i != node->node_id) {
            if (SendVoteRequest(i, &req) == VOTE_GRANTED) {
                votes++;
            }
        }
    }
    
    // 超过半数即当选
    if (votes > g_cluster_size / 2) {
        node->role = NODE_LEADER;
        StartHeartbeat(node);
    }
}

集群的好处很明显:高可用、负载均衡。但代价也不小——一致性协议带来的延迟,你想想看,每次写入都要过半数节点确认。

29.4 事件溯源:记录一切

事件溯源这个模式,我第一次接触是在金融系统里。它的核心思想很简单:不存当前状态,只存状态变更的事件。

举个例子,传统方式存账户余额:

// 传统方式
typedef struct {
    uint32_t account_id;
    int64_t balance;  // 直接存余额
} Account_t;

事件溯源方式:

// 事件溯源方式
typedef enum {
    EVENT_ACCOUNT_CREATED,
    EVENT_DEPOSIT,
    EVENT_WITHDRAW,
    EVENT_TRANSFER
} EventType_t;

typedef struct {
    EventType_t type;
    uint64_t timestamp;
    uint32_t account_id;
    union {
        struct { int64_t amount; } deposit;
        struct { int64_t amount; } withdraw;
        struct { 
            uint32_t from_account;
            uint32_t to_account;
            int64_t amount;
        } transfer;
    } data;
} AccountEvent_t;

// 通过重放事件计算当前余额
int64_t ReplayBalance(uint32_t account_id) {
    int64_t balance = 0;
    EventIterator_t it;
    
    EventStore_InitIterator(&it, account_id);
    
    while (EventStore_Next(&it)) {
        AccountEvent_t* event = EventStore_Get(&it);
        switch (event->type) {
            case EVENT_DEPOSIT:
                balance += event->data.deposit.amount;
                break;
            case EVENT_WITHDRAW:
                balance -= event->data.withdraw.amount;
                break;
            // ...
        }
    }
    
    return balance;
}

为什么这么做?好处太多了:

  • 完整的审计轨迹:谁在什么时候做了什么,一清二楚
  • 时间旅行:可以回到任意历史状态
  • 事件重放:出bug了修复后重放一遍就行

我的经验:事件溯源不是银弹。如果业务状态频繁变更,事件流会变得极其庞大。我曾经见过一个系统,每天产生上亿条事件,查询历史状态慢得让人崩溃。这时候就需要快照(Snapshot)机制了。

29.5 CQRS模式:读写分离的艺术

CQRS,全称Command Query Responsibility Segregation。说白了,就是命令和查询分开处理。

为什么需要CQRS?你想想看,在一个高并发系统里,读和写的负载模式完全不同。写操作需要强一致性,读操作可以接受最终一致性。硬把它们绑在一起,两边都难受。

// CQRS架构核心接口

// 命令端(写)
typedef struct {
    uint32_t command_id;
    CommandType_t type;
    void* data;
    uint32_t data_len;
} Command_t;

int32_t CommandBus_Dispatch(Command_t* cmd) {
    // 验证命令
    if (!ValidateCommand(cmd)) {
        return -EINVAL;
    }
    
    // 写入事件溯源存储
    EventStore_Append(CommandToEvent(cmd));
    
    // 发布到消息队列
    MessageQueue_Publish("commands", cmd);
    
    return 0;
}

// 查询端(读)
typedef struct {
    uint32_t query_id;
    QueryType_t type;
    void* params;
    void* result;
} Query_t;

int32_t QueryBus_Execute(Query_t* query) {
    // 从读模型直接查询
    // 读模型是专门优化的视图
    return ReadModel_Query(query);
}

// 事件同步:将写模型同步到读模型
void EventProjection_Update(AccountEvent_t* event) {
    switch (event->type) {
        case EVENT_DEPOSIT:
            // 更新读模型的余额
            ReadModel_UpdateBalance(
                event->account_id,
                event->data.deposit.amount
            );
            break;
        // ...
    }
}

CQRS和事件溯源经常一起出现,但不是必须的。我见过只用CQRS不用事件溯源的,也见过反过来用的。看业务场景。

29.6 架构演进路线图

说了这么多,咱们画张图总结一下:

消息队列架构演进路线 阶段1:单机队列 进程内消息传递 环形缓冲区 适用:单核MCU 阶段2:分布式 多节点协同 分区路由 适用:多机部署 阶段3:集群 Raft/Paxos 选主、心跳 适用:高可用 阶段4:事件溯源 只存事件,不存状态 完整审计轨迹 适用:金融、审计 阶段5:CQRS 读写分离 命令/查询分离 适用:高并发 演进原则: 不要一步到位,根据业务增长逐步演进。单机能搞定就别上分布式。

29.7 实战建议

说了这么多理论,给点实在的建议:

  1. 从单机开始:别一上来就搞分布式。我见过太多项目,连单机队列都没调明白,就急着上集群,结果问题翻倍。
  2. 持久化要早做:消息队列的持久化,越早做越好。等出了事故再补,数据已经丢了一地。
  3. 监控不能少:分布式系统最怕黑盒。每个节点的队列深度、处理延迟、错误率,都得有监控。
  4. 测试要全面:网络分区、节点宕机、消息乱序,这些场景都要覆盖。我曾经在测试环境漏了网络分区测试,上线第一天就出了事故。

核心要点回顾:

  • 单机队列:简单高效,但无法扩展
  • 分布式队列:分区路由,水平扩展
  • 集群队列:一致性协议,高可用
  • 事件溯源:记录所有变更,可回溯
  • CQRS:读写分离,各取所长

架构演进没有银弹。每个阶段都有它的适用场景和代价。我的建议是:理解这些模式,但不要盲目套用。根据你的业务特点,选择最合适的方案。

公众号:蓝海资料掘金营,微信deep3321