第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 架构演进路线图
说了这么多,咱们画张图总结一下:
29.7 实战建议
说了这么多理论,给点实在的建议:
- 从单机开始:别一上来就搞分布式。我见过太多项目,连单机队列都没调明白,就急着上集群,结果问题翻倍。
- 持久化要早做:消息队列的持久化,越早做越好。等出了事故再补,数据已经丢了一地。
- 监控不能少:分布式系统最怕黑盒。每个节点的队列深度、处理延迟、错误率,都得有监控。
- 测试要全面:网络分区、节点宕机、消息乱序,这些场景都要覆盖。我曾经在测试环境漏了网络分区测试,上线第一天就出了事故。
核心要点回顾:
- 单机队列:简单高效,但无法扩展
- 分布式队列:分区路由,水平扩展
- 集群队列:一致性协议,高可用
- 事件溯源:记录所有变更,可回溯
- CQRS:读写分离,各取所长
架构演进没有银弹。每个阶段都有它的适用场景和代价。我的建议是:理解这些模式,但不要盲目套用。根据你的业务特点,选择最合适的方案。