
如果你维护过优惠券省钱APP一定体会过那种“优惠力度越大接口响应越慢”的崩溃感。商品库几十万SKU价格、折扣、品牌、销量各种筛选条件叠在一起用户点一下“筛选”MySQL里一条多表join加like的SQL直接跑到800ms以上高峰期数据库CPU打满运营活动一上就告警。我负责的某优惠券省钱APP就遇到过这个问题后来引入ElasticsearchES承担多维商品搜索用Canal订阅MySQL的binlog把数据近实时同步进ES最终把商品查询和筛选聚合接口的P99稳定压在40ms以内。这套方案不复杂但落地时坑不少这篇文章就从架构设计、同步链路、索引模型、性能调优到故障排查把能复用的经验一次讲清楚。1. 优惠券省钱APP查询慢的根因1.1 多维度筛选到底在筛些什么优惠券省钱APP的用户操作路径很典型打开APP搜索框输入“保湿面霜”筛选栏勾上“品牌”“价格区间50-100元”“折扣力度5折以下”“券后价可用”再按“销量优先”排序。后端要同时满足文本匹配、精确条件过滤、范围过滤和排序频率最高的接口包括关键词搜索、分类筛选、品牌筛选、价格区间筛选、折扣力度筛选、优惠券类型筛选、店铺等级筛选以及各种排序组合。数据模型上商品信息在MySQL的product表券信息在coupon表店铺在shop表分类在category表还有商品和分类的多对多关系。早期工程直接在MySQL里join加where条件一多SQL就变成一张“面条”一个查询要关联四五张表每个筛选条件都对应一个where子句排序字段还可能来自不同表。我在项目里看过慢日志一个带品牌、价格区间和折扣排序的查询平均耗时1.2sP99能到3s以上。用explain一看临时表、filesort、全表扫描全占了。从业务角度想这种慢是必然的。用户筛选条件组合是离散的MySQL索引最左前缀原则对动态组合条件无能为力关键词搜索用like %xx%索引基本失效排序字段跨表关联时需要先join再排序数据一多必然磁盘排序。更现实的问题是这个数据库还同时承担订单、库存、用户资产等核心事务写入查询压力一大锁等待和主从延迟马上恶化形成恶性循环。1.2 为什么纯MySQL优化救不了这个场景很多团队碰到慢查询第一反应是加索引。这个方向没错但在这个场景里只能部分缓解。给所有可能的筛选字段都建上复合索引可以解决部分固定组合但用户筛选项一旦超过五六个复合索引数量会爆炸而且写入成本明显上升。关键词搜索靠MySQL的全文索引也试过中文分词效果一般拼音、同义词、错别字纠错都很难做运营希望“保湿面霜”能同时召回“面霜保湿”“moist cream”这种相关结果MySQL全文本职工作中不太擅长。另一个思路是引入redis缓存热门查询但缓存只能挡重复请求无法解决首次查询和长尾筛选组合。活动期间新上架的商品、券的实时变更频繁缓存命中率会大幅下降。我见过一个方案是给MySQL加读写分离和查询从库本质上还是把压力横向扩但多维度组合查询的复杂度没有降低只是延迟被延后了。这个问题的本质是MySQL是OLTP系统适合事务和精确主键查询不适合复杂检索和统计排序。便宜省钱APP的“商品搜索”本质上是一个搜索问题应该交给搜索引擎而不是让关系型数据库硬扛。于是我们把目光转向Elasticsearch并配套解决数据实时同步的问题。1.3 为什么选择Elasticsearch与Canal的组合ES作为开源搜索引擎天生适合多字段联合查询、中文分词、聚合统计、相关性排序这些正是优惠券商品检索需要的。它用倒排索引加速文本匹配用doc_values加速排序和聚合用filter context做条件过滤查询性能在百万级数据量下轻松到毫秒级。但ES不能取代MySQL成为唯一数据源。便宜券APP的商品数据要依赖MySQL的事务能力比如价格变动、库存扣减、券的上下架这些强一致操作必须留在数据库里。如果应用层“双写”MySQL和ES一旦ES写失败或服务重启两边数据就出现偏差。我们的方案是把MySQL当作事实源通过Canal监听binlog将增量数据以事件流方式同步到ESMySQL负责读写事务ES负责高并发检索。这种架构下MySQL可以优化为“只做写和少量读”ES扛住搜索流量各司其职。之所以选Canal而不是自己写一个binlog订阅程序是因为Canal很成熟伪装成MySQL从库解析增量日志对业务代码无侵入支持故障位点恢复还能对接Kafka等消息中间件做解耦。相比直接改业务代码在写入时同步ESCanal方案不侵入主业务流程线上验证成本更低。后面会重点讲同步链路的细节这一节先把整体架构说清楚。2. 搜索层架构整体设计2.1 数据同步链路MySQL → Binlog → Canal → Kafka → ES整体链路分成两条线全量初始化线和增量实时线。全量初始化用于首次搭建或索引重建我们写了一个一次性任务分批从MySQL读取商品、券、店铺、分类数据组装成ES文档后bulk写入。增量实时线则常驻运行MySQL开启binlog并设置为ROW模式Canal模拟MySQL从库连接拉取binlog解析成增删改事件发送到Kafka的topic。同步消费者从Kafka拉取消息经过业务规则过滤和字段拼接写入ES。为什么中间要放Kafka而不是让Canal直接写ES我踩过这个坑。早期原型里Canal直接把增量事件推给ES写入程序但ES的bulk请求偶尔抖动消费程序又是单机部署一旦程序重启期间binlog积压Canal内存缓存扛不住数据就丢。引入Kafka之后Canal只管把事件写到消息队列落地到磁盘ES写入消费者可以按自己的速率消费宕机重启后从Kafka继续消费消息不会丢。Kafka还天然做了多消费者扩展的支撑同一个topic可以让对账任务、统计任务、风控任务各取所需。这个链路看起来长但实际端到端延迟能控制在秒级以内。我们线上体感是MySQL一条商品价格变更提交后大约1-2秒ES就能查到新价格。这个延迟对优惠券APP完全够用用户不是盯着价格实时跳动缓存层还能进一步平滑。2.2 商品索引的文档模型设计ES索引设计决定了查询上限设计不好后面处处受制。商品索引名字叫products_v1线上通过别名products访问这样将来重建索引切换别名不影响应用。索引设置分片数5个副本数1个refresh_interval默认调整为30秒降低写入压力。文档字段设计上有一个原则能存keyword就存keyword能用整数绝不用浮点。举几个关键字段title主搜索字段使用ik_max_word分词器同时保留一个title.keyword子字段用于精确排序和运营强匹配。categoryId、brandId、shopId这些精确筛选字段都定义为keyword用keyword比long有时更方便因为term查询和聚合都走doc_values不需要额外解析。price以“分”为单位存储为long类型不做double。浮点比较在查询和聚合时容易产生精度问题用整数反而最稳。couponAmount、discountRate同样用整数或Scaled Float线上建议直接用分存储折扣力度可以单独冗余一个discountLevel字段模拟分桶。sales使用long或integer用于销量排序。statusbyte类型1表示上架0表示下架查询条件里必须带上。updateVersionlong类型存业务侧维护的版本号用于同步幂等判断。mapping配置示例如下{ settings: { number_of_shards: 5, number_of_replicas: 1, index.refresh_interval: 30s }, mappings: { dynamic: strict, properties: { productId: { type: keyword }, title: { type: text, analyzer: ik_max_word, fields: { keyword: { type: keyword, ignore_above: 256 } } }, categoryId: { type: keyword }, brandId: { type: keyword }, shopId: { type: keyword }, price: { type: long }, couponAmount: { type: long }, discountRate: { type: long }, sales: { type: long }, status: { type: byte }, updateTime: { type: date, format: epoch_millis }, updateVersion: { type: long } } } }dynamic设为strict是为了防止脏字段被自动映射成错误类型比如某次业务方临时塞进一个priceText字段ES自动映射成text并且附带一个keyword子字段后面reindex时反而麻烦。宁可让写入报错也不要让索引模型悄悄膨胀。2.3 查询DSL设计如何把多维度条件压成毫秒级查询搜索接口的DSL以bool查询为核心。关键思路是把精确筛选条件全部放到filter上下文不要参与相关性打分把关键词匹配放到must或should上下文用minimum_should_match控制召回率。filter不走打分只走二值判断ES还会缓存filter的结果同一个过滤条件在短时间内反复查时会命中缓存性能提升明显。一个典型查询长这样{ from: 0, size: 20, query: { bool: { must: [ { match: { title: { query: 保湿面霜, minimum_should_match: 70% } } } ], filter: [ { term: { status: 1 } }, { term: { categoryId: 10012 } }, { terms: { brandId: [B001, B002] } }, { range: { price: { gte: 5000, lte: 10000 } } }, { range: { discountRate: { lte: 50 } } } ] } }, sort: [ { sales: { order: desc } } ], track_total_hits: true }fromsize只适合浅分页。线下列表往往要翻几十页from超过10000后ES会强制限制即使不限制性能也会断崖式下降。我们在线上完全改用search_after PITPoint In Time实现深分页查询时先打开一个PIT然后用游标不断翻页性能稳定。注意PIT和search_after不能和普通from混用前端翻页参数需要同步改造。商品排序是个复杂决策。用户可以选择“综合推荐”“销量优先”“价格最低”“折扣最大”等。综合推荐我用function_score给字段加权销量权重1.5佣金比例权重0.8时间衰减权重0.5品牌旗舰店再加2分。算出_score后按分递减这就是默认的“综合排序”。对价格和销量排序直接用字段排序因为doc_values已经加载速度很快。2.4 缓存兜底和降级策略ES再快也扛不住活动峰值所有请求都打过来。我们在ES前面加了一层Redis查询缓存缓存key由“请求参数归一化后的MD5”组成value存商品ID列表、总数和聚合信息。TTL设5-10秒这个时长对券类APP足够能挡住同一时间段的大量重复筛选。缓存未命中时再走ES回填缓存。降级策略也不能省。ES集群如果出现红色状态或查询超时服务层要能自动降级到MySQL的基础查询兜底虽然慢一点但至少不白屏。我们用一个动态配置开关控制降级比例正常情况下全量ES查询ES异常时自动切20%流量到MySQL兜底等ES恢复再慢慢切回。这是线上稳定性的底线不能为了追求性能把可用性丢在一边。3. Canal与ES同步链路核心实现3.1 Canal监听原理和几个关键配置Canal的工作原理是模拟MySQL主从复制协议把自己伪装成一个从库向MySQL请求binlog解析后发给客户端。因此使用Canal前必须确认线数据库已经开启binlog且binlog_format设为ROW。很多人会漏掉ROW模式下的binlog大小比STATEMENT模式大不少同步前要看下磁盘空间和网络带宽。Canal配置上需要重点管好两件事一是连接MySQL的用户权限至少要SELECT、REPLICATION SLAVE、REPLICATION CLIENT二是消息投递方式我们选择了Kafka模式。canal.properties里的关键配置如下canal.serverMode kafka canal.mq.servers 127.0.0.1:9092 canal.mq.producerGroup canal-producer canal.mq.flatMessage true canal.instance.master.address 127.0.0.1:3306 canal.instance.dbUsername canal canal.instance.dbPassword canal canal.instance.connectionCharset UTF-8 canal.instance.filter.regex shop\\..*canal.instance.filter.regex是个容易踩坑的地方。默认订阅全实例的所有库表数据量大且没用的表也会被解析。我们只关心shop库下的核心表所以正则限定为shop\..*避免无效消息把Kafka和消费端打爆。flatMessagetrue让Canal把数据转成扁平的JSON字符串消费端解析更简单如果设为false默认是protobuf格式解析麻烦但不影响功能我建议直接用true。同步位点管理也很重要。Canal自身支持基于ZooKeeper或文件保存位点Kafka模式下可以依赖Kafka的offset但Canal的位点可能和Kafka offset存在不一致。我们用了Kafka自带的重放机制同时把Canal的位点信息落一份到Redis做双保险。一旦消费程序出问题可以从Kafka重放最近的N条消息而不是全量重灌。3.2 消费端如何稳定地把binlog事件写入ES同步消费者从Kafka拿到的是一个JSON字符串里面包含table、type、data、old字段。我们需要根据type区分INSERT、UPDATE、DELETE然后组装成ES的index、update或delete请求。最容易出问题的是UPDATE操作。Canal默认会把变更前和变更后的整行都放在消息里如果消费端简单地把最新数据整个覆盖通常没问题但如果同一条记录短时间内被多次更新消息到达顺序一旦错乱就可能把旧版本覆盖新版本。解决方法是使用业务侧的时间戳或版本号做幂等判断。我给每张业务表都冗余了一个update_time字段消费端在组装ES文档时把update_time的毫秒值放入updateVersion字段。写入ES前先查一下当前文档的版本如果当前文档版本大于等于新消息版本直接丢弃否则再更新。这个判断在并发场景下要用ES脚本实现原子的“版本校验后更新”避免并发冲突。写代码时注意不要用ES内部的_version那个字段是ES控制并发版本用的业务版本号最好单独建字段。消费端另一个重点是批量写入。单条消息单次bulk性能差我们设置每攒500条或每500ms flush一次。批量大小需要压测不是越大越好太大ES内存和压力会陡增。我用的经验值是500条一批每批大小控制在几MB内双节点下写入吞吐很稳定。下面是伪代码结构public class CanalSyncConsumer { private static final BulkProcessor bulkProcessor createBulkProcessor(); public void onMessage(ListCanalMessage messages) { for (CanalMessage msg : messages) { String productId msg.get(productId); long version Long.parseLong(msg.get(updateTime)); if (msg.isDelete()) { bulkProcessor.delete(new DeleteRequest(products, productId)); continue; } // 组装文档更新版本号 ProductDoc doc convertToDoc(msg); doc.setUpdateVersion(version); bulkProcessor.add( new UpdateRequest(products, productId) .doc(mapper.convertValue(doc)) .upsert(mapper.convertValue(doc)) ); } } }注意UpdateRequest配合upsert可以在文档不存在时执行插入。全量同步跑批时和增量同步同时工作就靠这一行避免重复数据。3.3 ES写入链路调优让同步不再拖后腿同步链路如果写得不好最直观的问题是ES索引lag持续增长搜索结果和MySQL不一致。我们做过几轮调整第一索引refresh_interval改为30秒。这个参数控制数据从内存buffer变成可搜索间隔越长写入性能越好但搜索延迟也越长。30秒对导购场景足够用户不会纠结商品在10秒内是否可见。活动上线前需要更快可见时再临时改成5秒活动结束后调回。第二bulk线程池参数和队列长度。ES的bulk线程池默认线程数是CPU核数如果同步消费是单链路不容易打满但如果和查询混合bulk的队列会被大量查询挤压导致写入reject。我们限制同步消费端QPS而不是让ES无限灌数据。通过es.put.bulk.maxSize和并发数配置把写入TPS控制在集群能消化的范围内。第三副本策略。全量灌库时可以把副本数暂时设为0refresh_interval设为-1写入完成后再开启副本并强制合并性能可能快数倍。但这个操作需要停在维护窗口线上增量阶段不建议动副本。我们只在索引重建时使用这个技巧避免对查询产生影响。第四文档路由。商品查询几乎总带分类给索引设置routing categoryId查询时如果指定了分类可以只路由到包含该分类分片减少扫描分片数。代价是分类之间数据可能不均衡某个大分类数据量很大时单个分片反而成为瓶颈。我们权衡后没有启用routing因为5个分片在百万级数据量下扫描成本本身不大查询已经足够快但如果你的数据量到亿级这个策略值得重新评估。3.4 查询侧参数细节把ES性能榨出来查询侧最容易出问题的不是语句难写而是默认行为不贴合业务。比如track_total_hitsES默认在命中超过1万时会停止精确计数返回relation: gte。列表页需要显示“共10000条结果”这没问题但某些运营统计需要精确数没设置这个参数就会错。我们在列表查询里显式设置track_total_hits为true代价是稍微增加CPU开销但总数必须准确。另一个细节是filter context的查询结果缓存。ES会缓存filter子句的结果前提是这些子句确实在filter中而不是must中。很多人用must做精确条件也能查出来但失去了缓存和免打分优势。我们把所有精确条件、范围条件全部放filter关键词才放must实测相同QPS下CPU占用下降30%左右。排序字段不能随便用text类型text默认不能排序。我们为需要排序的字段建立了doc_values并且用keyword或数值类型存储。如果业务需要按“券面额大小”排序那couponAmount必须是keyword或long不能是一个带分词的text字段。还有一个小细节是ES的sort默认按字段值升序如果文档中该字段缺失会放到最后但可以设置missing参数指定缺失值排序位置。查询超时也要设。ES单次请求不能无限跑线上我们设置协调节点超时200ms如果超过就返回降级到MySQL。这个阈值要根据压测结果调不能拍脑袋。我们最初设500ms结果ES慢查询拖着连接和线程池打满后来改成200ms并配合熔断整体稳定性明显提升。4. 实操过程从全量迁移到线上60天验证4.1 全量数据清洗与索引灌库ES索引不能凭空出现最痛苦的是第一次全量迁移。我们写了一个全量同步Job从MySQL按productId分页查数据每页1000条汇总关联表和券表后组装成ProductDoc然后用bulk接口写入ES。这里有个容易忽略的点如果只查product表redis、券信息、店铺名都没有ES文档里这些字段就是空的后面再依赖增量同步去补需要补很多轮。更合理的做法是写一个“宽表SQL”把商品、分类、品牌、有效券信息一次性join成结果集再写入ES。SQL示例SELECT p.id, p.title, p.price, p.status, p.update_time, c.name AS category_name, b.name AS brand_name, s.name AS shop_name, co.coupon_amount, co.discount_rate FROM product p LEFT JOIN category c ON p.category_id c.id LEFT JOIN brand b ON p.brand_id b.id LEFT JOIN shop s ON p.shop_id s.id LEFT JOIN coupon co ON co.product_id p.id AND co.status 1 WHERE p.id :lastId ORDER BY p.id LIMIT 1000注意这里的left join可能产生一对多如果某个商品有多个有效券结果集会有多行。我们按productId做聚合优先取券面额最大的一条那种“多张券叠加”的场景暂不做复杂合并先保证主券信息准确。全量迁移过程中增量同步还在跑所以写入ES必须用upsert而不能用单纯的index覆盖。否则可能出现全量Job读到旧数据增量消费读到新数据两条消息同时到ES最后写入的是旧版本。我们的解法是前面说的updateVersion机制全量Job从MySQL取出时也带update_timeupsert时同样校验版本谁新谁留下。4.2 增量同步联调和位点问题全量数据灌完增量同步一开各类问题就冒出来了。我们遇到第一个问题是新增商品搜不到。排查发现是Canal的filter.regex正则没匹配对只配了shop\\.product但实际库名不是shop而是另一个业务库名导致商品表根本没被订阅。这类配置问题如果能在Canal启动日志里看到订阅表清单就不会拖到联调才发现。第二个问题是消息重复。Kafka在消费者宕机重启后会发生rebalance可能把最近一批消息重复消费。我们靠updateVersion幂等拦住了但刚开始没加导致ES里出现重复明细搜索count偏大。教训是不管消息中间件能不能保证exactly-once消费者一定要自己实现幂等不要把希望寄托在外部系统。第三个问题是binlog大事务引起的延迟尖峰。运营有一次批量改价一万条商品在一个事务里更新Canal连续解析大事务Kafka收到大量消息ES消费端处理了将近一分钟才追平。业务上的直观表现是改价后搜索价格在几十秒内没更新。我们的应对是给消费端增加动态批量大小消息积压多时自动把500条一批改为2000条一批用吞吐换延迟同时给Canal加监控binlog位点落后超过阈值则告警。4.3 从800ms到40ms的调优记录迁移完成后开始压测。第一轮压测结果不太理想ES查询平均120msP99到了300ms和预期有距离。通过慢日志和profile分析发现几个问题一是关键词查询使用match匹配整个title但ik分词在长标题上产生大量重分词should算分成本高。我们把关键词查询调整成match_phrase优先再配合minimum_should_match减少不相关词干扰。二是过滤条件中用了很多term查询但没有利用index_prefix或doc values优化影响不大。三是深分页用fromsize每页都很慢。改成search_after后P99从300ms掉到80ms。第二轮优化集中在缓存。Redis查询缓存拦截了相同排序和筛选条件的重复请求综合所有场景ES收到的查询量下降70%左右。优化后线上平均查询耗时23msP99 42ms。这个数据在压测环境下稳定高峰期QPS翻倍时P99略有上升但始终低于80ms。我们用Prometheus记录了ES的查询延迟直方图效果比预期好。需要注意的是42ms是“用户实际筛选接口”的数据包括网络传输和JSON序列化。如果单独看ES的查询耗时平均只有6ms。这也说明瓶颈往往不在ES本身而在应用层参数拼接、序列化和缓存策略。优化时不要只盯ES应用链路也要一起剖析。4.4 对账、灰度与数据一致性校验能搜到数据只是第一步数据对不对才是信任基础。我们上线前做了两类校验总量校验和抽样校验。总量校验每10分钟执行一次对比MySQL核心表行数和ES文档总数。正常情况下两者一致由于增量同步延迟允许存在秒级差异如果差异超过1000条就告警。注意ES文档总数通过_count接口获取不是_stats里的docs.count后者包含尚未删除的旧segment文档不是实时准确值。抽样校验每小时抽取20个商品比较ES文档关键字段和MySQL源表字段。我们用一个小程序先把MySQL主键列表取出来按主键随机抽取然后去ES和MySQL分别查同一条记录比对价格、标题、状态和券信息。若不一致则记录log并通知值班群。这套对账机制上线后帮我们抓到一个隐蔽问题某些商品的券信息更新时Canal只订阅了coupon表但product主表没有变化同步消费者确实更新了ES可某个关联字段因为JOIN中的另一个表数据异常导致文档整体被覆盖成脏数据。这个问题靠抽样校验发现后来在消费端加了关联数据有效性校验才解决。灰度上线选择20%流量切到新搜索接口数据库慢查询数立刻下降但线上监控发现一个小问题新接口部分结果的排序和旧SQL不一致。原因是MySQL的order by默认把NULL放前面ES的sort默认把缺失值放最后。细节差异导致极少数商品位置跳动。我们统一了缺失值排序规则后灰度扩大到100%。5. 常见问题排查与避坑指南5.1 一张表看懂高频故障很多问题不止一次遇到整理成速查表适合直接贴到团队wiki故障现象可能原因解决办法新商品搜不到Canal订阅正则配置错误检查filter.regex确认库表名同步延迟持续升高大事务binlog积压、bulk批次太小动态调大批量增加消费并发价格筛选结果不对double精度误差字段改long以分为单位ES和MySQL数据不一致更新乱序引入updateVersion并做幂等深翻页越来越慢fromsize超过万级改用search_after PIT搜索结果召回膨胀should过多调minimum_should_match精确条件放filterbulk请求频繁失败队列打满reject降低写入TPS增加节点索引字段类型无法改ES不支持修改已有字段重建索引用别名切换这张表后面几行特别值得展开讲。ES不支持修改已有字段类型这个问题几乎所有团队都会遇到。线上商品索引一开始price用double后来发现聚合精度有问题想改成long但ES对已存在的字段类型是禁止修改的。我们只能新建products_v2索引修改好mapping全量灌数后把别名从v1切到v2最后删除旧索引。这个动作必须在低峰期做并且要保证同步消费者两端都跑否则切换期间增量消息会丢失。5.2 排查链路从日志到监控排查这类问题不能只盯着ES控制台。我习惯按这条链路走先看应用日志里的traceId确认请求是否走了缓存、ES还是MySQL兜底再看ES慢日志和took指标判断是查询DSL慢还是集群本身有异常然后看Canal和Kafka的lag指标确认数据同步是否正常最后看系统负载和JVM GC排除节点资源瓶颈。线上我遇到过“查询变慢但ES CPU不高”的现象后来发现是应用服务器连接ES的HTTP连接池被打满。原因是查询服务并发太高但ES依赖的RestClient连接池默认最大连接数较小高并发时大量连接排队。增加连接池配置后问题马上消失。这个坑不通过全链路监控很难定位所以日志和指标面板要提前覆盖好。Canal侧也有一个经典故障MySQL主从切换后Canal位点和新的主库binlog位点对不上导致同步停止。我们部署时对Canal做了HA但主从切换时的位点提交还是可能丢失。后来在切换演练中反复模拟确定一套标准动作切换前暂停Canal消费记录当前位点切换完成后重新定位到新主库binlog再恢复消费。这类操作要写成SOP文档线上演练过至少三次才放心。5.3 几条越早做越省心的经验最后放几条我个人使用下来的建议如果重做这个项目我会一开始就按这个标准执行。第一索引别名从第一天就安排上。不要直接让应用写死products_v1而是都通过products别名访问。以后任何mapping变更、索引重建都可以无感切换应用代码不用改。第二同步消费者必须把“对账”当中国第一优先级。不要等到线上数据出BUG才去补数定时对账任务能在用户发现问题之前拦住大部分故障。对账频率不需要太高10分钟一次足够。第三ES的refresh_interval不要为了追求实时性设置太短。优惠券场景5秒实时和30秒实时用户感知差异很小但写入性能差异很大。除非运营活动明确要求秒级可见否则30秒是最佳平衡点。第四Canal和Kafka的监控一定要有独立看板。关注两个核心指标Canal位点落后耗时和Kafka消费组lag。只要这两个指标长期为0或很小ES数据就不会有大偏差。我们后来把所有核心表的变更延迟可视化值班同学不用登录服务器就能判断数据同步是否健康。第五不要忽略ES升级和客户端版本兼容。老版本ES客户端连新版服务端可能遇到查询异常这个坑我在集成新功能时踩过一次。如果团队长期用ES应该把版本升级纳入季度例行维护避免拖太久导致升级成本太高。这套优惠券省钱APP的搜索性能优化方案技术栈不复杂但每个环节都有细节。核心价值其实不是“用ES替代MySQL查询”而是通过Canal把数据一致性、通过ES把检索能力、通过Redis把热点流量各管好一层。整个落地过程中最让我觉得值得分享的不是某条DSL写得多花哨而是那些必须提前规划的数据同步、幂等等级和降级机制。把这些地基打牢后面的性能调优才好做。希望这篇文章能帮你少走一点弯路。