CONDSTORE and QRESYNC
核心思路是利用一个全局递增的“修改序号”(mod-seq)来标记每条消息的状态变动,然后让客户端带上自己上次同步时看到的 mod-seq,仅拉取自那以后“真正变动”过的消息(新邮件、标志改动或被删除),从而避免每次都要全量扫描、也不会把旧邮件当“新邮件”再次推送。
一、原理讲解
mod-seq(修改序号)
- 服务端为每个邮箱(mailbox)维护一个
highest_modseq
,初始 0。 - 每当有新邮件到达或对已有消息做 STORE/EXPUNGE/MOVE 等会改变状态的操作时,先把
highest_modseq++
,再把这次操作涉及到的消息的modseq
字段更新为这个新值。 - 这样,每条消息的
modseq
总是它最后一次状态变动时的序号。
- 服务端为每个邮箱(mailbox)维护一个
CONDSTORE 扩展
在 CAPABILITY 里报出
CONDSTORE
。SELECT 时,服务器在标准的 EXISTS/RECENT/UIDVALIDITY/UIDNEXT 之后,额外返回:
* OK [HIGHESTMODSEQ 12345]
这里的
12345
就是当前最高的修改序号。客户端本地记录这个数字,下次只对
modseq > 12345
的消息发 FETCH,就只拉新邮件或真有变动的标志。
QRESYNC 扩展
在 CAPABILITY 里再报出
QRESYNC
。客户端在重新 SELECT 时带上
(QRESYNC (<last-uid-validity> <last-modseq> <known-uid-set>))
参数:A142 SELECT "INBOX" (QRESYNC (1750701677 12345 1,4:6))
服务器先返回一个
VANISHED
列表,告知自上次以来被 EXPUNGE(删除)的那些 UID;然后以
CHANGEDSINCE
的方式只 FETCHmodseq>last-modseq
的消息;最后跟上正常的 SELECT 响应(EXISTS/RECENT/UIDVALIDITY/UIDNEXT/HIGHESTMODSEQ)。
这种“先通知删除,再增量拉取变动,再给出最新状态”流程,既让客户端保持与服务器一致,又避免重复或遗漏。
二、典型交互流程示例
下面给出一个精简的示例,标注客户端(C:)和服务端(S:)通信:
C: A001 CAPABILITY
S: * CAPABILITY IMAP4rev1 CONDSTORE QRESYNC …
S: A001 OK CAPABILITY completed
C: A002 LOGIN user pass
S: A002 OK LOGIN completed
C: A003 SELECT "INBOX"
S: * FLAGS (\Answered \Flagged \Deleted \Seen \Draft)
S: * OK [PERMANENTFLAGS (\Answered \Flagged \Deleted \Seen \Draft \*)]
S: * 5 EXISTS
S: * 0 RECENT
S: * OK [UIDVALIDITY 1750701677]
S: * OK [UIDNEXT 10]
S: * OK [HIGHESTMODSEQ 2048]
S: A003 OK [READ-WRITE] SELECT completed
# —— 客户端初次要全量拿 FLAGS 或 HEADER ——
C: A004 UID FETCH 1:* (FLAGS)
S: * 1 FETCH (UID 1 FLAGS (\Seen))
…
S: * 9 FETCH (UID 9 FLAGS ())
S: A004 OK FETCH completed [HIGHESTMODSEQ 2048]
# —— 一段时间后,有邮件和标志变化 ——
C: A005 IDLE
S: + idling
S: * 10 EXISTS
S: * 1 RECENT ← 通知新邮件到达
# 客户端退出 IDLE
C: A005 DONE
S: A005 OK IDLE completed
# —— 客户端增量抓取新变动,只拉 UID ≥ UIDNEXT 且 modseq>2048 ——
C: A006 UID FETCH 10:* (FLAGS) (CHANGEDSINCE 2048)
S: * 10 FETCH (UID 10 FLAGS () MODSEQ 2050)
S: A006 OK FETCH completed [HIGHESTMODSEQ 2050]
# —— 客户端再拉这条新邮件的 HEADER/BODY ——
C: A007 UID FETCH 10 (RFC822.SIZE FLAGS BODY.PEEK[HEADER] BODY.PEEK[TEXT]<0>)
S: * 10 FETCH (UID 10 … BODY[HEADER] {…} … BODY[TEXT] {…})
S: A007 OK FETCH completed
- 首次 SELECT 拿到当前
HIGHESTMODSEQ=2048
。 - 初次 FETCH 拿完历史消息后,也把
[HIGHESTMODSEQ 2048]
送回给客户端。 - IDLE 时服务器推送新邮件到达 (
EXISTS
/RECENT
)。 - 客户端发带
CHANGEDSINCE 2048
的 FETCH,只收到那条modseq=2050
的 UID 10。 - 最后再专门对 UID 10 拉 HEADER/BODY。
这样,客户端既能实时收到新邮件与变动,又永不重复拉旧数据。
3. 邮件层面 modseq
虽然你不会在普通的 FETCH 响应里一并返回每条消息的 modseq
,但要能实现 CONDSTORE/QRESYNC 里的增量查询和 CHANGEDSINCE
过滤,你必须在消息层面保存它。原因有三:
判断“哪些消息”发生了变动 客户端只给出了上次看到的
last-modseq
(比如 2048)。服务器要知道哪些消息的状态序号超过了这个值,才能筛选出新邮件、改过标志或被删除的消息;如果不在mail
表里存modseq
,就没法做这种比较。返回给客户端的 MODSEQ 值 在
FETCH … (CHANGEDSINCE <n>)
的响应里,服务器需要把每条变动消息的MODSEQ
一并返回给客户端:* 10 FETCH (UID 10 FLAGS () MODSEQ 2050)
客户端拿到这个新的
2050
,用来更新本地的 “已知最大 modseq” 值。性能和可维护性 在数据库里给每条消息一个
modseq
字段(并对它建索引),比在每次操作时去扫描操作日志、再去拉出受影响的消息要高效得多,也更容易维护。
简要示例表结构
-- 邮箱表:记录最高的 mod-seq
ALTER TABLE mw_mailbox
ADD COLUMN highest_modseq BIGINT NOT NULL DEFAULT 0;
-- 消息表:记录每条消息最后一次变动的序号
ALTER TABLE mw_mail
ADD COLUMN modseq BIGINT NOT NULL DEFAULT 0;
-- 对 modseq 建索引,加速 CHANGEDSINCE 查找
CREATE INDEX idx_mail_mailbox_id_modseq ON mw_mail(mailbox_id, modseq);
只维护邮箱级别的 highest_modseq
而不记录消息层面的 modseq
,就没法筛出自上次同步后“哪些消息”真的变动了,也无法在 FETCH 响应中带回新的 MODSEQ
。因此,消息表上的 modseq
字段是实现增量同步必不可少的一环。
HIGHESTMODSEQ
是针对邮箱(mailbox) 级别维护的,它表示该邮箱当前所产生的最大“修改序号”。 不过,你也需要在每条消息上保存一个 modseq
字段,用来记录该消息最后一次状态变动时的序号。
典型的表结构示例
-- 邮箱表:存最高 mod-seq
ALTER TABLE mailbox
ADD COLUMN highest_modseq BIGINT NOT NULL DEFAULT 0;
-- 消息表:给每条消息记录它的 mod-seq
ALTER TABLE mail
ADD COLUMN modseq BIGINT NOT NULL DEFAULT 0;
4. 修改代码
新邮件入库 或 消息状态变动(STORE/EXPUNGE/MOVE 等)时更新highest_modseq和modseq
添加触发器
- 在
mw_mail
表上加一个BEFORE INSERT OR UPDATE
Trigger,自动把新邮件入库或 MOVE(邮箱变更)时的modseq
写好; - 在
mw_mail_flag
表上加一个AFTER INSERT OR DELETE
Trigger,自动处理 STORE(打标)和 EXPUNGE(删标)时的modseq
更新。
两条 Trigger 都调用同一个底层函数,该函数会:
- 在同一事务里把对应
mw_mailbox.highest_modseq
自增并拿到 新序号; - 把这个序号写入受影响的
mw_mail.modseq
。
1. 底层函数
CREATE OR REPLACE FUNCTION fn_assign_modseq()
RETURNS TRIGGER AS $$
DECLARE
newmod BIGINT;
BEGIN
-- 为这个邮箱分配一个全局递增的新序号
UPDATE mw_mailbox
SET highest_modseq = highest_modseq + 1
WHERE id = TG_ARGV[0] -- 触发时传入 mailbox_id 列名
RETURNING highest_modseq INTO newmod;
-- 插入/更新邮件时,用这个新序号覆盖 NEW.modseq
IF TG_TABLE_NAME = 'mw_mail' THEN
NEW.modseq := newmod;
RETURN NEW;
END IF;
-- 对 mw_mail_flag 表触发时,TG_ARGV[1] 传入关联的 mail_id
UPDATE mw_mail
SET modseq = newmod
WHERE id = TG_ARGV[1];
RETURN NULL; -- flag 表触发器不需要返回行
END;
$$ LANGUAGE plpgsql;
注意:
TG_ARGV
用来传递触发器参数,下面会演示如何传。
2. 在 mw_mail
上的 Trigger
-- 触发场景:新插入邮件(UID 分配后)或 MOVE(更新 mailbox_id)
CREATE TRIGGER trg_mail_modseq
BEFORE INSERT OR UPDATE OF mailbox_id
ON mw_mail
FOR EACH ROW
EXECUTE FUNCTION fn_assign_modseq('id', 'mailbox_id');
TG_ARGV[0]
='mailbox_id'
,函数里会用NEW.mailbox_id
去更新对应的邮箱。- 对 INSERT,
NEW.modseq
被写成最新值;对 MOVE(更新mailbox_id
),同样重新分配一个序号。
3. 在 mw_mail_flag
上的 Trigger
-- 触发场景:新增标志(STORE)或删除标志(EXPUNGE)
CREATE TRIGGER trg_flag_modseq
AFTER INSERT OR DELETE
ON mw_mail_flag
FOR EACH ROW
EXECUTE FUNCTION fn_assign_modseq('mailbox_id', 'mail_id');
- 这里
TG_ARGV[0]
='mailbox_id'
,需要你在mw_mail_flag
行里能拿到对应mw_mail.mailbox_id
。 TG_ARGV[1]
='mail_id'
,函数会用它去更新mw_mail
表的modseq
。
小结
- 所有涉及消息状态变动(新邮件、MOVE、STORE、EXPUNGE)的操作,都被触发器拦截并统一调用
fn_assign_modseq
。 - 触发器自动维护了
mw_mailbox.highest_modseq
和对应mw_mail.modseq
,应用层完全不用再写那串事务逻辑,也不怕遗漏。
这样,你就只需关心正常的 INSERT/UPDATE/DELETE 操作,增量同步的 mod‐seq 机制就能在数据库层面自动、稳健地跑起来。
客户端查询时:
- 初次
SELECT
后,服务器把当前HIGHESTMODSEQ
(即mailbox.highest_modseq
)返回给客户端; - 客户客后续增量
FETCH
请求带上CHANGEDSINCE <last-modseq>
,服务器只返回那些modseq
大于它的消息。
* OK [HIGHESTMODSEQ 2048]