1. 从秒杀场景切入:为什么搜索和爬虫是架构师的必修课
秒杀系统、搜索引擎、爬虫,这三个词放在一起,很多人的第一反应是“这不是三个独立的方向吗”。但如果你真正在电商或者内容平台做过架构,就会发现它们之间有一条非常清晰的逻辑链:秒杀解决的是高并发写入和库存扣减的问题,爬虫解决的是数据从外部获取的问题,搜索引擎解决的是海量数据快速检索的问题。这三者组合起来,恰好覆盖了一个内容电商类系统从数据采集、数据存储到数据检索的完整闭环。
我当初听这一课的时候,最大的感受就是:架构师训练营到了第18课,终于把之前分散的知识点串成了一条线。前面讲过的缓存、消息队列、分库分表,在秒杀场景里全部要用上;而搜索引擎这一块,Lucene和Elasticsearch的底层原理,又和之前讲过的倒排索引、分词器设计紧密相关。如果你只是孤立地学每一个技术点,到了实际项目里还是会不知道怎么选型、怎么组合。
这篇文章适合谁看?如果你已经写过一些业务代码,对Spring Boot、MySQL、Redis有基本的使用经验,但一遇到“秒杀怎么做”“搜索怎么优化”“爬虫怎么设计”这类问题就心里没底,那这篇总结就是写给你的。我会把课程里的核心思路拆开,补上我自己的实操经验和踩过的坑,尽量让你看完之后能直接在自己的项目里复现。
注意:本文涉及的秒杀、爬虫、搜索引擎内容均基于公开技术原理和通用实践,不涉及任何特定平台或敏感场景。
2. 秒杀系统的架构设计:从超卖问题到异步削峰
2.1 秒杀的核心矛盾到底是什么
秒杀的本质矛盾只有一个:瞬时高并发请求与有限库存之间的冲突。假设一个商品只有100件库存,但瞬间涌入了10万个请求,如果每个请求都去数据库查库存、扣库存,数据库连接池瞬间被打满,系统直接崩溃。更严重的是,如果扣库存的逻辑没有做好并发控制,还会出现超卖——明明只有100件,最后卖出去了120件。
课程里讲了一个很经典的例子:早期很多秒杀系统直接用UPDATE stock SET count = count - 1 WHERE id = ? AND count > 0,这条SQL在单机MySQL下确实能保证不超卖,因为InnoDB的行锁会串行化更新。但问题在于,10万个请求同时打过来,数据库根本扛不住这个QPS。所以架构设计的核心思路不是“怎么扣库存”,而是“怎么把大部分请求挡在数据库之前”。
2.2 分层过滤:把99%的请求拦截在 upstream
课程里给出的方案是典型的分层过滤架构,我把它整理成了一张表,方便你对照自己的系统:
| 层级 | 拦截手段 | 拦截比例(经验值) | 核心逻辑 |
|---|---|---|---|
| 客户端 | 按钮置灰、验证码、答题 | 拦截30%-50% | 减少无效点击,防止脚本刷单 |
| CDN/网关 | 限流、黑名单 | 拦截20%-30% | 按IP或用户维度限流 |
| 应用层 | Redis预减库存 | 拦截90%以上 | 内存操作,原子递减 |
| 消息队列 | 异步下单 | 削峰填谷 | 把同步请求转为异步处理 |
| 数据库 | 最终扣减 | 兜底 | 保证数据一致性 |
这个表里的拦截比例是我根据实际项目经验估算的,不同业务场景会有差异。但核心思想很明确:越往前拦截,成本越低,效果越好。客户端置灰一个按钮,成本几乎为零;但让请求打到数据库再拒绝,成本就高得多了。
2.3 Redis预减库存的实操细节
课程里重点讲了用Redis做预减库存的方案,我把它拆成具体的步骤:
- 系统启动时,把秒杀商品的库存加载到Redis中,用
String类型存储,key为seckill:stock:{商品ID}。 - 用户请求到达时,先执行
DECR命令。如果返回值大于等于0,说明抢到了名额,进入下一步;如果返回值小于0,说明库存已空,直接返回失败。 - 抢到名额的请求,把订单信息写入消息队列(比如RocketMQ或Kafka),然后立即返回“排队中”。
- 消费者从队列里取出消息,执行真正的数据库扣减和订单创建。
这里有一个非常关键的细节:DECR命令在Redis中是原子操作,所以不需要额外加锁。但如果你用的是GET再SET,那就一定会有并发问题。我见过有同学用GET判断库存大于0,然后再DECR,这中间有一个时间窗口,高并发下必然超卖。
提示:Redis预减库存后,如果后续数据库扣减失败(比如库存真的不够了),需要把Redis中的库存加回去,否则会出现“Redis有库存但数据库没库存”的不一致情况。这个补偿逻辑一定要做。
2.4 消息队列削峰的参数选择
课程里提到了消息队列的削峰作用,但没有展开讲参数怎么设。我补充一下我的经验:
- 队列容量:不要设得太大。如果队列积压了几十万条消息,消费者处理不过来,用户端会一直显示“排队中”,体验很差。一般建议队列容量控制在秒杀库存的2-3倍。
- 消费者并发数:根据数据库的写入能力来定。假设数据库每秒能处理500次写入,那消费者并发数就控制在500左右,不要超过数据库的承受上限。
- 消息过期时间:秒杀场景下,消息过期时间不宜过长,一般30秒到1分钟。如果用户等了1分钟还没结果,基本上已经失去耐心了。
我实测下来,一个4核8G的消费者实例,处理简单的订单创建逻辑,QPS大概在800-1200之间。你可以根据这个基准来估算需要的实例数量。
3. 爬虫系统设计:从数据采集到反爬对抗
3.1 爬虫的本质是一个分布式任务调度系统
很多人觉得爬虫就是写个HttpClient发请求、解析HTML。但真正做过大规模爬虫的人都知道,爬虫的核心难点不在“怎么发请求”,而在“怎么管理海量的URL、怎么调度任务、怎么去重、怎么应对反爬”。
课程里把爬虫分成了几个核心模块:URL管理、页面下载、页面解析、数据存储。我把它扩展成一个更完整的架构:
- URL管理器:维护待抓取队列和已抓取集合。待抓取队列可以用Redis的
List或Sorted Set,已抓取集合用BloomFilter去重。 - 调度器:从待抓取队列中取出URL,分配给下载器。调度策略可以是广度优先、深度优先,或者按优先级排序。
- 下载器:负责发送HTTP请求,获取页面内容。需要处理超时、重试、代理切换等。
- 解析器:从页面中提取新的URL和结构化数据。可以用Jsoup、HtmlUnit,或者正则表达式。
- 存储器:把解析出来的数据写入数据库或文件系统。
这个架构看起来简单,但每一个模块都有很多细节可以优化。比如URL去重,如果用Set存储所有已抓取的URL,内存消耗会非常大。假设你抓了1亿个页面,每个URL平均50字节,那就是5GB内存。用BloomFilter可以把内存降到几百MB,代价是有一定的误判率。
3.2 BloomFilter去重的原理和参数计算
BloomFilter的核心思想是:用多个哈希函数把一个元素映射到一个位数组中的多个位置,如果所有位置都是1,则认为元素可能存在;如果有任何一个位置是0,则元素一定不存在。
课程里没有展开讲参数计算,我补充一下。假设你要存储n个URL,允许的误判率为p,那么位数组的大小m和哈希函数个数k的计算公式是:
- m = -n * ln(p) / (ln2)^2
- k = (m/n) * ln2
举个例子,n=1亿,p=0.01(1%误判率),那么m ≈ 9.6亿位,约114MB;k ≈ 7。也就是说,用114MB的内存,就可以对1亿个URL做去重,误判率只有1%。这个性价比是非常高的。
注意:BloomFilter的误判是“假阳性”,即把没抓过的URL判断为抓过了。这会导致少量页面漏抓,但不会重复抓取。对于大多数爬虫场景,这个代价是可以接受的。
3.3 反爬对抗的常见手段和应对策略
课程里提到了反爬,但讲得比较浅。我根据实际经验补充几个常见的反爬手段和应对思路:
| 反爬手段 | 表现 | 应对策略 |
|---|---|---|
| User-Agent检测 | 返回403或验证码 | 维护UA池,随机切换 |
| IP限流 | 请求频率过高被封IP | 使用代理池,控制请求频率 |
| 验证码 | 弹出图形或滑块验证 | 接入打码平台或OCR识别 |
| 动态渲染 | 页面内容由JS生成 | 使用无头浏览器(如Playwright) |
| 请求签名 | 参数需要加密签名 | 逆向JS,模拟签名算法 |
这里我要特别提醒一点:反爬对抗是一个持续博弈的过程,没有一劳永逸的方案。你今天能用的方法,明天可能就失效了。所以爬虫系统一定要设计成可扩展的,把反爬策略做成插件式,方便随时替换。
3.4 爬虫的合规边界
做爬虫一定要有合规意识。课程里虽然没有展开讲,但这是每个从业者必须知道的底线:
- 遵守robots.txt协议,不抓取明确禁止的路径。
- 控制请求频率,不对目标站点造成过大压力。
- 不抓取个人隐私数据、不绕过付费墙、不破解加密接口。
- 数据用于合法用途,不进行二次倒卖或恶意竞争。
这些不是技术问题,而是职业操守问题。我在实际项目中,一般会在爬虫启动前先检查robots.txt,并且把请求频率控制在目标站点能承受的范围内。
4. Lucene与Elasticsearch:从倒排索引到分布式搜索
4.1 Lucene的倒排索引到底是怎么工作的
Lucene是Elasticsearch的底层引擎,理解Lucene的倒排索引是理解ES搜索性能的关键。课程里讲了一个很形象的比喻:正排索引就像书的目录,你知道章节名,可以快速翻到对应页码;倒排索引就像书末尾的索引页,你知道一个关键词,可以快速找到所有包含这个关键词的页码。
具体来说,Lucene的倒排索引包含以下几个核心概念:
- Term(词项):经过分词器处理后得到的最小单位。比如“极客大学架构师训练营”经过分词后,可能得到“极客”“大学”“架构师”“训练营”等Term。
- Posting List(倒排列表):每个Term对应的文档ID列表,以及Term在文档中出现的位置、频率等信息。
- Segment(段):Lucene索引的基本存储单元。每次写入新文档,都会生成一个新的Segment。Segment是不可变的,删除和更新实际上是标记删除,然后在合并时真正清除。
这里有一个非常重要的设计思想:Segment的不可变性。这个设计带来了几个好处:首先,不需要加锁,因为Segment一旦生成就不会被修改;其次,可以充分利用操作系统的文件缓存;最后,合并Segment时可以批量操作,效率更高。但代价是,删除和更新不是实时的,需要等到Segment合并时才会真正生效。
4.2 Elasticsearch的分布式架构解析
Elasticsearch在Lucene之上做了一层分布式封装,核心概念包括:
- Cluster(集群):一个或多个节点的集合,对外提供统一的搜索服务。
- Node(节点):一个ES实例,可以是Master节点、Data节点或Coordinating节点。
- Index(索引):逻辑上的数据集合,类似于关系型数据库中的“库”。
- Shard(分片):索引的物理存储单元,一个索引可以分成多个Shard,分布在不同的节点上。
- Replica(副本):Shard的备份,用于提高可用性和查询吞吐量。
课程里重点讲了Shard和Replica的分配策略。我补充一下我的实操经验:
- Shard数量:不要设得太多。每个Shard本质上是一个Lucene索引,会消耗文件句柄、内存和CPU资源。一般来说,单个Shard的大小控制在10GB-50GB之间比较合适。假设你的索引总大小是500GB,那么设10-20个Shard就足够了。
- Replica数量:至少设1个。如果集群节点数大于等于3,可以设2个。Replica不仅可以提高可用性,还可以分担查询压力,因为查询请求可以同时打到Primary Shard和Replica Shard上。
- 分片分配:ES默认会自动分配Shard到各个节点。但如果节点数量少于Shard数量,就会出现多个Shard挤在同一个节点上的情况,影响性能。所以规划集群时,节点数量最好是Shard数量的整数倍。
4.3 分词器的选择与自定义
分词器是搜索引擎的核心组件之一。英文分词相对简单,按空格和标点符号切分即可。但中文分词就复杂得多,因为中文没有天然的空格分隔。
课程里提到了几种常见的中文分词器:
- Standard分词器:按字切分,比如“极客大学”会切成“极”“客”“大”“学”。优点是简单,缺点是丢失了语义信息。
- IK分词器:最常用的中文分词器,支持细粒度和智能分词两种模式。细粒度模式会把“极客大学”切成“极客”“大学”“极”“客”“大”“学”,智能模式会切成“极客”“大学”。
- HanLP分词器:功能更强大,支持词性标注、命名实体识别等,但配置相对复杂。
我实测下来,IK分词器的智能模式在大多数场景下表现最好。但如果你有特殊需求,比如需要识别自定义的专业术语,那就需要自定义词典。IK支持通过配置文件加载自定义词典,把“架构师训练营”作为一个完整的词加入词典,这样分词时就不会被切碎。
提示:自定义词典的格式是一行一个词,可以带词频和词性。词频越高,分词器越倾向于把这个词作为一个整体切分出来。
4.4 搜索性能优化的几个关键点
课程里讲了一些搜索优化的技巧,我结合自己的经验补充几个:
第一,合理使用Filter和Query。Filter不计算相关性得分,可以被缓存,性能比Query高很多。如果你只是做条件筛选,比如“价格在100到200之间”,那就用Filter,不要用Query。
第二,避免深度分页。ES默认的from+size分页方式,在深度分页时性能很差。因为每个Shard都需要返回from+size条数据给Coordinating节点,然后Coordinating节点再排序取前size条。假设你有10个Shard,要查第1000页(每页10条),那每个Shard都要返回10010条数据,总共100100条,网络传输和内存消耗都非常大。解决方案是用search_after或者Scroll API。
第三,控制返回字段。默认情况下,ES会返回文档的所有字段。如果你只需要几个字段,用_source过滤,减少网络传输和序列化开销。
第四,合理设置刷新间隔。ES默认每秒刷新一次索引,这意味着新写入的数据最多1秒后才能被搜索到。如果你对实时性要求不高,可以把刷新间隔调大,比如30秒,这样可以减少Segment合并的频率,提高写入性能。
5. 常见问题与排查技巧实录
5.1 秒杀场景下的典型问题
问题一:Redis预减库存成功,但数据库扣减失败,怎么办?
这是最常见的不一致问题。我的处理方式是:在消息队列的消费者里,如果数据库扣减失败,就把Redis中的库存加回去。同时记录一条补偿日志,方便后续排查。如果加回去也失败了(比如Redis挂了),那就需要人工介入,或者设计一个定时任务,定期对比Redis和数据库的库存,发现不一致就自动修复。
问题二:消息队列积压严重,用户等待时间过长。
首先检查消费者的处理速度是不是太慢。如果是,可以增加消费者实例,或者优化消费逻辑。如果消费者已经满负荷了,那说明队列容量设得太大了,需要限制进入队列的请求数量。我的做法是在Redis预减库存之后、写入队列之前,加一个队列长度检查,如果队列长度超过阈值,直接返回“活动太火爆,请稍后再试”。
问题三:同一用户重复下单。
这个问题的根源是幂等性没做好。解决方案是:在Redis预减库存时,同时记录用户ID和商品ID的映射关系,用SETNX命令。如果设置成功,说明是第一次请求;如果设置失败,说明已经下过单了,直接返回重复下单的提示。
5.2 爬虫场景下的典型问题
问题一:爬虫跑了一段时间后,速度越来越慢。
大概率是URL去重的数据结构出了问题。如果你用的是Set,随着已抓取URL数量增加,内存占用越来越大,GC压力也越来越大。解决方案是换成BloomFilter,或者定期清理Set中的历史数据。
问题二:目标网站返回403,但浏览器访问正常。
这是典型的反爬。检查你的请求头是不是缺少User-Agent、Referer、Cookie等字段。有些网站还会检查Accept-Language、Connection等。我的做法是直接用浏览器的开发者工具复制一个完整的请求头,然后在此基础上做随机化。
问题三:解析出来的数据有乱码。
检查页面的编码格式。有些网站声明的是UTF-8,但实际内容是GBK。解决方案是先用HttpClient获取字节流,然后根据Content-Type头或者meta标签判断编码,再用对应的编码解码。
5.3 Elasticsearch场景下的典型问题
问题一:集群状态变成Yellow。
Yellow表示主分片正常,但副本分片未分配。常见原因是节点数量少于副本数量。比如你只有1个节点,但索引设了1个副本,那副本就无法分配。解决方案是增加节点,或者把副本数设为0(仅限测试环境)。
问题二:搜索请求超时。
首先检查是不是深度分页导致的。如果是,改用search_after。如果不是,检查Shard数量是不是太多,导致Coordinating节点需要合并大量结果。一般来说,单个搜索请求涉及的Shard数量不要超过100个。
问题三:写入性能差。
检查刷新间隔是不是太短。默认1秒刷新一次,会产生大量小Segment,合并压力大。可以适当调大刷新间隔,比如30秒。另外,批量写入比单条写入性能高很多,建议用Bulk API,每批500-1000条。
| 问题场景 | 排查思路 | 解决方案 |
|---|---|---|
| 秒杀超卖 | 检查扣库存逻辑是否原子 | 用Redis DECR或数据库行锁 |
| 消息积压 | 检查消费者速度和队列容量 | 增加消费者或限制入队 |
| 爬虫变慢 | 检查去重数据结构 | 换BloomFilter或定期清理 |
| 爬虫403 | 检查请求头完整性 | 复制浏览器请求头并随机化 |
| ES Yellow | 检查节点数和副本数 | 增加节点或调整副本数 |
| ES搜索超时 | 检查分页深度和Shard数 | 用search_after或减少Shard |
5.4 我踩过的一个大坑:ES的Mapping设计
刚开始用ES的时候,我直接让ES自动推断Mapping类型。结果写入了一条"price": "100"的数据,ES把price字段推断成了text类型。后来我想按价格范围查询,发现text类型不支持范围查询,只能重新建索引,把price改成integer或float。
这个坑的教训是:永远不要依赖ES的自动Mapping,一定要手动定义Mapping。特别是数值类型、日期类型、布尔类型,一定要显式声明。另外,对于不需要分词的字段(比如商品ID、订单号),要设成keyword类型,而不是text类型。keyword类型支持精确匹配和聚合,性能也比text好。
6. 从课程到落地:我的个人实践体会
这一课的内容密度很大,秒杀、爬虫、搜索引擎三个方向,每一个展开都能讲一整天。但课程的价值在于把它们串起来了:秒杀系统需要搜索引擎来支撑商品搜索,爬虫为搜索引擎提供数据来源,搜索引擎又反过来为秒杀活动提供流量入口。这是一个完整的闭环。
我在实际项目中落地这些方案时,最大的体会是:不要追求一步到位。秒杀系统可以先从Redis预减库存+消息队列开始,不用一上来就搞分布式事务;爬虫可以先从单机多线程开始,不用一上来就搞分布式调度;搜索引擎可以先从单节点ES开始,不用一上来就搞集群。先把核心链路跑通,再根据实际瓶颈逐步优化。
另外,监控和告警一定要提前做好。秒杀活动开始前,把Redis、消息队列、数据库的监控指标都配好,设置合理的告警阈值。爬虫运行期间,监控抓取速度和失败率。ES集群要监控JVM内存、磁盘使用率和查询延迟。这些监控数据在出问题的时候,能帮你快速定位瓶颈。
最后分享一个小技巧:在做秒杀压测的时候,不要只压核心接口,要把整个链路都压一遍,包括网关、缓存、消息队列、数据库。我见过很多系统,单独压某个组件都没问题,但全链路压测时就会出现各种超时和雪崩。只有全链路压测通过了,才能放心上线。