后端系统工程 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 这类编号,渠道替身只监听本机端口。 需求与范围 需求写成可以逐条断言的形式: 订单状态只能沿状态机迁移;任何迁移都以“当前状态等于前置状态”为条件,不满足即拒绝,不覆盖。 渠道调用没有拿到确定应答时,订单不得判为失败,也不得归还库存;结果必须通过查询或关单收敛。 同一笔支付的成功通知无论到达几次、以什么顺序到达,最多造成一次状态迁移、一次库存确认和一条下游事件。 成功通知到达时若预留已被释放:能重新占到名额就确...
后端系统工程 30:关注关系与可撤销订阅
关注关系看起来只是一张两列的表,难点在于它同时是一种授权。用户 A 关注私密账号 B 并获批后,A 就能读 B 的“仅粉丝可见”内容;B 拉黑 A 之后,这项授权必须收回,而且收回之后 B 新发的内容不能再被 A 看到。关系表之外通常还有粉丝列表、粉丝计数、下一篇要讲的 Feed 收件箱等派生视图,它们靠消费关系事件异步更新。重复点击关注、关注与取消交错、事件乱序和重复投递、消费者丢失进度,都会让派生视图与真相源出现差距;差距一旦落在权限上,就是越权读取。 本章的工程放在 examples/social 的 social.rel 包中,全部用例跑在真实 PostgreSQL 16.15 事务上。验收目标有两条:重复关注、取消与事件乱序不会让已经取消的关系“复活”;内容入口与派生视图在权限判断上给出一致的结论,或者明确声明二者允许相差的窗口。 需求与范围 操作只有六个:关注、取消关注、私密账号审批(同意或拒绝)、拉黑、解除拉黑、发布带可见性的内容。可见性分三档:PUBLIC 任何未被拉黑的人可读,FOLLOWERS 只有有效粉丝可读,SELF 只有作者本人可读。 业务不变量写成可以...
后端系统工程 31:Feed 的扇出成本与热点
首页 Feed 要回答一个问题:读者当前关注的作者里,最近发了哪些读者有权看的帖子,按什么顺序排,翻到第二页时从哪里接着读。工程上的争议集中在“候选什么时候算出来”。发帖时把帖子写进每个粉丝的收件箱(推),代价落在写路径上,并且和作者粉丝数成正比;读 Feed 时现场到每个被关注作者的时间线里取(拉),代价落在读路径上,和读者关注的作者数成正比;按作者粉丝数分档(混合),则把两种代价按阈值切开。粉丝数呈重尾分布时,少数热点作者决定了推方案的写入峰值,也决定了拉方案里被反复读取的那几条时间线。 另一层要求来自第 30 篇:关注关系是一种授权。收件箱里的行是发帖时刻算出的候选,读者之后取消关注、被拉黑、帖子改成仅自己可见,这些行不会自己消失。如果读路径直接把物化的收件箱行返回给读者,撤销就被绕过了。 本章工程在 examples/social 的 social.feed 包中,复用第 30 篇 social.rel 的关系表、RelationService 与 AccessCheck,全部用例跑在真实 PostgreSQL 16.15 上。验收目标有两条:在同一张合成关注图上比较推、...
后端系统工程 32:点赞事实与计数校准
点赞数是派生数据。真相源是“谁赞了哪个条目”这组事实,计数只是对事实的汇总;为了让读取不必每次 count(*),计数被单独存放、异步更新。破坏两者一致的路径有五条:用户重复点击,赞与取消交错,事件被重复投递,消费者在提交与确认之间崩溃,以及修复用的批量重算与在线增量同时改同一行。前三条会把计数推高或拉低;消费者崩溃会让已生效的事件被再投一次;批量重算若与在线增量直接冲突,修复本身就会制造新的偏差。 本章代码在 examples/social 的 social.like 包,全部用例跑在真实 PostgreSQL 16.15 上,包括独立 JVM 的 kill -9 与数据库 immediate 停机。验收目标:点赞/取消交错与事件重复之后,计数与事实记录一致;计数允许落后于事实,落后多少有明确定义,并且在任何时刻都能算出误差上界。 需求与范围 操作有三个:点赞、取消点赞、读取条目的点赞数。业务不变量写成可以用 SQL 检查的形式: 每个 (user, item) 至多一行事实,liked 表示当前是否已赞,version 每次状态变化加一。 每次状态变化恰好写一条 Outbo...
后端系统工程 33:通知渠道的结果未知
通知服务调用外部渠道时,最难处理的结果是“没有结果”:请求发出去了,响应没回来。渠道可能根本没收到,也可能已经接受并开始外发,只是响应在路上丢了。本地数据库能把“同一事件只生成一条逻辑通知”做成硬约束,却无法替外部渠道保证“用户只收到一次”。本篇用一个最小通知服务把这两件事分开:逻辑去重由唯一约束负责,外部重复由渠道契约决定,结果未知要作为一种持久状态记录下来,并且有出口。 需求与范围 业务方提交事件,例如“订单 ord-1001 已发货”,通知服务按用户偏好为每个启用渠道生成投递任务,由工作进程领取后调用渠道。要满足的条件如下。 同一 (user_id, event_key) 无论被提交多少次、多少并发,只产生一条逻辑通知。 每条逻辑通知在每个启用渠道上最多一条任务;同一任务在同一时刻最多有一个有效令牌;被取代的持有者无法再改写任务状态,但它已发出的外部调用无法撤回。 每次调用渠道都留下一条投递记录,结果未知的调用能在表里查到,不被后续结果覆盖。 超时后的重试策略取决于渠道能力:支持幂等键就带同一键重发;不支持幂等键但可查询就先查询;两者都不具备就停止自动处理。 领取前偏好被...
后端系统工程 34:可撤销短链与访问统计
短链服务的主路径只有一次查表和一个 3xx 响应,难点在三个容易被忽略的边界:两个创建者同时申请同一个别名时谁的目标留下;所有者撤销一条短链之后,旧目标还会被返回多久;统计库变慢或不可用时,跳转是否跟着变慢。实现是一个 Java 21 + PostgreSQL 16 的最小服务,三个问题各自对应一组可执行的断言。 需求与范围 操作只有五种:创建随机码短链、创建自定义别名、撤销、按码访问、记录点击。过期时间在创建时写定,之后不改;目标地址创建后不可修改,需要改目标就撤销旧码再建新码。 业务不变量: I1 码唯一且不覆盖。一个码最多对应一个目标;任何被告知“创建成功”的调用方,其提交的目标就是库中该码的目标。 I2 撤销有界生效。撤销事务提交后,返回旧目标的 302 只会在一个可计算的窗口内出现,窗口上界由缓存参数决定,并且可以测量。 I3 统计不阻塞跳转。统计库连接失败、表锁等待或写入超时,不改变重定向的状态码,请求线程也不等待统计侧的连接或锁。 I4 点击至多计一次。同一次点击因重试被写入多次时,统计表里只有一行。点击可以因队列满而丢弃,但丢弃数必须被计数。 明确排除:账号与...
后端系统工程 35:文件上传与异步处理
一次文件上传跨越两个互不共享事务的存储:字节落在文件系统或对象存储里,文件名、归属、处理进度落在数据库里。两边各自可以做到原子,合起来却没有一次提交能同时覆盖它们。于是有三种残局必须单独处理:字节写完了、元数据没提交,字节成了没人认领的孤儿;后台处理任务因为租约过期被执行了两次,发布动作也跟着做了两次;处理还没结束,读取接口已经把文件交了出去。示例实现基于 Java 21、PostgreSQL 16 和一个本地目录替身,覆盖从分块上传到发布读取的完整链路,三种残局分别用崩溃、重复执行和提前读取的实验构造出来。 需求与范围 操作有七个:创建上传会话、上传分块、完成上传、提交元数据、中止会话、读取文件,以及后台的处理与回收。客户端在创建会话时声明分块数、总字节数和整体 SHA-256,分块可以乱序、可以重传。 业务不变量: F1 字节不丢、不错。任何进入内容地址 blobs/sha256/<hex> 的文件,其内容的 SHA-256 等于 <hex>;任何被 file_object 引用的 blob 在回收后仍然存在。 F2 孤儿可回收。字节已经写入但元数据...















