后端系统工程 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...
后端系统工程 14:会话存储与登录撤销
系列导航:上一篇 13 RPC 客户端的请求生命周期 | 本篇 14 | 下一篇 15 配置发布与客户端收敛 登录给出的是一份可撤回的授权。用户点击“退出”、管理员踢掉一台设备、用户修改口令后选择“退出所有设备”,这些操作都要在某个时刻之后让旧凭据失效。只有一个服务实例时,这件事只是删掉一条内存记录;有多个实例时,就要回答三个问题:哪一份记录说了算,撤销之后多久所有实例都会拒绝,以及一个与撤销同时到达的“续期”请求会不会把会话救回来。 本篇用 PostgreSQL 共享会话表和四个独立 JVM 进程的 HTTP 实例回答这三个问题,并与自包含签名令牌对照。代码在 examples/14-session-store/,输出在 evidence/14/output.txt。 需求契约 接口只有六个:/login、/whoami、/refresh、/logout、/logout-all、/admin/revoke-session。会话凭据放在 Cookie sid 中。会话在以下五个条件同时成立时才有效: 会话行存在,令牌哈希匹配; revoked_at 为空; 会话的 epoch ...
后端系统工程 15:配置发布与客户端收敛
系列导航:上一篇 14 会话存储与登录撤销 | 本篇 15 | 下一篇 16 服务发现中的旧地址 一次配置发布在数据库里只是一次事务提交,真正难的是提交之后的那段路:成百上千个客户端进程要在有限时间内拿到这份新配置,中途可能错过通知、收到迟到的旧响应、整段时间离线,或者在服务端不可达时启动。发布本身也会出错,一份取值越界的配置一旦被客户端接受,后果和一次错误的代码上线相同。 本篇实现一个最小配置中心:PostgreSQL 保存不可变版本和单调递增的发布记录,一个独立 JVM 提供快照与长轮询接口,客户端是独立的 Python 进程,带本地缓存和灰度命中。实验用真实组件制造五类故障:通知丢失、旧响应、客户端离线、服务器重启、缓存损坏,再加两个负向对照,分别是只靠通知的服务器和无条件接受响应的客户端。代码在 examples/15-config-center/,输出在 evidence/15/output.txt。 需求契约 操作只有四个:发布一份配置(可附灰度规则)、回退到某个旧版本、客户端读取当前快照、客户端等待下一次变化。系统承诺以下不变量: 版本内容写入后不再修改;每次发布...
后端系统工程 13:RPC 客户端的请求生命周期
系列导航:上一篇 12 有界连接池与等待取消 | 本篇 13 | 下一篇 14 会话存储与登录撤销 本地函数调用只有两种结局:返回,或者抛出异常。远程调用多了第三种:请求可能已经发出、也可能已经在对端执行,但结果没有回来。调用方在截止时间到达时只能如实说“不知道”。RPC 客户端的大部分复杂度都来自这第三种结局:同一条连接上,迟到的响应不能交给后来的请求;超时之后,客户端要判断能不能再发一次;服务端则不应该为一个已经被放弃的请求做事。 本篇实现一个最小 RPC,客户端是单连接多路复用,服务端是独立 JVM 进程并带故障注入,用 20 个用例检验四件事:字节流被正确切成帧;响应只交给发起它的那次尝试;截止时间同时约束客户端等待和服务端执行;只有允许的操作才会被自动重试。代码在 examples/13-rpc-client/,输出在 evidence/13/output.txt。 需求契约 调用方看到一个操作:call(method, idempotency, key, payload, timeout),返回状态之一:OK、APP_ERROR、INVALID_ARGUMENT、DE...
后端系统工程 12:有界连接池与等待取消
系列导航:上一篇 11 撤回、历史保留与消息系统集成验收 | 本篇 12 | 下一篇 13 RPC 客户端的请求生命周期 数据库连接的数量不由应用决定。PostgreSQL 为每个客户端会话分配一个后端进程,max_connections 规定服务器同时接受的连接上限,并且只能在启动时设置(PostgreSQL 16 连接设置)。应用侧连接池是这份服务端预算在单个进程里的记账员:每借出一条连接,它就替调用方占用了服务端的一个槽位。记账错一次,可能表现为请求永远等不到连接,也可能表现为若干实例同时扩容后,整个数据库拒绝新会话。 本篇实现一个有界连接池,并用真实 PostgreSQL 检验四类结果:容量不超预算;等待超时、等待取消与归还交错时没有槽位丢失;连接在服务端被终止或会话状态被污染后不会交给下一个调用方;多实例的总连接数受数据库侧上限约束。代码在 examples/12-connection-pool/,全部输出在 evidence/12/output.txt。 需求契约 调用方只看到四个操作:在截止时间内获取一条连接;使用后归还;发现连接损坏时声明它不可复用;服务关闭时让池...
后端系统工程 24:在线变更的数据兼容
在线修改表结构时,系统里同时存在一份正在变化的表定义和至少两个版本的应用代码。旧代码按旧列读写,新代码按新列读写,后台回填任务又在补写历史行。三方都在提交事务,任何一方都可能把另一方刚写下的值改掉。DDL 本身还要拿表锁,一条看起来只改元数据的 ALTER TABLE 也可能让整张表的普通查询排队。 本篇用一张合成工单表,把“整数优先级”迁成“文本优先级”,并在迁移后期引入一个旧格式表达不了的新取值。要回答的问题有三个:回填期间新写入的值怎样不被覆盖;旧代码还在只写旧列时,新列怎样不落后;从哪一步开始,回退到旧代码就会丢语义,系统怎样拒绝一次假装无损的回退。实验结论都在本地 PostgreSQL 16.15 上用真实事务、真实线程和独立进程验证,证据在 evidence/24/;只引用文档、没有实验的论断在文中单独标出。 需求、不变量与范围 合成表 ticket 有主键 id、标题 title 和整数列 priority,取值 1、2、3。目标结构用文本列 priority_level 表示优先级,取值 low、normal、high,迁移后期再允许 urgent。映射固定为 1...
后端系统工程 25:缓存旧值回填与失效修复
Cache Aside 的写法很短:读时先查缓存,未命中就读数据库再回填;写时先提交数据库,再删除缓存。这段代码里有一个读者与写者交错的窗口:读者读到旧行之后、回填之前,写者提交了新版本并删掉了缓存,读者随后把旧行写回去。此后缓存里的旧值没有任何人负责清除,只能等 TTL。另一个窗口出现在写者自身:数据库已经提交,删除缓存的那条命令还没发出,进程就退出了。 本篇的验收目标是:数据更新与慢读回填交错之后,旧值可以被拒绝、被检测、被修复;旧值最多存活多久(新鲜度窗口)要写成由哪些量组成的上界,并指出哪些条件下不存在上界。全部数据为合成数据,商品标识形如 sku-lab-05。 需求与范围 服务管理一组商品描述,数据库是唯一真相源,每行有一个单调递增的 version。缓存只是加速副本,允许短暂落后,但不允许“无限期落后”。具体要求: 读路径:命中直接返回;未命中读库并回填。 写路径:只改数据库,然后让缓存失效;不允许写者直接把新值写进缓存(避免写者之间的乱序覆盖成为第二个问题)。 写者在提交后任意时刻崩溃,旧值最终被清除,且不依赖 TTL。 失效命令可能重复、乱序到达,重复和乱序不...















