
周末大促那几天优惠券APP的用户突然涌入首页搜一下“某品牌券”服务端平均延迟直接飙到2.3秒第5页之后翻页基本超时。这大概是所有做优惠券聚合类APP都会撞上的墙不是单个查询慢而是多维度组合筛选下的全文检索慢。我当时接手这个性能优化第一步就想给MySQL加索引、上缓存搞了一圈发现只是治标。真正把方案落到Elasticsearch以下简称ES和Canal的搭配上搭了一条MySQL→binlog→Canal→Kafka→ES的增量同步管道再对查询层做了filter缓存、聚合预计算和深分页改造单次搜索稳定压在20ms上下。这篇文章就把这条链路从头讲一遍适合正在做商品/优惠券/本地生活检索的同学参考也适合刚接触ES和同步组件的后端工程师抄作业。1. 优惠券搜索场景到底难在哪里不只是“查得快”这么简单1.1 多条件叠加一次搜索背后藏着多少约束先还原一下优惠券APP里的真实查询形态。用户搜“火锅”的时候系统要同时满足类目是“生活服务-美食-火锅”价格区间在20到200之间折扣力度必须是满100减50或5折券商家评分不低于4.5还需要按“最多人领券”排序。这还没算地理位置用户可能在杭州想把附近3公里内的可用券也筛出来。把这些条件拆开后你会发现任何单一条件都不难难在组合。MySQL应对这种查询时最常用的是建联合索引。(类目, 价格, 折扣力度)这组索引可能可以覆盖部分场景但只要条件里加上评分、距离或者领券人数排序索引就开始失效优化器要么走某个条件后回表要么干脆全表扫。加上优惠券商品表的行数往往上千万甚至上亿聚合后的结果集又大分页深度超过100页时索引的作用基本为零。合作方那边偶尔还要求模糊搜索比如输“海底捞”结果匹配“海底捞火锅”输“公斤”要能搜到“kg”。这种同义词、拼音、错别字纠错的需求MySQL的LIKE %关键字% 根本接不住。这不是加个索引能解决的问题而是索引模型和查询模型都不是为这个场景设计的。1.2 MySQL的B树索引与ES倒排索引的先天差异要想理解为什么最后选了ES而不是继续堆MySQL得先把两种数据结构的差异讲透。MySQL的B树索引就像一本书的目录告诉你某个词在哪些页码但如果你要查找“第3章第2节里包含‘图例’且页数少于20页”的内容目录只能分头查到相关页码范围然后你去取交集这个交集可能大得吓人因为目录只有页级粒度没有段落级粒度。优惠券查询里的“类目、价格、折扣、评分”就相当于不同的页码条件组合起来后回表读取所有候选商品行再做内存过滤和排序自然慢。ES的倒排索引则是一张词条到文档ID列表的映射表。比如“火锅”这个词由分词器提前拆出来它对应一份包含“火锅”的所有商品ID列表。多个查询条件就是多个倒排列表ES用高效的集合运算把结果算出来全程都是位图、跳表这类批量操作不需要逐行扫描。更关键的是ES天然支持相关性打分、短语匹配、同义词扩展、拼音前缀等能力这些都是MySQL需要写大量代码才能模拟出来的。我并不是说ES能完全替代MySQL它的角色是搜索加速层MySQL继续扮演事实数据源和事务系统。ES只存搜索需要的字段不存大字段、不存复杂关系单条文档干脆利落这才能保证毫秒级返回。2. 数据从MySQL流向ElasticsearchCanal链路全解析2.1 binlog监听原理与Canal的定位数据同步方案很多为什么选Canal这类binlog监听方案而不是双写或者定时任务双写的问题在于业务代码侵入性太强每次商品变更都要同时改MySQL和ES任何一处漏写就会造成数据不一致。定时任务的问题更明显要么延迟高要么效率低全量扫描一次几千万行对数据库压力很大在夜里跑还会拖累其他业务。Canal做的事情本质上就是伪装成MySQL的一个slave节点申请binlog日志然后按MySQL主从复制协议把更新流接过来。MySQL只要能配置主从复制Canal就能工作业务侧完全无感。这一步最关键的是binlog格式必须是ROW模式只有ROW模式才记录每行变更前后的完整数据Canal才能还原出“哪个ID更新成什么值”这种结构化事件。为了消费稳定典型拓扑是Canal把解析出来的变更事件投递到Kafka再用独立的消费服务批量写入ES。这样的好处是Kafka做缓冲即使ES短暂抖动消费端也不会把消息丢掉同时消费速率可以自己控制写入ES时能攒一批走bulk批量接口比单条写高效得多。2.2 实际部署配置从binlog开启到消费端写入先确保MySQL服务端开启了log_bin同时设置server_id为一个不会与现有主从机器冲突的唯一值不能改成和库内其他实例一样的数字。开启后需要专门给Canal建一个账号权限建议控制在SELECT、REPLICATION SLAVE、REPLICATION CLIENT这三项别直接给全局所有权限生产环境安全还是要守住的。创建账号的SQL大致是这样CREATE USER canal% IDENTIFIED BY your_password; GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO canal%; FLUSH PRIVILEGES;接下来在Canal的instance.properties里配置数据源和监听规则canal.instance.master.address127.0.0.1:3306 canal.instance.dbUsernamecanal canal.instance.dbPasswordyour_password canal.instance.connectionCharsetUTF-8 canal.instance.filter.regexproduct_db\\.coupon_productfilter.regex这里我建议精确匹配到业务表而不是监听整个库否则所有表的变化都会涌进来白白消耗解析与传输资源。需要监听多张表时用逗号分开比如product_db\\.coupon_product,product_db\\.merchant。Canal拿到变更后如果选择推给Kafka在canal.properties里配置adapter或者直接使用客户端模式都可以。更常见的是用Canal的Tcp模式开发一个简单的客户端这个客户端收到的是序列化的消息对象里面包含tableName、eventTypeINSERT/UPDATE/DELETE、以及before和after的数据Map。消费端的核心逻辑很直接大致是解析事件类型如果是INSERT或者UPDATE就构造ES的upsert请求用主键ID作为文档ID如果是DELETE就调用ES的delete接口。每次攒够一定数量或到时间阈值就执行一次bulk提交我这里用的是每批2000条或每500ms刷一次线上跑下来写入速率很稳定。2.3 数据一致性版本号与幂等更新同步链路最容易出问题的不是千篇一律的插入而是更新顺序。比如一个商品在很短时间内被改了三次三条变更事件几乎同时到达Kafka消费端并发去写同一份ES文档如果先写后写的顺序乱了ES里最终存的可能是旧数据。解决这个问题的关键是每一张业务表都要有单调递增的update_time或者版本号。ES文档里把这个字段存成long类型消费端在写之前做一次“版本校验”如果ES里已有文档的版本号大于等于当前变更事件的版本号就跳过这次更新否则才用当前数据覆盖。具体实现可以在ES文档里加入_meta.version字段{ id: 10001, name: 海底捞100元券, category_id: 120, price: 88.00, update_time: 1678543200 }在upsert脚本里如果当前ctx._source.update_time params.update_time就执行更新否则返回noop这样即使消费并发也不会出现旧写新覆盖的情况。3. 商品索引建模把“毫秒级查询”做出来的核心3.1 Mapping设计一个追求检索速度的多字段组合索引模型定得好不好直接决定了查询是几个毫秒还是几百毫秒。优惠券商品搜索需要支撑文本搜索、精确筛选、范围筛选、地理位置筛选和排序我的做法是采用经典的“宽表设计”把每个检索维度都独立成一个字段而不是塞在一个大JSON里。核心Mapping长这样{ mappings: { properties: { id: { type: keyword }, name: { type: text, analyzer: ik_max_word, fields: { keyword: { type: keyword } } }, brand_name: { type: keyword }, category_id: { type: keyword }, price: { type: scaled_float, scaling_factor: 100 }, discount_rate: { type: keyword }, merchant_score_level: { type: keyword }, coupon_type: { type: keyword }, sales_total: { type: long }, location: { type: geo_point }, update_time: { type: long } } } }name字段采用中文分词器这里用ik_max_word是想尽可能多切出词配合match查询召回全而name.keyword字段用于精确匹配或排序比如“按名称排序”的罕见需求。可能有同学会问为什么不把类目设为long或者把价格设为double在ES里数值类型的倒排索引和范围查询性能其实不差但keyword类型在filter上下文中走的是位图配合缓存效果更好。尤其像discount_rate这种可枚举的离散值用keyword天然适合做聚合和过滤。3.2 空间换时间预计算维度与字段降级刚开始做的时候我一度想把所有条件都在查询时实时计算比如“折扣力度大于7折”用range查询“商家评分大于4分”用range查询。后来压测发现这种自由range虽然灵活但会让filter缓存复用率很低因为用户的评分阈值可能变化很大很难命中缓存。于是我们改用预计算字段上线前在数据加工阶段把商品按价格切成price_bucket比如low(0-50)、mid(50-200)、high(200-1000)把商家评分切成score_high、score_mid等几个档位。查询时直接对price_bucket做term过滤而不是对price做range过滤。这样改动之后效果非常明显因为term过滤的位图计算几乎可以忽略不计。诚然多一个字段就多一份存储但换来的是更稳定的查询时延。结合优惠券场景用户对价格区间本来也是大致看的真正需要精确价格范围的场景非常少损失一点精确性换取性能性价比很高。地理位置字段我用了geo_point类型配合geo_distance查询过滤周围3公里。由于地理距离计算相对较重我们在ES里也预存了city_id字段先按城市粗筛再用真正的距离精筛这样大部分请求在城市维度就打住了距离计算只发生在同一个城市内的少量候选集上。4. 查询优化三板斧真正把检索速度压下去4.1 用filter替代query让缓存和位集做主力很多刚接触ES的同学写查询时喜欢把所有条件都用query上下文包起来例如{ query: { bool: { must: [ { match: { name: 火锅 } }, { range: { price: { gte: 20, lte: 200 } } } ] } } }这里面其实有一个大坑range在query上下文会被当成词项参与相关性打分还要单独算一个norm效率并不理想。更麻烦的是ES的query cache和filter cache是两套缓存query上下文里的条件普遍很少被缓存复用。正确做法是把结构化条件全部放进filter上下文{ query: { bool: { must: [ { match: { name: 火锅 } } ], filter: [ { term: { category_id: 120 } }, { term: { price_bucket: mid } }, { term: { merchant_score_level: high } }, { term: { city_id: hangzhou } }, { geo_distance: { distance: 3km, location: { lat: 30.27, lon: 120.15 } } } ] } } }filter上下文内的每个子查询都会被ES缓存成一个独立的位集下次遇到完全相同的条件直接用位集取交集不再重复计算。在我们的场景里city_id和coupon_type是复用率最高的条件第二个请求开始基本就是内存位运算P99能再压好几毫秒。4.2 商品维度聚合用terms和composite替代实时扫描除了查询结果APP首页往往还要展示每个类目下的券数量、满减档位的分布。直接在ES里对全量商品做一次agg返回结果当然没问题但每次请求都触发一次聚合扫描开销不小。我们的做法是分两级第一级在查询主索引用terms聚合拿几个常用的key比如类目、折扣档位因为ES的terms聚合可以直接读取正排doc_values速度已经很快只要别在高基数字段上做深聚合就行比如“按商品ID聚合”这种千万级不同值的必须坚决避免。第二级对于首页全局频道的那种大通栏我们会用一个单独的小索引或者Redis缓存提前算好“每个城市每天发券数量Top10”每10分钟刷新一次。这样首页频道打开时连ES都不用查直接走Redis几乎零耗时。结论是聚合能避开就避开不能避开的尽量用低梯度聚合再往上层用缓存挡住高流量。4.3 深分页改造search_after才是真实选项很快你就会发现ES的fromsize一旦超过1万会直接报错即使不报错翻页越深代价也越大因为每次都要计算全局排序后丢掉前from个结果。优惠券APP的场景里用户基本上不会翻超过10页但我们依然要防住尾部流量和爬虫。我的改造方案是第一页仍用普通的fromsize可以接受但从第二页开始前端传回上一页最后一条商品的排序字段值集合后端使用search_after来续接。例如上一页最后一条商品按销量排序的值是4650查询下一页就带上{ query: { ... }, sort: [ { sales_total: desc }, { _id: desc } ], search_after: [4650, 10001], size: 20 }注意search_after必须依赖稳定的排序主键我习惯在sales_total后面追加_id作为二级排序防止两个商品销量相同导致翻页错乱。另外还有一个隐藏的好处search_after查询不会像深分页那样在协调节点消耗大量内存它天然只需要维护一个小窗口。5. 性能压测与实战调优从200ms到20ms的路径5.1 压测场景设计与核心指标优化有没有效不能只看平均延迟必须用P90、P95、P99和TPS来验收。我们压测时模拟了200个并发用户查询条件分布刻意做得比较真实50%的请求是“关键词类目价格区间”的组合30%是纯关键词搜索剩下的20%是只按类目或城市筛选。这样能和线上用户行为基本对上不然只跑一个最简单查询压出来的数据没有任何参考价值。最初阶段的结果非常有代表性MySQL老接口的P95是1400msTPS只有400左右换成ES之后第一版还没做三维打法的P95是80msTPS到了2000做完filter上下文和预计算字段后P95直接掉到25msTPS稳定在5000以上。这个对比不是为了证明MySQL差而是想说明架构层优化比SQL层优化带来的数量级提升更显著。5.2 慢查询拆解profile API告诉我们瓶颈都在哪压测过程中出现过几次波动明明只有20ms左右的查询偶尔会突然飙到200ms。这时候我打开了ES的profileAPI一条慢请求的详细耗时段立刻暴露出来。有段时间慢主要卡在build_scorer阶段原因是某个筛选字段是高基数的keyword比如merchant_id直接拿来过滤虽然能过滤掉大部分数据但构建位集的成本很高。后来改成对merchant_id做“范围归一化”预计算生产一个merchant_size_level字段把商家按商品数量分成大中小几档过滤这个字段后就快非常多。还有一次是中文分词器在单次查询中被触发了太多词项。原因是我用了match_phrase来搜索商品名用户输入“麻辣小龙虾”时倒排表合并的词项非常多导致fetch阶段从磁盘取原始文档的时间变高。最后我改成同时保留match召回宽结果和match_phrase做精排——先用match拿到候选集再对候选集按短语匹配重算分数而不是把所有词项放在一个查询里硬扛。5.3 集群参数与写入优化Refresh、堆内存与批量很多优化是发生在查询之前写入侧的参数直接决定查询时的数据布局。ES默认refresh_interval是1s这个对近实时搜索足够但大批量同步时每1秒就会生成一个分段分段多了之后查询要在多个分段上取结果反而变慢。我把商品索引的refresh_interval在增量峰值时段调成30s配合bulk批量写入分段数量大大减少。用户对新增券时效性的容忍度一般在分钟级30s这个值完全够用。JVM堆内存我给了32GB单机物理内存64GB这个比例正好能把es的filter cache、segment cache的都装进内存。如果给小了频繁GC对耗时的冲击非常明显如果给太大又会让OS层的文件缓存被严重挤压反而得不偿失。另外indices.queries.cache.size我设置为堆的10%并观察filter cache命中率命中率低于50%时优先检查是不是条件写法不统一而不是简单加大缓存。6. 同步链路里那些防不胜防的坑6.1 binlog格式设置与主从冲突有一次我们把一个新商品表接入监听结果Canal一直报Table xxx doesnt exist排查了半天才发现MySQL里binlog_format仍然是STATEMENT而不是ROW。STATEMENT格式只记录SQL语句Canal没法解析出变更行数据自然什么都拿不到。切换binlog格式不能随意执行SET GLOBAL必须修改配置文件并重启MySQL否则只对之后的新会话生效存量数据同步依然拿不到。还有一个小坑是Canal的slaveId片不能和MySQL现有从库的server_id重复重复会导致MySQL报错并终止复制链路这是最容易被忽略的也是新加实例最常踩的坑。6.2 延迟放大与消费压力告警同步链路里的延迟问题分为两种Canal到Kafka的延迟Kafka到ES消费端的延迟。前者如果长期在高位一般说明Canal解析速度跟不上可以适当调大Canal的批大小后者如果积压严重先别急着加消费者线程看看ES的bulk写入有没有报rejected_execution。我们的告警策略是在每条事件里带上MySQL的update_time消费端记录当前时间并算出差值如果落后超过2分钟触发P2告警。超过10分钟就触发P1因为这已经说明增量链路断了用户看到的搜索结果是旧数据。刚开始上线时这种告警救了我好几次有一次Kafka的某个分区因为磁盘满被限流延迟悄悄拉高到5分钟搜索引擎还在照常响应而这个告警帮我兜住了底。6.3 全量增量的双通道恢复方案不管Canal链路设计得多严谨总有全量重建索引的时候比如突然发现一段时间内线上数据有遗漏或者索引Mapping需要大改。我们习惯的做法是保留一条全量同步通道平时不用但随时可以拉起。具体流程是先从MySQL导出一份全量数据快照用离线任务写入到ES的临时索引写完临时索引后再把当天开始累积的增量事件按版本号合并过去最后通过别名切换把线上流量从旧索引切到新索引。切换别名的操作可以在ES里用原子接口完成{ actions: [ { remove: { index: old_index, alias: product_search } }, { add: { index: new_index, alias: product_search } } ] }这套双通道方案的好处是重建期间旧索引继续服务不会出现搜索中断。整个切换过程有5秒左右的流量缓冲配合消费端继续消费增量事件保证切换时间点前后的数据一致。我建议任何做CanalES同步的团队都要提前演练一次全量切换这比出故障时再临场设计靠谱得多。最后再提醒一句性能优化没有银弹ES和Canal只是我所在场景下的解法。如果你的数据量、查询模型或者更新频率和优惠券APP不一样完全可以参考这里的思路换用其他框架但几件事是通用的——先搞清楚查询约束再做索引和查询适配然后让数据同步链路做到可观测、可恢复。只有这样所谓毫秒级响应才不是一个演示Demo而是真能扛住大促流量的生产事实。