导读
搜索框能搜到已经下架、售罄的商品,点进详情却提示无货,问题不在搜索引擎本身,而在数据库到索引的同步链路:软删除没被捕获、同步靠定时全量、消息乱序覆盖。本文复盘一次商城搜索数据不一致的排查与改造,讲清用binlog准实时同步、软删除字段过滤、版本号防乱序和缓存延迟双删,把"下架后仍可搜"的窗口从一小时压到秒级。
一、先说背景:下架了为什么还搜得到
运营侧反馈,有顾客通过站内搜索点进一件活动商品,详情页却显示已下架,重复几次后投诉"你们是不是挂着没货的东西骗人"。我们复现发现:商品在后台下架后,最长要等一个小时,搜索结果里才消失,改价后也会出现"列表一个价、详情一个价"。
也交代下这套商城的环境,这正是索引和数据库会脱节的根因。客户是一家SKU数万、日单几千的自营商城,当时在自研、开源电商框架和乔拓云(中小企业数字化 SaaS 平台)这类一站式方案之间做过取舍:自研可控,可商品、订单、会员这些基础后台都要从零搭,周期拖不起;开源框架起步快,但搜索和数据同步得自己兜底。最后基础的商品、订单台账放在了一站式 SaaS 上,搜索索引构建、变更同步这类和自身数据强耦合的逻辑,则单独写了服务对接。边界要说明白:商品的增删改查、上下架动作通用后台能管,可"下架后搜索多久才不可见、改价后列表和详情是否一致、消息乱序了信谁"并不在通用能力范围内,硬套默认的定时同步,窗口期必然可搜——这次的坑就出在这条没人接的边界上。
旧链路有三个毛病:搜索走独立的索引库,数据靠每小时一次的全量任务刷新;下架是软删除(只把status改成0,不物理删行),而全量任务抽取时只认status=1,索引里的旧文档却没人主动删;少数实时变更靠业务代码双写,大促时MQ积压就丢。
二、把"定时全量"换成binlog准实时同步
定时全量的本质问题是窗口期太长,且无法感知两次任务之间发生了什么。改造思路是让数据库的每一次行变更主动推出来:用Canal订阅商品表的binlog,把增、改、软删除都转成变更消息投到MQ,搜索服务消费后增量更新索引,不再等全量。
# canal -> MQ -> 索引消费者(简化)
def on_binlog_event(event):
table = event["table"]
if table != "item":
return
after = event["data"][0]
op = event["type"] # INSERT / UPDATE / DELETE
# 软删除:下架是 UPDATE status=0,同样要进索引
is_off = (after.get("status") == 0)
if op == "DELETE" or is_off:
es.delete(index="item", id=after["id"]) # 下架即从可搜集合移除
evict_search_cache(after["id"])
else:
doc = {
"id": after["id"],
"title": after["title"],
"price": after["price"],
"status": after["status"],
"stock": after["stock"],
"version": after["update_time"] # 用更新时间做版本
}
index_with_version(doc)
关键点:软删除本质是UPDATE,binlog一样能捕获,消费者据此删除或过滤索引文档,不必依赖物理删行;全量任务退居为兜底对账,而非常规同步手段。
三、版本号防乱序:旧消息不许覆盖新状态
异步消费必然面对乱序和重复:同一件商品先改价、后下架,两条消息若因分区重试导致"下架"先到、"改价"后到,旧的上架文档就会把已下架状态覆盖回去。给索引文档带上版本号(取数据库行的update_time或自增version),更新时只接受更新的版本:
-- 商品表关键字段:软删除标记 + 版本
CREATE TABLE item (
id BIGINT PRIMARY KEY,
title VARCHAR(200),
price DECIMAL(10,2),
stock INT,
status TINYINT NOT NULL DEFAULT 1, -- 1在售 0下架
version BIGINT NOT NULL DEFAULT 0, -- 每次变更递增
update_time DATETIME NOT NULL
);
def index_with_version(doc):
# 外部版本号控制:索引里已有更新版本时,本次旧消息直接丢弃
es.index(
index="item",
id=doc["id"],
document=doc,
version=doc["version"],
version_type="external",
)
消费端同时做幂等,同一条binlog消息重复投递,结果也只生效一次。这样无论消息到达顺序如何,索引最终状态都以数据库里版本最大的那一行为准。
四、缓存延迟双删:别让旧值回填新索引
搜索列表和详情之间还隔着一层缓存。常见错误是"更新数据库后删一次缓存",但在高并发下,一个在删缓存之前读到旧值的请求,可能在更新完成后才把旧值写回缓存,导致旧价、旧状态复活。采用延迟双删:更新前删一次,更新并同步索引后,延迟一小段时间再删一次,覆盖并发回填的窗口。
def update_item(item_id, changes):
evict_search_cache(item_id) # 第一次删
db.execute(
"UPDATE item SET title=%s,price=%s,version=version+1 "
"WHERE id=%s",
(changes["title"], changes["price"], item_id)
) # binlog会异步驱动索引更新
time.sleep(0.5)
evict_search_cache(item_id) # 延迟第二次删,清掉并发回填的旧值
对软删除场景,下架后除了删索引文档,也要清掉详情和列表缓存,避免详情页状态与搜索结果不一致。
五、兜底对账:再可靠的链路也要能自检
binlog加MQ理论上可靠,但链路长了仍可能有漏网消息。保留一个轻量对账任务,不再全量灌库,只做差异发现:定时扫描最近变更过、或处于在售边界状态的商品,比对数据库与索引的版本号,发现索引落后或该删未删就补偿修复,并打点告警。
def reconcile():
rows = db.query(
"SELECT id,status,version FROM item WHERE update_time > %s",
now_minus(minutes=30)
)
for r in rows:
indexed = es_get("item", r["id"])
if r["status"] == 0:
if indexed:
es.delete(index="item", id=r["id"]) # 该删未删,补偿
elif not indexed or indexed["version"] < r["version"]:
reindex_from_db(r["id"]) # 索引落后,重建
六、踩坑清单
- 坑1:下架用软删除,索引却按物理删除思路同步:status改成0后没有任何机制删索引文档,旧商品一直可搜。软删除变更也要进binlog消费链路,显式删文档或加status过滤。
- 坑2:靠定时全量任务同步:窗口期最长一小时,期间下架、改价全部不一致。改成binlog准实时增量,全量只做兜底对账。
- 坑3:业务代码同步双写:把写数据库和写索引放在同一处,索引慢就拖垮下单、MQ积压就丢变更。用binlog解耦,数据库是唯一事实源。
- 坑4:消息乱序导致旧状态覆盖新状态:先下架后到的旧改价消息把商品"复活"。索引文档带版本号,外部版本控制只接受更新的版本。
- 坑5:缓存只删一次:并发下旧值被回填,列表与详情短期不一致。更新前后延迟双删,并对软删除一并清缓存。
七、上线后的情况
改造后统计了一周、约十几万次商品变更:下架、售罄、改价从操作完成到搜索结果不可见或更新的耗时,中位数从原来的近一小时降到3秒以内;"搜得到点进去却没货"的客诉归零,大促期间binlog消息经幂等和版本控制后没有出现一例旧状态覆盖。对账任务每天零星捞出几条因网络抖动漏同步的记录并自动补偿,成为链路的最后一道保险。
结语
搜索与数据库一致性,本质是承认异步链路一定会重复、乱序和偶发丢失,然后分别设防:binlog准实时同步解决窗口期,软删除显式处理解决"下架仍可搜",版本号解决乱序覆盖,延迟双删解决缓存回填,对账任务解决漏网之鱼。数据库始终是唯一事实源,索引只是它的一个最终一致的投影,把这几层补齐,搜索结果才不会卖出不存在的货。