🕷️ 设计一个分布式爬虫系统:蜘蛛侠的秘密!

40 阅读10分钟

想象你要收集全国所有图书馆的书籍信息 📚:

一个人收集(慢)

你一个人:
    ↓
去北京图书馆(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: 三层优化

  1. 多线程并发
// 每个爬虫Worker启动10个线程
int threadCount = 10;
for (int i = 0; i < threadCount; i++) {
    new Thread(this::crawl).start();
}
  1. 分布式部署
10台爬虫机器 × 10线程 = 100并发
  1. 异步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. 布隆过滤器去重:1亿URL仅占120MB内存
  2. Redis任务队列:分布式任务调度
  3. 反爬虫策略:IP代理池 + 随机UA + Selenium
  4. 多线程并发:每台10线程,10台机器=100并发
  5. 监控统计:实时监控爬取状态

下次面试,这样回答

"分布式爬虫系统的核心是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⭐!