后端系统工程 04:会话、成员与消息的持久化边界
后端系统工程 04:会话、成员与消息的持久化边界 本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收 从本篇开始,第 00–03 篇建立的方法落到一个逐步演进的最小即时通信工程上(代码在 examples/im/)。第一个问题是持久化边界:哪些状态必须在同一个事务里写入,哪些可以分开;哪个状态是权威来源,哪个只是派生。边界划错的后果往往要到故障时才显现——例如消息写入了,但请求键的记录在另一个存储里没写成功,客户端重发后就多出一条消息。 本篇确定会话、成员、消息三者的表结构,说明会话内序号在哪个锁下分配、请求键为何与消息放在同一行,以及成员资格为何用序号区间而不是一个“当前成员”标志表示。所有实验直接调用存储层,连接真实的 PostgreSQL 16,不经过网关。 1. 需求与不变量 本篇负责需求卡中的 R1(部分)、R2、R3(数据模型部分),以...
后端系统工程 00:一份技术方案怎样接受验证
后端系统工程 00:一份技术方案怎样接受验证 本批导航:00 技术方案怎样接受验证|01 API 契约与业务不变量|02 负载模型与容量预算|03 单库基线与故障实验工具|04 持久化边界|05 长连接接入|06 发送确认|07 排序与增量同步|08 群消息扇出|09 多设备已读|10 网关故障与路由收敛|11 撤回与集成验收 评审技术方案时,常见的提问是“这个设计能不能扛住”“消息会不会丢”。这些问题回答不了,原因通常不在于设计者不够仔细,而在于方案本身没有给出可以判定的陈述:“不丢消息”没有说明在哪一刻之后不丢,“能扛住”没有说明扛住的是什么负载、用什么去观察。一份能接受验证的方案,必须让每一句承诺都能落到一个观察位置和一个判定方法上,判定结果只有成立或不成立两种。 本篇为整个系列确定写方案的方法。对象是后面 04–11 篇要逐步实现的最小即时通信服务(下文简称“消息服务”),使用合成的团队协作场景:成员、会话、设备、消息,域名统一为 example.test。本篇产出三件东西:一张需求卡、一张状态表、一组设计决策记录,以及检查需求卡格式与证据对应关系的脚本 exampl...
后端系统工程 18:消息队列接入与积压恢复
系列导航:上一篇 17 从持久日志构建最小消息队列 | 本篇 18 | 下一篇 19 延迟任务的领取与取消 上一篇把“受理”“落盘”“消费确认”拆成了三条线,用一个自制的单机日志逐一观察。换成成熟的消息中间件之后,这三条线依然存在,只是分别落到了客户端协议里:publisher confirm 说明 broker 接下了消息,消费确认把消息的所有权交给消费者,两者之间还隔着队列、重投和消费者自己的数据库事务。中间件不会替应用决定先提交还是先确认,也不会替应用判断一条消息是不是已经处理过。 本篇用真实的 RabbitMQ 3.12.1 节点和 PostgreSQL 16 检验接入方的契约:生产确认与不可路由消息、提交后再确认与去重表、毒消息的有限次重试和隔离、积压时的有界预取。输入是与第 17 章共用的 3000 条合成订单事件,代码在 examples/18-rabbitmq-consumer/,输出在 evidence/18/output.txt。 实验只有一个 RabbitMQ 节点。它能说明单个 broker 进程重启或被 kill -9 之后,已确认的持久消息还在不在;它...
后端系统工程 20:发号服务的唯一性与失败模式
系列导航:上一篇 19 延迟任务的领取与取消 | 本篇 20 | 下一篇 21 有限能力的数据库路由代理 “生成一个唯一 ID”这句话省略了三个限定:在多大范围内唯一,在哪些故障之后仍然唯一,除了唯一还要求什么。一张订单表的主键只需在该表内不重复;跨系统传递的标识要在所有系统中不重复;作为分片键或排序键时,还会被要求大致按时间递增。不同方案对这些问题的回答不同,它们的失败模式也不同:数据库号段在进程崩溃后留下空洞,时间型标识在时钟回退时可能重复,节点号方案在两个进程误用同一节点号时必然重复。 本篇实现一个基于 PostgreSQL 的号段发号器,作为主方案验证并发取号、进程重启、数据库故障下的唯一性;再实现 UUIDv4、UUIDv7 和 Snowflake 式 64 位标识作为契约对照,用受控时钟测试时钟回退,用真实多进程测试节点号冲突。代码在 examples/20-id-service/,输出在 evidence/20/output.txt。 需求契约 号段服务的使用方只调用一个操作:为某个业务标签取下一个号。服务承诺: 同一业务标签下发出的号全局唯一,这一点在发号进程崩...
后端系统工程 19:延迟任务的领取与取消
系列导航:上一篇 18 消息队列接入与积压恢复 | 本篇 19 | 下一篇 20 发号服务的唯一性与失败模式 “三十分钟后关闭未支付的预约”“两小时后提醒参会者”,这类需求都可以写成一句话:到了某个时刻之后,执行一次某个动作。真正实现起来,这句话里的“到了”“执行”和“一次”都要重新定义。到期时刻由谁的时钟判断?多个执行者同时扫描时,谁拿到任务?执行者拿到任务后被杀死或被暂停,任务何时由别人接手?原执行者恢复后又提交结果,系统听谁的?取消请求与到期同时到达,返回给调用方的“已取消”究竟保证了什么? 本篇用 PostgreSQL 保存任务,多个独立 JVM 进程担任执行者,用真实的 kill -9 和 SIGSTOP 制造执行者失联,逐一回答这些问题。结论先写在契约里:系统允许同一任务有多次执行尝试,“一次”只落在副作用上,并且要靠唯一约束或幂等键来保证。代码在 examples/19-delayed-tasks/,输出在 evidence/19/output.txt。 需求契约 调用方可以做两件事:安排一个任务(类型、载荷、延迟、最大尝试次数),以及取消一个任务。执行者可以做四件...
后端系统工程 23:分片扩容时的数据交接
系列导航:上一篇 22 非分片键查询与全局分页 | 本篇 23 | 下一篇 24 在线变更的数据兼容 两个分片扩到三个,需要把一部分数据从旧分片搬到新分片,同时写入不停。这件事拆开看有三个难点。复制需要时间,复制期间源端还在变化,复制完的副本一出来就是旧的。路由表改了之后,已经拿着旧路由的写入方不会立刻知道,它们的写会继续打到源端。写入方遇到超时会重试,一次请求可能在源端提交了、重试却落到目标端。任何一处处理不当,结果都是某些已经告诉调用方“成功”的写,在迁移完成后不见了,或者已经删除的行又回来了。 本篇实现按桶迁移:版本化路由、快照复制、按事务水位追赶、冻结栅栏、按键与版本校验、切流。三个独立的写入进程在迁移全程持续写入、删除与重试,迁移结束后逐键核对“所有已确认写”。代码在 examples/23-shard-rebalance/,输出在 evidence/23/output.txt。 需求与不变量 数据是合成的键值记录 items(item_id, bucket, val, row_version),写操作有三种:创建(值为 0,版本 1)、增量(val += delta,...
后端系统工程 16:服务发现中的旧地址
系列导航:上一篇 15 配置发布与客户端收敛 | 本篇 16 | 下一篇 17 从持久日志构建最小消息队列 服务发现的地址集总是落后于事实。一个提供者进程被暂停、被杀死,或者与注册中心断开,注册中心要等租约过期才删除它的地址;删除事件再经过 watch 流到达消费者,消费者才停止向它发请求。这段时间里,消费者手里的地址已经指向一个不能服务的进程。服务发现做不到“没有旧地址”,能做的是把旧地址的存活时间算清楚,并给这段时间准备失败预算。 本篇用真实 etcd 3.4 实例、真实提供者进程和真实消费者进程测量这个窗口:提供者被 SIGSTOP(进程在,续租停)、被 kill -9、被优雅终止时,旧地址各存活多久;同一窗口内,带本地剔除与重试的消费者和不带的消费者各有多少次失败。另外测试三件和“不漏事件”有关的事:watch 断线后按 revision 续订、历史被压缩后全量重新同步、etcd 自身重启。负向对照两个:断线后从“现在”开始 watch 的消费者,以及恢复后不检查租约的提供者。代码在 examples/16-service-discovery/,输出在 evidence/...
后端系统工程 22:非分片键查询与全局分页
系列导航:上一篇 21 有限能力的数据库路由代理 | 本篇 22 | 下一篇 23 分片扩容时的数据交接 订单按买家 user_id 分片之后,“某个买家的订单”只落在一个库里,查询直接路由即可。“某个商户的订单,按下单时间倒序分页”就不同了:同一商户的订单散在每一个分片上,路由层手里没有能把它收窄到单个分片的条件。上一篇的路由代理对这类语句的处理是拒绝。业务上这个查询不能不做,于是有两条路:每次都问遍所有分片,再在协调方合并;或者额外维护一张按商户组织的全局索引,先查索引再回分片取数。 两条路都要回答同一组问题:翻页时同一行会不会出现两次,会不会有行永远翻不到;翻页期间有新订单写入时,边界划在哪里;索引和主数据不一致时返回什么;一次查询最多允许打到几个分片、等多久。代码在 examples/22-shard-query/,输出在 evidence/22/output.txt。 查询契约与规模假设 查询只有一种:给定 merchant_id,按 (created_at desc, order_id desc) 返回一页订单,以及一个用于取下一页的不透明游标。对它的承诺如下: 翻...
后端系统工程 17:从持久日志构建最小消息队列
系列导航:上一篇 16 服务发现中的旧地址 | 本篇 17 | 下一篇 18 消息队列接入与积压恢复 生产者收到一句“发送成功”,这句话到底承诺了什么?消息可能只是进了 broker 的内存队列,可能已经交给操作系统但还停在页缓存里,也可能已经被 fsync 刷到设备。三种状态在正常运行时看不出差别,进程崩溃、掉电或者磁盘出错时才会分开。消费端也有同样的问题:消费者读到了消息、处理了消息、提交了进度,这三件事的先后顺序决定了重启后丢消息还是重复处理。 本篇用一个单机日志队列把这些位置逐一拆开:分段追加日志、带长度与 CRC32C 的记录格式、启动时的恢复扫描、broker 管理的消费组进度,以及“确认—落盘—消费确认”三条彼此独立的线。代码在 examples/17-log-queue/,全部输出在 evidence/17/output.txt。 先划清边界:这是教学用的单机实现,没有复制,没有共识,没有多副本确认,也没有分区与再均衡。它最多只能说明“一块本地磁盘上的一个进程”在进程崩溃后能保住什么;单机磁盘损坏时数据就丢了,任何关于集群持久性的结论都不能从这里推出。 需求与保证...
后端系统工程 21:有限能力的数据库路由代理
系列导航:上一篇 20 发号服务的唯一性与失败模式 | 本篇 21 | 下一篇 22 非分片键查询与全局分页 账户表拆到两个 PostgreSQL 实例之后,每一次读写都要先回答“这一行在哪个库”。把这个问题从业务代码里拿走的常见做法,是在应用与数据库之间放一个路由层。路由层一旦被叫作“数据库代理”,读者往往默认它像一个数据库:任何 SQL 都能发,事务照常工作,会话参数照常生效。这种默认是错的。路由代理能承诺的范围,取决于它真正解析了什么、钉住了什么、拒绝了什么。 本篇实现一个能力有限的路由代理,并把限制写成接口的一部分:有限路由代理不等于兼容任意 SQL 或数据库协议。它只认一张分片表上的严格语法子集,只支持单分片事务,对子集之外的请求返回带编码的拒绝。代码在 examples/21-routing-proxy/,全部输出在 evidence/21/output.txt,证据边界见 evidence/21/LIMITS.md。 范围与不变量 业务对象是合成的测试账户:account(account_id, owner, balance, version),account_id...










