想象你要收集全国所有图书馆的书籍信息 📚:
一个人收集(慢):
你一个人:
↓
去北京图书馆(3天)
去上海图书馆(3天)
去广州图书馆(3天)
...
去1000个图书馆(3000天)💀
↓
8年才能完成!❌
结果:
- 太慢了 ❌
- 一个人累死 ❌
1000人同时收集(快):
1000个人:
↓
每人负责1个图书馆
↓
3天完成 ✅
但问题:
- 如何分配任务?🤔
- 如何避免重复?🤔
- 如何汇总数据?🤔
这就是分布式爬虫:多个爬虫协同工作!
🤔 核心挑战
挑战1:URL去重 🔍
问题:
网站A链接到网站B
网站B也链接到网站A
↓
循环爬取 💀
↓
永远爬不完 ❌
解决:
URL去重 ✅
已爬取的URL不再爬 ✅
挑战2:任务调度 📋
问题:
100台爬虫机器
↓
如何分配任务?
↓
如何避免重复爬取?
解决:
分布式任务队列 ✅
Redis/Kafka存储待爬URL ✅
挑战3:反爬虫 🛡️
网站反爬虫手段:
1. User-Agent检测 🤖
2. IP封禁 🚫
3. 验证码 🔢
4. JS动态加载 📜
5. Cookie/Session验证 🍪
解决:
1. 随机User-Agent ✅
2. IP代理池 ✅
3. 验证码识别(OCR)✅
4. Selenium模拟浏览器 ✅
5. Cookie池 ✅
🎯 核心设计
设计1:系统架构 🏗️
分布式爬虫系统架构
┌────────────────────────────────────┐
│ 调度中心(Master) │
│ - 任务分配 │
│ - 监控管理 │
│ - 失败重试 │
└──────────────┬─────────────────────┘
│
↓
┌────────────────────────────────────┐
│ 任务队列(Redis/Kafka) │
│ - 待爬URL队列 │
│ - 优先级队列 │
└──────────────┬─────────────────────┘
│
┌───────┼───────┐
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 爬虫1 │ │ 爬虫2 │ │ 爬虫3 │
│ - 下载 │ │ - 下载 │ │ - 下载 │
│ - 解析 │ │ - 解析 │ │ - 解析 │
│ - 存储 │ │ - 存储 │ │ - 存储 │
└──────────┘ └──────────┘ └──────────┘
↓ ↓ ↓
┌────────────────────────────────────┐
│ 数据存储(MongoDB/MySQL) │
│ - 爬取结果 │
│ - 已爬URL(布隆过滤器) │
└────────────────────────────────────┘
设计2:URL去重 🔍
方案1:Set集合(简单,内存占用大)
// 已爬取的URL
Set<String> crawledUrls = new HashSet<>();
// 检查URL是否已爬取
if (crawledUrls.contains(url)) {
return; // 已爬取,跳过
}
// 爬取URL
crawl(url);
// 添加到已爬取集合
crawledUrls.add(url);
缺点:
- 1亿个URL → 约10GB内存 💀
- 内存占用太大 ❌
方案2:布隆过滤器(推荐)⭐⭐⭐
布隆过滤器原理:
- 位数组 + 多个Hash函数
- 空间占用小(1亿URL → 120MB)✅
- 查询快(O(1))✅
特点:
- 可能误判(说存在,实际可能不存在)
- 不会漏判(说不存在,一定不存在)✅
爬虫场景:
- 误判率设置0.01%
- 可接受少量重复爬取
- 节省大量内存 ✅
代码实现(Guava):
@Component
public class UrlDeduplicator {
// ⭐ 布隆过滤器(1亿容量,0.01误判率)
private final BloomFilter<String> bloomFilter =
BloomFilter.create(Funnels.stringFunnel(StandardCharsets.UTF_8),
100_000_000, // 预期容量
0.0001); // 误判率
/**
* ⭐ 检查URL是否已爬取
*/
public boolean isDuplicate(String url) {
return bloomFilter.mightContain(url);
}
/**
* ⭐ 添加URL到已爬取集合
*/
public void addUrl(String url) {
bloomFilter.put(url);
}
}
Redis实现(分布式):
@Component
public class RedisUrlDeduplicator {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String BLOOM_FILTER_KEY = "crawler:bloom_filter";
/**
* ⭐ 检查URL是否已爬取(Redis BitMap)
*/
public boolean isDuplicate(String url) {
// 使用3个Hash函数
int hash1 = hash(url, 1) % 1_000_000_000;
int hash2 = hash(url, 2) % 1_000_000_000;
int hash3 = hash(url, 3) % 1_000_000_000;
// 检查3个位是否都为1
Boolean bit1 = redisTemplate.opsForValue().getBit(BLOOM_FILTER_KEY, hash1);
Boolean bit2 = redisTemplate.opsForValue().getBit(BLOOM_FILTER_KEY, hash2);
Boolean bit3 = redisTemplate.opsForValue().getBit(BLOOM_FILTER_KEY, hash3);
return Boolean.TRUE.equals(bit1) &&
Boolean.TRUE.equals(bit2) &&
Boolean.TRUE.equals(bit3);
}
/**
* ⭐ 添加URL到已爬取集合
*/
public void addUrl(String url) {
int hash1 = hash(url, 1) % 1_000_000_000;
int hash2 = hash(url, 2) % 1_000_000_000;
int hash3 = hash(url, 3) % 1_000_000_000;
redisTemplate.opsForValue().setBit(BLOOM_FILTER_KEY, hash1, true);
redisTemplate.opsForValue().setBit(BLOOM_FILTER_KEY, hash2, true);
redisTemplate.opsForValue().setBit(BLOOM_FILTER_KEY, hash3, true);
}
/**
* Hash函数
*/
private int hash(String url, int seed) {
int hash = 0;
for (int i = 0; i < url.length(); i++) {
hash = hash * seed + url.charAt(i);
}
return Math.abs(hash);
}
}
设计3:任务队列 📋
@Service
public class CrawlerQueue {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String QUEUE_KEY = "crawler:url_queue";
private static final String PRIORITY_QUEUE_KEY = "crawler:priority_queue";
/**
* ⭐ 添加URL到队列
*/
public void addUrl(String url) {
redisTemplate.opsForList().rightPush(QUEUE_KEY, url);
}
/**
* ⭐ 添加URL到优先队列(用于种子URL)
*/
public void addPriorityUrl(String url, double priority) {
redisTemplate.opsForZSet().add(PRIORITY_QUEUE_KEY, url, priority);
}
/**
* ⭐ 获取待爬URL
*/
public String getUrl() {
// 优先从优先队列获取
Set<String> urls = redisTemplate.opsForZSet()
.reverseRange(PRIORITY_QUEUE_KEY, 0, 0);
if (urls != null && !urls.isEmpty()) {
String url = urls.iterator().next();
redisTemplate.opsForZSet().remove(PRIORITY_QUEUE_KEY, url);
return url;
}
// 从普通队列获取
return redisTemplate.opsForList().leftPop(QUEUE_KEY);
}
/**
* 获取队列长度
*/
public long getQueueSize() {
Long listSize = redisTemplate.opsForList().size(QUEUE_KEY);
Long zsetSize = redisTemplate.opsForZSet().zCard(PRIORITY_QUEUE_KEY);
return (listSize != null ? listSize : 0) + (zsetSize != null ? zsetSize : 0);
}
}
设计4:爬虫Worker 🕷️
@Component
@Slf4j
public class CrawlerWorker {
@Autowired
private CrawlerQueue crawlerQueue;
@Autowired
private UrlDeduplicator urlDeduplicator;
@Autowired
private PageParser pageParser;
@Autowired
private PageStorage pageStorage;
private volatile boolean running = true;
/**
* ⭐ 启动爬虫
*/
@PostConstruct
public void start() {
// 启动多个线程
int threadCount = 10;
for (int i = 0; i < threadCount; i++) {
new Thread(this::crawl, "crawler-worker-" + i).start();
}
}
/**
* ⭐ 爬取逻辑
*/
private void crawl() {
while (running) {
try {
// 1. 获取待爬URL
String url = crawlerQueue.getUrl();
if (url == null) {
// 队列为空,等待
Thread.sleep(1000);
continue;
}
// 2. 检查URL是否已爬取
if (urlDeduplicator.isDuplicate(url)) {
log.info("⭐ URL已爬取,跳过:{}", url);
continue;
}
// 3. 下载页面
String html = downloadPage(url);
if (html == null) {
log.warn("⭐ 下载失败:{}", url);
continue;
}
// 4. 解析页面
Page page = pageParser.parse(url, html);
// 5. 存储数据
pageStorage.save(page);
// 6. 提取新URL
List<String> newUrls = pageParser.extractUrls(html);
for (String newUrl : newUrls) {
if (!urlDeduplicator.isDuplicate(newUrl)) {
crawlerQueue.addUrl(newUrl);
}
}
// 7. 标记URL已爬取
urlDeduplicator.addUrl(url);
log.info("⭐ 爬取成功:{}", url);
// 8. 随机延迟(避免被封)
Thread.sleep((long) (Math.random() * 1000 + 1000));
} catch (Exception e) {
log.error("⭐ 爬取异常", e);
}
}
}
/**
* ⭐ 下载页面
*/
private String downloadPage(String url) {
try {
// 使用HttpClient下载
CloseableHttpClient httpClient = HttpClients.createDefault();
HttpGet httpGet = new HttpGet(url);
// ⭐ 设置User-Agent(模拟浏览器)
httpGet.setHeader("User-Agent", getRandomUserAgent());
// 设置超时
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(5000)
.setSocketTimeout(5000)
.build();
httpGet.setConfig(requestConfig);
CloseableHttpResponse response = httpClient.execute(httpGet);
if (response.getStatusLine().getStatusCode() == 200) {
return EntityUtils.toString(response.getEntity(), "UTF-8");
}
return null;
} catch (Exception e) {
log.error("⭐ 下载失败:{}", url, e);
return null;
}
}
/**
* 随机User-Agent
*/
private String getRandomUserAgent() {
String[] userAgents = {
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
};
return userAgents[(int) (Math.random() * userAgents.length)];
}
}
设计5:反爬虫策略 🛡️
策略1:IP代理池
@Component
public class ProxyPool {
private List<Proxy> proxyList = new CopyOnWriteArrayList<>();
private AtomicInteger currentIndex = new AtomicInteger(0);
@PostConstruct
public void init() {
// 加载代理IP列表
proxyList.add(new Proxy("192.168.1.1", 8080));
proxyList.add(new Proxy("192.168.1.2", 8080));
proxyList.add(new Proxy("192.168.1.3", 8080));
}
/**
* ⭐ 获取代理IP(轮询)
*/
public Proxy getProxy() {
if (proxyList.isEmpty()) {
return null;
}
int index = currentIndex.getAndIncrement() % proxyList.size();
return proxyList.get(index);
}
/**
* 标记代理IP失败
*/
public void markFailed(Proxy proxy) {
// 失败次数过多,移除代理
proxy.incrementFailCount();
if (proxy.getFailCount() > 5) {
proxyList.remove(proxy);
}
}
}
/**
* 使用代理下载
*/
private String downloadWithProxy(String url) {
Proxy proxy = proxyPool.getProxy();
HttpHost httpHost = new HttpHost(proxy.getHost(), proxy.getPort());
RequestConfig requestConfig = RequestConfig.custom()
.setProxy(httpHost)
.build();
httpGet.setConfig(requestConfig);
// 下载...
}
策略2:Cookie池
@Component
public class CookiePool {
private Queue<String> cookieQueue = new ConcurrentLinkedQueue<>();
@PostConstruct
public void init() {
// 加载Cookie列表(从多个账号获取)
cookieQueue.add("cookie1=value1");
cookieQueue.add("cookie2=value2");
cookieQueue.add("cookie3=value3");
}
/**
* ⭐ 获取Cookie
*/
public String getCookie() {
String cookie = cookieQueue.poll();
if (cookie != null) {
// 使用后放回队列
cookieQueue.offer(cookie);
}
return cookie;
}
}
策略3:Selenium模拟浏览器
@Component
public class SeleniumCrawler {
private WebDriver driver;
@PostConstruct
public void init() {
// 配置Chrome选项
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless"); // 无头模式
options.addArguments("--disable-gpu");
driver = new ChromeDriver(options);
}
/**
* ⭐ 使用Selenium下载页面(支持JS渲染)
*/
public String downloadPage(String url) {
try {
driver.get(url);
// 等待页面加载
Thread.sleep(2000);
// 获取页面源码
return driver.getPageSource();
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
@PreDestroy
public void destroy() {
if (driver != null) {
driver.quit();
}
}
}
🎓 面试题速答
Q1: URL如何去重?
A: **布隆过滤器(推荐)**⭐:
// Guava布隆过滤器
BloomFilter<String> bloomFilter =
BloomFilter.create(Funnels.stringFunnel(UTF_8),
100_000_000, // 1亿容量
0.0001); // 0.01%误判率
// 检查URL是否已爬取
if (bloomFilter.mightContain(url)) {
return; // 已爬取
}
// 爬取后添加
bloomFilter.put(url);
优点:
- 1亿URL → 120MB内存
- 查询O(1)
Q2: 任务如何分配?
A: Redis队列:
// 添加URL到队列
redisTemplate.opsForList().rightPush("crawler:queue", url);
// 爬虫获取URL
String url = redisTemplate.opsForList().leftPop("crawler:queue");
分布式:
- 多台爬虫机器从同一个Redis队列获取任务
- 自动负载均衡
Q3: 如何防止被封IP?
A: IP代理池 + 随机延迟:
// 1. IP代理池
Proxy proxy = proxyPool.getProxy();
httpGet.setConfig(RequestConfig.custom()
.setProxy(new HttpHost(proxy.getHost(), proxy.getPort()))
.build());
// 2. 随机延迟(1-2秒)
Thread.sleep((long) (Math.random() * 1000 + 1000));
// 3. 随机User-Agent
httpGet.setHeader("User-Agent", getRandomUserAgent());
Q4: JS动态加载的页面如何爬取?
A: Selenium模拟浏览器:
// 配置Chrome无头模式
ChromeOptions options = new ChromeOptions();
options.addArguments("--headless");
WebDriver driver = new ChromeDriver(options);
// 访问页面
driver.get(url);
// 等待JS执行
Thread.sleep(2000);
// 获取渲染后的HTML
String html = driver.getPageSource();
Q5: 如何提高爬取速度?
A: 三层优化:
- 多线程并发:
// 每个爬虫Worker启动10个线程
int threadCount = 10;
for (int i = 0; i < threadCount; i++) {
new Thread(this::crawl).start();
}
- 分布式部署:
10台爬虫机器 × 10线程 = 100并发
- 异步IO:
// 使用异步HttpClient
CloseableHttpAsyncClient httpAsyncClient =
HttpAsyncClients.createDefault();
Q6: 如何监控爬虫状态?
A: 统计指标:
@Component
public class CrawlerMonitor {
private AtomicLong totalCount = new AtomicLong(0);
private AtomicLong successCount = new AtomicLong(0);
private AtomicLong failCount = new AtomicLong(0);
/**
* ⭐ 定时上报统计(每分钟)
*/
@Scheduled(fixedRate = 60000)
public void report() {
long total = totalCount.get();
long success = successCount.get();
long fail = failCount.get();
long queueSize = crawlerQueue.getQueueSize();
log.info("⭐ 爬虫统计:总数={}, 成功={}, 失败={}, 队列={}",
total, success, fail, queueSize);
}
}
🎬 总结
分布式爬虫系统核心
┌────────────────────────────────────┐
│ 1. URL去重(布隆过滤器)⭐ │
│ - 1亿URL → 120MB │
│ - O(1)查询 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 2. 任务队列(Redis) │
│ - 分布式队列 │
│ - 优先级队列 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 3. 反爬虫 🛡️ │
│ - IP代理池 │
│ - 随机User-Agent │
│ - Selenium模拟浏览器 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 4. 分布式部署 │
│ - 多台爬虫机器 │
│ - 多线程并发 │
└────────────────────────────────────┘
┌────────────────────────────────────┐
│ 5. 监控统计 │
│ - 爬取成功率 │
│ - 队列长度 │
└────────────────────────────────────┘
🎉 恭喜你!
你已经完全掌握了分布式爬虫系统的设计!🎊
核心要点:
- 布隆过滤器去重:1亿URL仅占120MB内存
- Redis任务队列:分布式任务调度
- 反爬虫策略:IP代理池 + 随机UA + Selenium
- 多线程并发:每台10线程,10台机器=100并发
- 监控统计:实时监控爬取状态
下次面试,这样回答:
"分布式爬虫系统的核心是URL去重和任务调度。URL去重使用布隆过滤器实现,1亿URL仅占用120MB内存,误判率设置为0.01%。布隆过滤器使用3个Hash函数,将URL映射到位数组的3个位置。查询时检查这3个位是否都为1,时间复杂度O(1)。使用Redis BitMap存储位数组,支持分布式去重。
任务调度使用Redis队列实现。待爬URL存储在Redis List中,爬虫Worker从队列头部取URL,爬取完成后将新URL加入队列尾部。使用Redis ZSet实现优先队列,种子URL设置高优先级优先爬取。多台爬虫机器从同一个Redis队列获取任务,自动实现负载均衡。
反爬虫策略包括IP代理池、随机User-Agent和Selenium模拟浏览器。维护一个代理IP池,每次请求随机选择一个代理。User-Agent从预设的浏览器列表中随机选择。请求之间随机延迟1-2秒,避免请求频率过高。对于JS动态加载的页面,使用Selenium驱动Chrome无头浏览器,等待JS执行完成后获取渲染后的HTML。
性能优化方面,每台爬虫机器启动10个Worker线程并发爬取。部署10台爬虫机器,总并发度达到100。使用异步HttpClient进一步提升IO性能。爬取结果异步写入MongoDB,避免阻塞爬虫线程。
监控方面,统计总爬取数、成功数、失败数、队列长度等指标,每分钟上报一次。失败的URL重新加入队列重试,最多重试3次。异常情况发送钉钉报警。"
面试官:👍 "很好!你对分布式爬虫的设计理解很深刻!"
本文完 🎬
上一篇: 216-设计一个API网关.md
下一篇: 218-设计一个视频网站系统.md
作者注:写完这篇,我都想去当蜘蛛侠了!🕷️
如果这篇文章对你有帮助,请给我一个Star⭐!