异步新闻爬虫去重优化:无需Semaphore且不损失性能
异步爬虫重复数据问题的解决方案
问题背景
我开发了一个带有后台网页爬虫服务的网站,最初用同步模式爬取186000篇新闻需要约24小时。改成异步模式后,发现爬取的30条新闻里仅5条是唯一的,其余全是重复内容。虽然确认过代码逻辑,但问题依然存在。尝试用Semaphore锁定URL列表后解决了重复问题,但爬完全部新闻仍需约23.5小时,完全没达到性能提升的预期。现在需要不用Semaphore就能解决重复问题的方案。
问题代码
private async Task<List<News>> GetTheNewsDataAsync(HtmlWeb web, HtmlDocument doc, List<string> links) { var news = new List<News>(); var baseUrl = "https://www.dnes.bg"; var date = ""; var dateParsed = new DateTime(1000, 1, 1, 12, 30, 0); var imageSrc = ""; var title = ""; var sterilizedContent = ""; var link = ""; var tasks = new List<Task<News?>>(); for (int i = 0; i < links.Count; i++) { var index = i; tasks.Add(Task.Run(async () => { link = links[index]; doc = await web.LoadFromWebAsync(baseUrl + link); var titleElement = doc.DocumentNode.SelectSingleNode("//h1[@class='title']"); if (titleElement != null) { title = titleElement.InnerText; } if (!await CheckIfNewsExistsAsync(title) && title != "") { var contentElement = doc.DocumentNode.SelectNodes("//p"); if (contentElement != null) { sterilizedContent = SterilizeTheNews(contentElement); } var dateElement = doc.DocumentNode .SelectSingleNode("//div[@class='art_author']"); if (dateElement != null) { if (dateElement.InnerText.StartsWith("Обновена")) { var pipeIndex = dateElement.InnerText.IndexOf('|'); date = dateElement.InnerText.Substring("Обновена: ".Length, pipeIndex - "Обновена: ".Length).Trim(); } else { date = dateElement.InnerText.Split(',')[0]; } try { if (date.Contains("мар")) { date = date.Replace("мар", "март"); } dateParsed = DateTime.ParseExact(date, "d MMM yyyy HH:mm", new CultureInfo("bg-BG")); } catch (Exception e) { Console.WriteLine($"Exception parsing the date! {e.Message}"); dateParsed = new DateTime(1000, 1, 1, 12, 30, 0); } } var imageElement = doc.DocumentNode.SelectSingleNode( "//div[@id='article_text']//img"); if (imageElement != null) { imageSrc = imageElement.Attributes["src"].Value; } else { imageSrc = "https://digitalfinger.id/wp-content/uploads/2019/12/" + "no-image-available-icon-6.png"; } var currentNews = new News { Title = title, Content = sterilizedContent, Date = dateParsed, ImageUrl = imageSrc }; return currentNews; } return null; })); } var result = await Task.WhenAll(tasks); foreach(var item in result) { if (item != null) { news.Add(item); } } return news; }
问题根源
重复数据的核心原因是方法级共享变量的竞态条件:你定义的date、dateParsed、imageSrc、title、sterilizedContent、link、doc都是方法级别的变量,所有异步任务都会同时读写这些变量。当多个任务并行执行时,后执行的任务会覆盖这些变量的值,导致最终生成的News对象使用了被覆盖后的内容,出现大量重复。
解决方案
把所有共享变量移到异步任务内部,让每个任务拥有独立的局部变量,避免跨任务的变量覆盖。同时可以将单条新闻的处理逻辑提取为独立方法,提升代码可读性:
修改后的代码
private async Task<List<News>> GetTheNewsDataAsync(HtmlWeb web, List<string> links) { var news = new List<News>(); var baseUrl = "https://www.dnes.bg"; var tasks = new List<Task<News?>>(); // 遍历链接,为每个链接创建独立的处理任务 foreach (var link in links) { tasks.Add(ProcessSingleNewsAsync(web, baseUrl, link)); } var result = await Task.WhenAll(tasks); news.AddRange(result.Where(item => item != null)); return news; } // 单独处理单条新闻,所有变量均为局部变量,完全隔离 private async Task<News?> ProcessSingleNewsAsync(HtmlWeb web, string baseUrl, string link) { var doc = await web.LoadFromWebAsync(baseUrl + link); // 提取标题 var titleElement = doc.DocumentNode.SelectSingleNode("//h1[@class='title']"); if (titleElement == null) return null; var title = titleElement.InnerText.Trim(); if (string.IsNullOrEmpty(title) || await CheckIfNewsExistsAsync(title)) return null; // 提取内容 var sterilizedContent = string.Empty; var contentElement = doc.DocumentNode.SelectNodes("//p"); if (contentElement != null) { sterilizedContent = SterilizeTheNews(contentElement); } // 解析日期 var dateParsed = new DateTime(1000, 1, 1, 12, 30, 0); var dateElement = doc.DocumentNode.SelectSingleNode("//div[@class='art_author']"); if (dateElement != null) { var date = string.Empty; if (dateElement.InnerText.StartsWith("Обновена")) { var pipeIndex = dateElement.InnerText.IndexOf('|'); date = dateElement.InnerText.Substring("Обновена: ".Length, pipeIndex - "Обновена: ".Length).Trim(); } else { date = dateElement.InnerText.Split(',')[0].Trim(); } try { if (date.Contains("мар")) { date = date.Replace("мар", "март"); } dateParsed = DateTime.ParseExact(date, "d MMM yyyy HH:mm", new CultureInfo("bg-BG")); } catch (Exception e) { Console.WriteLine($"Exception parsing the date! {e.Message}"); } } // 提取图片链接 var imageSrc = "https://digitalfinger.id/wp-content/uploads/2019/12/no-image-available-icon-6.png"; var imageElement = doc.DocumentNode.SelectSingleNode("//div[@id='article_text']//img"); if (imageElement != null && imageElement.Attributes["src"] != null) { imageSrc = imageElement.Attributes["src"].Value; } return new News { Title = title, Content = sterilizedContent, Date = dateParsed, ImageUrl = imageSrc }; }
额外性能优化建议
- 移除不必要的
Task.Run:LoadFromWebAsync本身就是异步方法,不需要用Task.Run包装,直接调用即可减少线程池资源消耗。 - 控制并发数:用
SemaphoreSlim限制同时执行的任务数量(比如设置为10-20),避免目标网站触发反爬机制,同时避免本地线程过载:private async Task<List<News>> GetTheNewsDataAsync(HtmlWeb web, List<string> links) { var news = new List<News>(); var baseUrl = "https://www.dnes.bg"; var semaphore = new SemaphoreSlim(15); // 限制15个并发任务 var tasks = new List<Task<News?>>(); foreach (var link in links) { tasks.Add(ProcessSingleNewsWithSemaphoreAsync(web, baseUrl, link, semaphore)); } var result = await Task.WhenAll(tasks); news.AddRange(result.Where(item => item != null)); return news; } private async Task<News?> ProcessSingleNewsWithSemaphoreAsync(HtmlWeb web, string baseUrl, string link, SemaphoreSlim semaphore) { await semaphore.WaitAsync(); try { return await ProcessSingleNewsAsync(web, baseUrl, link); } finally { semaphore.Release(); } } - 缓存
CheckIfNewsExistsAsync结果:如果该方法是查询数据库,重复查询相同标题会浪费资源,可以用ConcurrentDictionary<string, bool>缓存已检查过的标题,减少数据库访问。
内容的提问来源于stack exchange,提问作者Alexander Nedelchev
相关产品推荐
相关产品推荐

