后端系统工程 15:配置发布与客户端收敛
系列导航:上一篇 14 会话存储与登录撤销 | 本篇 15 | 下一篇 16 服务发现中的旧地址 一次配置发布在数据库里只是一次事务提交,真正难的是提交之后的那段路:成百上千个客户端进程要在有限时间内拿到这份新配置,中途可能错过通知、收到迟到的旧响应、整段时间离线,或者在服务端不可达时启动。发布本身也会出错,一份取值越界的配置一旦被客户端接受,后果和一次错误的代码上线相同。 本篇实现一个最小配置中心:PostgreSQL 保存不可变版本和单调递增的发布记录,一个独立 JVM 提供快照与长轮询接口,客户端是独立的 Python 进程,带本地缓存和灰度命中。实验用真实组件制造五类故障:通知丢失、旧响应、客户端离线、服务器重启、缓存损坏,再加两个负向对照,分别是只靠通知的服务器和无条件接受响应的客户端。代码在 examples/15-config-center/,输出在 evidence/15/output.txt。 需求契约 操作只有四个:发布一份配置(可附灰度规则)、回退到某个旧版本、客户端读取当前快照、客户端等待下一次变化。系统承诺以下不变量: 版本内容写入后不再修改;每次发布...
后端系统工程 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 ...
后端系统工程 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。 失效命令可能重复、乱序到达,重复和乱序不...
后端系统工程 26:租约互斥与资源端栅栏
一个每日报表作业部署了多个实例,同一时刻只应有一个实例往报表输出库写结果。常见做法是在数据库里放一张租约表:谁抢到租约谁干活,租约到期没有续上就让给别人。这套做法能减少重复执行,但它本身不能阻止一种写入:实例 A 拿到租约、做完写前检查,然后整个进程停住;租约在停顿期间过期,实例 B 接手并写完;A 恢复后把手里那份已经过时的结果写了进去。A 自己无法察觉停顿,因为它恢复后执行的下一条指令就是“发出写请求”。 本篇的验收条件只有一条:暂停的旧持有者恢复后,无法写入受保护资源。实现上,租约在获取时原子地发出一个单调递增的 fencing token,受保护资源在写入的同一个原子操作里比较 token,拒绝比自己见过的最大值更小的请求。另外用同一时间线说明:资源端如果不检查 token,这个保证就不存在,再完善的租约也补不上。 分布式锁由几类可以叠加的能力组成。原子占位处理并发抢占,TTL 保证故障后的最终释放,Watchdog 减少长任务的误过期,fencing token 阻止过期持有者写入。Redis HA、Redlock 与 ZooKeeper/etcd 位于锁服务与授予方式...
后端系统工程 27:测试账户的余额与账本
一个积分账户只要支持“扣减”,就会同时面对三类问题:多个请求同时扣同一个账户时会不会扣成负数;客户端超时后重发时会不会扣两次;余额列被某段代码改错以后,能不能从别的记录里算出正确值。本篇用一个测试账户服务回答这三个问题。账户、金额与请求都是合成数据,金额单位是“模拟积分”,不对应任何真实货币或支付渠道。 验收目标有三条,每条都落到具体的检查语句和用例编号上: 并发扣减不透支。 超时重试不重复扣款。 余额与流水守恒,余额可以从流水重算。 需求与范围 服务管理一组测试账户,每个账户有一个整数余额。业务方通过 HTTP 发起扣减,例如兑换一件测试奖品花掉 30 积分;运营脚本通过内部调用发放积分。不支持撤销、冻结、部分退款与多币种,也不处理跨库转账。 本章约定的业务不变量: 编号 不变量 检查位置 I1 普通账户余额不小于 0 account.balance_non_negative 约束;UPDATE ... WHERE 条件 I2 同一客户端、同一幂等键至多产生一笔交易 idem_request 主键、ledger_txn 唯一约束 I3 每笔交易的分录之...
后端系统工程 28:稀缺名额的准入与库存预留
一个预约名额只有 40 个、同一时刻却有几百个请求到达的场景,至少要回答四件事:同时进入数据库的请求怎样限量,超出的请求是排队还是立刻拒绝;40 个名额怎样保证不多发;客户端在等待中断开或超时重发时,名额会不会被占两次;占了名额却迟迟不付款的预留到期后怎样归还,归还任务重跑时会不会多还。本篇用 examples/flash-sale 中的最小服务逐一回答。商品、用户和请求都是合成数据,SKU 固定为 sku-lab-01,用户为 user-0042 这类编号,域名只用 shop.example.test。订单与支付留给下一篇,本篇只交付“名额预留”这一层。 需求与范围 需求按可检查的形式写成五条: 任一时刻有效预留(状态为 HELD 或 CONFIRMED)的数量不超过库存总数。 同一个 request_key 无论发送多少次,最多产生一条预留;同键换用户或换 SKU 视为错误请求。 同一用户在同一 SKU 上最多持有一条有效预留。 单个服务进程内同时访问数据库的预留请求不超过并发上限;等待位置有界,满了立即返回 503。 到期的 HELD 预留由释放任务归还库存;释放任务重复...
后端系统工程 29:订单、支付模拟与库存补偿
上一篇把名额收进了一张预留表:预留有效期内名额归该用户,到期由释放任务归还。本篇在它上面加一层订单和一个假支付渠道,处理三件预留层回答不了的事:调用渠道超时,钱到底扣了没有;渠道的回调重复、乱序、迟到时,订单状态会不会被改坏;支付成功的消息到达时,预留已经过期并被别人买走,这笔钱怎么办。代码在 examples/flash-sale/src/lab/flash/order,库存层原样复用第 28 篇的 lab.flash.reserve.Inventory,没有改动。全部金额是模拟积分,订单固定 1990 分,SKU 为 sku-lab-01,用户为 user-0042 这类编号,渠道替身只监听本机端口。 需求与范围 需求写成可以逐条断言的形式: 订单状态只能沿状态机迁移;任何迁移都以“当前状态等于前置状态”为条件,不满足即拒绝,不覆盖。 渠道调用没有拿到确定应答时,订单不得判为失败,也不得归还库存;结果必须通过查询或关单收敛。 同一笔支付的成功通知无论到达几次、以什么顺序到达,最多造成一次状态迁移、一次库存确认和一条下游事件。 成功通知到达时若预留已被释放:能重新占到名额就确...










