流载荷、组位置与 PEL 分别变化。投递在确认前推进位置;确认只移除待处理引用。XAUTOCLAIM 转移归属,可能重复模拟副作用,也找不回已删除的载荷。图示来自原有实验。

Redis Streams 让一个消费组分担工作

Redis Streams 消费组把新条目分配给具名消费者,并记录尚未确认的投递。它不保证业务副作用恰好发生一次,不会阻止保留策略删除数据,也不会自行恢复被遗弃的任务。将其用作工作队列前,应区分组的读取位置、待处理条目与流里实际保留的数据。 流载荷、组位置与 PEL 分别变化。投递在确认前推进位置;确认只移除待处理引用。XAUTOCLAIM 转移归属,可能重复模拟副作用,也找不回已删除的载荷。图示来自原有实验。 读取位置与待处理任务不同 XGROUP CREATE jobs workers 0-0 从现有历史之前起步;使用 $ 则从当前末端之后开始。同组成员分担新投递,不同消费组拥有各自独立的投递状态。并发工作进程应使用不同的消费者名字。最后投递位置属于整个组,并不是每个消费者分别提交一个偏移。 未使用 CLAIM 或 NOACK 的普通 XREADGROUP 以 > 请求新条目,投递后将它们记入待处理列表 PEL。组的位置在新条目投递时就推进,发生在 XACK 之前。XPENDING 统计的是已经投递但尚未确认的条目,不是从来没有投递过的积压。因此,待处理计数为零时,组里仍可能有大量未读工作。 XACK 把指定 ID 从该组 PEL 移除,不删除流里的记录,也不推进最后投递位置。确认可以乱序完成,其他组依然可以读取保留下来的条目。XREADGROUP 使用 0-0 时读取的是这个具名消费者自己的待处理历史,不是在请求新工作,也不会把另一位消费者名下的待处理消息交给它。 恢复需要显式决策 反复执行普通 > 读取,不会重试被遗弃的 PEL 条目。XAUTOCLAIM 能把满足条件的待处理条目转给请求方,参数必须包含流、组、消费者、最小空闲毫秒数和起始 ID。之后应使用返回游标继续扫描,直到返回 0-0。某一批认领结果为空,并不代表扫描已经完成;过一段时间再次扫描,原来尚未达到空闲阈值的条目也可能变得可认领。 ...

二月 5, 2028 · 3 分钟 · XB Field Notes
生产者 c 等在已被 a、b 填满的两槽队列外;出队路径表示取出 a 后释放槽位,c 才能进入。

背压只有改变生产者行为,才真正控制了有界接入

背压把消费者的有限容量与生产者行为联系起来。通道已满时,生产者可能等待、拒绝新任务,或执行经过明确选择的丢弃策略。关键是容量限制真正改变上游怎么做。队列不断增长,或仪表盘显示积压,只能说明供需失衡,本身不自动形成控制接入的机制。需要从观察到过载,继续追到生产者是否确实减速或停止提交。 坏掉的样子 无界队列可以吸收短暂突发,却也会隐藏持续过载。如果到达速度长期超过完成速度,未完成任务和等待时间就持续增长。把积压挪到待处理线程、重试任务或另一个无界缓冲,并没有消除压力。容量边界必须覆盖任务真正被保存的位置,不能只限制一个容易观察的队列对象,却允许周围继续无限保留工作。 有界队列本身也没有定义完整过载策略。无限阻塞可能耗尽生产者线程或让关闭流程卡住;立即拒绝时,调用方若误认为成功,就可能丢失工作;每次拒绝后立即重试,又可能加重负载。应依据应用契约明确选择:在截止时间内等待、拒绝并稍后重试,还是有意丢弃。不同选择改变交付语义,不能在实现中含糊处理。 队列容量与处理完成是两件事。消费者取出项目可以释放槽位,但此时可能仍在处理这个项目。等待容量为二,也可能同时还有额外执行中的工作。生产者成功入队,仅说明队列接受了项目,不证明已经执行、持久保存或完成下游副作用。若把所有这些状态都叫作“成功”,过载和失败时就无法判断责任归属。 取出 a 先释放准入容量,处理确认发生在后。put 成功、队列为空与 join 完成是不同边界;队列记账不代表持久交付。 快速验证 将脚本保存为 check-backpressure.py,运行 python3 check-backpressure.py。它只使用 Python 标准库,在 Python 3.14.4 上执行。线程通过明确事件和有界等待协调,不依赖远端消息代理、数据库或刻意拖慢的外部服务。所有队列和任务都只存在当前进程中,结果用于观察接入与完成的语义,不代表分布式队列的持久化保证: from queue import Queue,Full from threading import Event,Thread import json unbounded=Queue() for item in range(1000):unbounded.put_nowait(item) assert unbounded.qsize()==1000 channel=Queue(maxsize=2) channel.put('a');channel.put('b') try: channel.put('deadline-rejected',timeout=.02) raise AssertionError('a full bounded queue accepted another item') except Full:rejected=True assert channel.qsize()==2 entered,delivered=Event(),Event() def produce(): entered.set();channel.put('c',timeout=2);delivered.set() producer=Thread(target=produce,daemon=True);producer.start();assert entered.wait(1) assert not delivered.wait(.05) and channel.qsize()==2 first=channel.get(timeout=1);assert first=='a' assert delivered.wait(1);producer.join(timeout=1);assert not producer.is_alive() assert channel.qsize()==2 rest=[channel.get(timeout=1),channel.get(timeout=1)];assert rest==['b','c'] assert channel.empty() completed=Event() def join():channel.join();completed.set() waiter=Thread(target=join,daemon=True);waiter.start();assert not completed.wait(.05) for item in [first,*rest]:channel.task_done() assert completed.wait(1);waiter.join(timeout=1);assert not waiter.is_alive() print(json.dumps({'unbounded_backlog':unbounded.qsize(),'bounded_capacity':2,'deadline_rejected':rejected, 'blocked_producer_resumed_after_get':delivered.is_set(),'delivered_items':[first,*rest], 'empty_queue_did_not_mean_processing_complete':True,'join_finished_after_task_done':completed.is_set()})) 第一个队列使用默认无界容量。没有消费者运行时,一千次 put_nowait 全部成功,留下同样数量的积压。这是小规模行为演示,不是耗尽内存测试。它说明接入成功本身无法证明下游已有进展,也不能证明到达速度可持续。监控若只统计提交成功率,就可能在处理能力不足时仍显示表面正常。 ...

八月 12, 2027 · 1 分钟 · XB Field Notes
两行 a、b、c 对比:RPUSH 配另一端的 LPOP 得到先进先出,配同端 RPOP 得到后进先出。

Redis 列表既是队列,也能当栈

Redis 列表是有顺序的字节值序列,可以从两端插入和取出。选择哪一端写、哪一端读,决定了它表现为队列还是栈。阻塞操作只是等待数据到达,并不自动提供分布式锁、消息确认或完整的任务处理协议。这些能力必须根据业务需要另外明确,不能从“支持阻塞”直接推导出来。 先把两端的约定说清楚 RPUSH 配合 LPOP 按先进先出顺序移除元素,早进入的元素先离开;RPUSH 配合 RPOP 则是后进先出。一条 LPUSH 带多个值时,会依照参数顺序逐个往左插入,因此传入 a、b、c 后,从左到右看到的是 c、b、a。设计队列时要验证生产者真正执行的命令,而不是只看图里画了哪一端。 列表允许重复值,相同消息插入两次就占两个位置,不会自动完成去重。当两次投递必须能够区分时,应使用不同的任务标识。LRANGE 的起止位置都包含在返回范围里,-1 表示最后一个元素。最后一个元素被移除时,整个列表键也会消失;对不存在的列表弹出会返回空结果,而不是创建一个空列表占位。 两端操作的单元素成本较低,但返回很大的范围或一次删除许多元素,仍然需要相应的工作量。LTRIM 可以保留最近一段窗口,不过用它裁剪待处理任务,就等于主动丢弃被裁掉的条目。生产者写入和随后的单独裁剪是两条操作,不能当成原子提交。保留哪一段应由数据用途决定,不能把日志窗口策略直接套到任务队列上。 等待的是连接,不是整个服务器 BLPOP 在数据到来或等待超时前阻塞当前连接,其他连接仍可正常执行,生产者也能照常投递。在 redis-py 中,成功结果包含键名和取出的值,超时返回 None;超时参数为零则表示无限等待。客户端的套接字超时需要容纳预期的服务器等待时间及响应时间,否则可能先在客户端超时。 消费者没有业务工作时,阻塞调用仍会占用连接。因此应给消费者设置有界的连接预算,并避免让生产者与正在等待的消费者共用同一条连接。还要区分移除顺序与完成顺序:FIFO 保证先移走较早的元素,多个工作者却可能先完成后来的任务。仅凭阻塞等待,无法保证某个业务资源只有一个持有者。 坏掉的样子 LPOP 和 BLPOP 都在业务处理完成之前把任务从列表里移除。消费者刚取到任务就崩溃,或者服务器弹出后响应丢失,该任务都已经不在待处理列表中。Redis 不会自动把它恢复回来,也不要求消费者确认完成。单独配置持久化,并不能修补这类应用层交接窗口。 Redis 6.2 开始提供 LMOVE,可以在一条原子命令中把任务从待处理列表转移到处理中列表;BLMOVE 是对应的阻塞版本。使用 Redis Cluster 时,两个列表键必须落在同一哈希槽;名称看起来相关,并不能保证满足这一条件。成功处理后,可通过 LREM 按唯一任务标识移除一个条目,作为应用约定的确认。对于长时间没有完成的任务,应用仍需定义恢复策略,因为列表不会自动为每个元素维护租约、时间戳或重试次数。 ...

四月 18, 2027 · 3 分钟 · XB Field Notes
重投进入同一事务边界,边界内包含操作身份与业务效果。

保护已提交效果:示意。

队列定义投递,消费者仍须保护副作用

队列的投递承诺只描述工作流程中的一段边界。消息到达消费者,与业务副作用已经提交,是不同事件。至少一次允许重复投递,至多一次则允许在不确定时丢失而不继续重试。仅凭这两个标签,不能保证订单、通知或数据库更新在所有故障下都恰好完成一次。可靠性需要沿真正的数据和确认路径逐步检查。 找到发布、确认与提交的位置 首先明确生产方何时知道 broker 已经接受消息、保留多久,以及持久化政策覆盖哪些故障。消息从未成功发布、超过保留期或被明确删除,都不会因为消费者自称至少一次就自动恢复。恢复前提必须与投递标签一起记录,否则“永不丢失”会遮住多个根本没有交给队列负责的环节。 消费方若先确认,再提交业务结果,就会形成丢失窗口:broker 停止重试时,应用可能还没完成工作。先提交又留下另一种窗口:结果已成功,但确认丢失,工作于是重新出现。这个重复仍然符合投递契约,并不意味着最初操作失败。排查时要分别检查业务结果与确认结果,不能用其中一个推断另一个。 可见性超时与确认期限控制的是何时允许下一次尝试,不证明第一个工作进程已经停止。慢处理者可以与重投后的处理者同时运行,因此延长期限与防止重复副作用解决的是不同问题。应同时记录投递尝试和已经提交的业务结果,而不是只统计消费者调用次数,便把它当作成功执行次数。 在真正的事务边界内消除重复效果 若副作用都位于同一个事务数据库,可以把唯一操作身份与业务结果放进同一事务。唯一性检查必须能够裁决并发尝试,单独读取再无保护地写入并不够。还需要保存足够的载荷信息,识别“同一身份却携带不同指令”的情况,不能看到编号重复就无条件接受。这既是去重问题,也是操作身份的契约问题。 数据库事务不会自动包住外部邮件、支付接口或另一套独立存储。这些目标需要自己的幂等或协调契约。本地标记先写、远端调用后做,可能丢失效果;标记后写,则可能留下重复窗口。因此,本地去重表不能被说成端到端恰好一次保证。只有明确哪些动作一起提交、哪些动作仍独立失败,设计才有可检查的边界。 有些服务确实提供带明确范围的恰好一次投递能力,例如 Google Pub/Sub 说明了地域、订阅类型等限制,也区分投递重复与发布侧重复。应阅读具体契约,不应宣称这个能力不可能存在,也不能把它推广成所有下游副作用都已解决。多个已发布消息代表同一业务意图时,稳定业务身份仍然有独立价值。 坏掉的样子 持续失败的消息可能阻塞有序进度,过短期限则可能让本来正常的任务出现重叠尝试。重投计数能提供调查线索,却不能唯一判断是毒数据、慢依赖、确认丢失还是工作进程停滞。应关联消息身份、载荷、异常、处理时长和确认记录,再决定怎样修改重试政策。只根据“一条还是多条计数升高”断言根因,很容易修错位置。 需要限制重试并说明隔离失败如何审阅或重新执行。进入死信队列只是移动未完成工作,并没有完成它。去重记录的保存期限必须覆盖计划容忍的重放范围,删除身份记录之后,旧请求可能再次生效;永远保存又有存储和隐私代价。这里没有适用于所有任务的统一选择,应把保留与重放规则一起设计。 历史精确 SQLite 落点证据涵盖回滚、重开和去重。总计是虚构夹具值;broker 行为与外部效果仍是独立边界。 快速验证 保存为 delivery_guarantee_probe.py,用启用断言的 Python 3 运行,不能使用 -O 或 PYTHONOPTIMIZE,因为断言中含有必需调用。保留的精确代码块记录报告 Python 3.12.14 与 SQLite 3.53.1 上通过;本轮没有重跑。临时 SQLite 数据库同时保存身份与无害数字总计,检查的是事务落点,不会扣款或发送外部消息,创建及清理的只是本程序自己的临时文件。 from contextlib import closing from pathlib import Path from tempfile import TemporaryDirectory import sqlite3 def apply(db, message_id, amount, interrupt=False): db.execute('BEGIN IMMEDIATE') try: prior = db.execute('SELECT amount FROM inbox WHERE id=?', (message_id,)).fetchone() if prior is not None: if prior[0] != amount: raise ValueError('same identity, different payload') db.commit() return False db.execute('INSERT INTO inbox VALUES (?, ?)', (message_id, amount)) db.execute('UPDATE totals SET amount=amount+? WHERE id=1', (amount,)) if interrupt: raise RuntimeError('controlled failure before commit') db.commit() return True except BaseException: db.rollback() raise with TemporaryDirectory(prefix='queue-sink-') as directory: path = Path(directory) / 'sink.db' with closing(sqlite3.connect(path, isolation_level=None)) as db: db.execute('CREATE TABLE inbox(id TEXT PRIMARY KEY, amount INTEGER NOT NULL)') db.execute('CREATE TABLE totals(id INTEGER PRIMARY KEY, amount INTEGER NOT NULL)') db.execute('INSERT INTO totals VALUES (1, 0)') try: apply(db, 'work-1', 100, interrupt=True) except RuntimeError: pass else: raise AssertionError('controlled failure did not occur') assert db.execute('SELECT count(*) FROM inbox').fetchone() == (0,) assert db.execute('SELECT amount FROM totals').fetchone() == (0,) assert apply(db, 'work-1', 100) assert apply(db, 'work-2', 50) # Close and reopen the real database rather than retaining an in-memory set. with closing(sqlite3.connect(path, isolation_level=None)) as db: assert not apply(db, 'work-1', 100) try: apply(db, 'work-1', 999) except ValueError: pass else: raise AssertionError('conflicting payload reused the same identity') assert db.execute('SELECT count(*) FROM inbox').fetchone() == (2,) assert db.execute('SELECT amount FROM totals').fetchone() == (150,) assert not db.in_transaction print('rollback kept marker and effect together; reopened retry changed nothing; total=150') 两项写入之后主动抛出的异常,会让身份标记与总计一起回滚。随后重试,提交两项操作;关闭并重新打开数据库后,重复第一项身份不会改变结果,而用不同数额复用相同身份会报错。最终数据库有两个身份,总计为一百五十,并且连接不在事务中。这个检查比只把编号放进内存集合更能说明持久化边界。 ...

二月 5, 2027 · 2 分钟 · XB Field Notes