文章发出去十分钟,站内搜索还搜不到。清缓存、换关键词、直接调接口,都没有。

这不是搜索的问题,是同步的问题。把索引当成 MySQL 之外的第二份写入,只要中间隔着队列、隔着一次异常、隔着一段没跑完的批处理,两边就会分叉,而且大部分分叉当场不会报错。真正稳的做法不是把双写写得更用力,而是从一开始就承认它会对不上,另配一条能自己跑完的对账。

一行 config 是我被逼出来的

写这个博客的发布脚本时,里面有一行不在任何文档里的东西:

config(['queue.default' => 'sync']);

不是为了少装一个 Redis。是那天文章入库成功、页面 200、正文渲染正常,站内搜索死活搜不到。翻了半小时才发现:文章写完只发出去一个同步索引的作业,作业进了本地队列,而队列那一头没有 worker 在消费。它就那么排着,排到天荒地老。

这之后我加了两件事:发布脚本里强制 sync,以及验证清单里多一条「查索引表有没有这条记录」。第一条让这一次能成,第二条才是真正救命的,因为它不假设任何链路是通的,只看结果。

双写的断法比你想的多

真实世界里断的方式不止一种,随手能列出十条:

  • 事务还没提交,消息先发出去了。消费者抢跑,按 id 去库里查,查不到,判定「已删除」,顺手把索引删了。
  • 事务回滚,消息收不回来。索引里多一条不存在的数据。
  • 队列重投,同一作业跑两遍。写操作幂等还好,遇上计数、累加类字段直接翻倍。
  • 消费者自己报错,重试到上限,消息进死信队列,没人看。
  • 灰度期间新旧两个消费者同时在跑,同一个文档的写入顺序错乱。
  • 有人从后台改了一条数据,走的是另一个更新入口,那个入口忘了发消息。
  • 批量导入或数据修复脚本绕过模型,直接 UPDATE。
  • 索引服务临时不可用,重试几次后作业被丢弃。
  • 双写成功了一半:MySQL 成功、索引超时,但 HTTP 层已经把 200 返回给用户了。

十条里有七条不报错。

这才是要命的地方。它不响,不告警,不进错误日志,你只在用户说「搜不到」的那天才知道它坏过,而且不知道坏了多久。

那我在事务里同步写索引呢

不行,而且比队列更糟。

一是事务里调外部服务,一次网络抖动就把整个业务事务拖到超时。索引服务的可用性直接变成发文章接口的可用性,这个耦合不该存在。

二是窗口变窄了,但没有消失。你在事务提交后同步写索引,中间还有个极窄的间隙:提交成功、写索引之前,进程被 OOM 掉了。窄不等于不存在,而这类间隙恰恰只在大促、重启、部署的时候出现。

三是延迟摆到了用户面前。走队列至少接口是快的,用户感知不到索引那 200ms。

见过不少项目把「一致性」直接理解成「同步」,最后换来的是接口 P99 翻倍、一堆重试逻辑,数据该丢还是丢。

binlog 是不是银弹

binlog 订阅(Canal、Debezium 那一套)真正解决的是「漏写」,不是「实时」。只要变更落到 MySQL,就一定会产生 binlog,绕过模型的 UPDATE 也能抓到。这一点比它的实时性值钱得多,因为上面那十条断法里,最难查的就是「有人走了另一个入口」。

但它不免费:

多一个中间件要运维,多一套账号权限要开,多一条会断的链路。binlog 的保留期有上限,消费位点落后超过保留期,那段变更就永久丢了,只能靠全量重刷兜底。而且它保证的是「变更不丢」,不是「索引正确」——你的映射逻辑改过一版,历史文档还是旧结构,照样要重刷。

所以我的判断:日活几万以下的站点,别上 CDC。为一个搜索框引入一套运维成本比你主业务还高的东西,不划算。等你手里已经有三四条类似的数据管道、运维这套是顺手的事,再上也不迟。

四种做法,各自的代价

方案 一致性 运维成本 什么时候选它
事务内同步双写 理论最强,实际最脆 低 几乎不要选
业务侧异步队列 最终一致,断法多 低 中小站点默认
binlog 订阅 不漏变更,可能滞后 高 数据管道成熟、有值班
定时对账补偿 只保证收敛 中 前面三种都必须配

注意最后一行。对账不是二选一的方案,是前面三种的必备兜底。把对账当成可选项的项目,最后都在人工修数据。

对账得真的能跑完

对账不是「定时跑个全量」。一张百万行的表在业务高峰做全量比对,会把你数据库打歪,所以大部分人的全量对账脚本写完就没跑过第二次。

能长期跑的做法是分三层:

  1. 近实时窗口。每小时捞最近 2 小时 update_time 变化的记录做比对,量小,随时能跑。
  2. 每日抽样。随机抽 1% 的 id,比对存在性和关键字段,只报差异、不自动修,人看一眼。
  3. 每周全量。低峰期分批,每批五千行,批间 sleep,一条一条补。

关键不在跑,在于差异要有地方落。一张 sync_diff 表,记录 id、差异类型、发现时间、修复状态。没有这张表,你的对账脚本跑完就忘,等于没跑。

// 每小时窗口对账,简化版
$since = time() - 7200;
$rows = $db->select(
    'SELECT id, title, update_time FROM articles WHERE update_time >= ?',
    [$since]
);

foreach ($rows as $row) {
    $doc = $es->get('articles', (string) $row['id']);

    if ($doc === null) {
        $queue->push(new SyncArticleDoc($row['id']));
        $diff->insert($row['id'], 'missing');   // 索引里没有
        continue;
    }

    if ($doc['title'] !== $row['title']) {
        $queue->push(new SyncArticleDoc($row['id']));
        $diff->insert($row['id'], 'stale');     // 索引比库里旧
    }
}

这段代码有它自己的坑:$es->get 一条一条串行查,两小时窗口里如果有几万条变更,跑不完。要么批量 mget,要么把窗口调小。我一开始就是串行查的,第一次跑就超了半个钟头没结束,后来改成五十条一批 mget 才勉强能看。

什么情况下双写就够了

给你一个我自己在用的判定:索引丢了,业务会不会错?

博客的站内搜索挂了,用户搜不到一篇,他换个词或者直接翻列表页,业务不错。后台运营查不到某个商品,他会骂人,但不会记错账。这两种,双写加每小时对账完全够用,别再往上加东西。

但如果索引是订单查询的唯一入口、是风控命中的依据、是计费的基础,那它就不是「搜索」,是数据副本。数据副本得按数据副本的标准做:变更捕获、位点管理、可重放、可全量重建。到那时候讨论的重点已经不是双写要不要加队列,而是这套东西有没有人半夜能起来看。

最后

我现在的动作很土:文章发完不等页面刷新,先查一遍索引表。没记录就手动补一次作业,补不上就说明链路断了,当天修。这个习惯做了几个月,它没让任何一次故障消失,但它让故障每次都发生在用户之前。

双写这件事,最贵的不是写错。是写错了没人知道。

艾林博客 - 技术分享、开发经验与AI探索的个人技术博客
艾林博客 - 技术分享、开发经验与AI探索的个人技术博客

延伸阅读:

分布式锁别上 Redlock:把单实例 Redis 的锁写对,就够 99% 的系统用了 架构设计
分布式锁别上 Redlock:把单实例 Redis 的锁写对,就够 99% 的系统用了

Redis 做分布式锁就单实例加看门狗,要正确性就上 etcd,Redlock 卡在中间两头不靠。本文讲清三种方案的真实边界:一把合格的 Redis 锁怎么写(唯一 value、Lua 原子释放、过期时间)、Redlock 为什么解决不了 GC 停顿与 fencing token、以及什么时候该换 etcd 或数据库唯一约束兜底。

后端 优化 安全

Valencio

/

2026-10-21

PHP 8.6 的性能头号卖点,在八月被撤回了 开发编程
PHP 8.6 的性能头号卖点,在八月被撤回了

PHP 8.6 的闭包优化 RFC 以 24:0 通过,但 static 自动推断在 8 月被撤回,那个 3% 只对显式写了 static 的闭包生效;同时 35 项弃用投票有 4 项被否,list() 以 23:23 保住。我把自己的 626 个 PHP 文件扫了一遍,自有代码零命中,升级计划随之改掉。

PHP 后端

Valencio

/

2026-10-16

内存条为什么涨了两倍多:AI 抢的不是内存,是晶圆 系统运维
内存条为什么涨了两倍多:AI 抢的不是内存,是晶圆

两根内存条从 700 多涨到 2000 多,根子不在缺货,在晶圆面积:HBM 每产出 1 bit 要占标准 DDR 三倍的硅片,2026 年吃掉 22% 的 DRAM 产能只交回 9% 的容量。再叠加 60%~70% 的服务器级产能被长协锁死、新产能要 2028 年才算数,这轮周期不会像 2019 年那样崩回去。文章同时给出云账单与内存预留的排查清单。

优化 AI

Valencio

/

2026-10-13

霸榜全球的 Space Bunny 无人认领:匿名发布早就是一条流水线 案例分析
霸榜全球的 Space Bunny 无人认领:匿名发布早就是一条流水线

一个查无此人的模型霸了全球调用量榜。这不是孤例——2026 年 3 月以来 OpenRouter 的 stealth 通道挂过 7 个匿名模型,6 个有主,全部指向中国厂商。这篇把匿名发布这条流水线拆开:运作机制、厂商动机、指纹破案方法论,以及一只无国籍兔子对行业统计的影响。

AI

Valencio

/

2026-10-10

GPT-6.1 Sol 发布:五分之一价格拿到 Astra 能力,同一周更强的 Astra 却被取消了 AI与大模型
GPT-6.1 Sol 发布:五分之一价格拿到 Astra 能力,同一周更强的 Astra 却被取消了

GPT-6.1 Sol 用约五分之一的价格拿到接近 Astra 的成绩,但同一周更强的 GPT-6.1 Astra 被取消发布,理由是欺骗性汇报与越权执行。本文拆解三档版本关系、缓存降价与推理档位收紧的真实影响、取消事件的时间线,以及生产环境 Agent 防范假完成报告与越权执行的四道工程防线。

AI 后端 安全

Valencio

/

2026-10-08

GPT-6 Sol 和 Luna 发布:价格砍到 Astra 的 1%,但按价签选模型会踩坑 行业快讯
GPT-6 Sol 和 Luna 发布:价格砍到 Astra 的 1%,但按价签选模型会踩坑

9 月 22 日 OpenAI 补上 GPT-6 的 Sol 和 Luna 两档,价格降到 Astra 的 1/5 和 1/100。但重点不是便宜:三档能力差距按工作类型分叉——编码任务上 Luna 只落后 Sol 2.2 分,智能体任务上却掉 12.5 分。本文附三档规格对比、计费隐藏条款、GPT-6 Sol 与前代的代际对比,以及一段可直接抄走的 PHP 模型路由实现。

AI 后端

Valencio

/

2026-09-23