做行情数据采集的 C# 开发者,应该都撞过同一堵墙:页面在浏览器里跑得干干净净,数据实时跳动、图表花里胡哨,可一旦换成程序去拿,不是取不到就是取不全。行情数据大多是异步渲染的,接口带签名,请求头里有反爬指纹,你花两天逆向完 JS,第三天平台一升级,直接全部作废。这篇文章写给正在做股票、期货、加密货币等行情抓取的 .NET 开发者,讲清楚一套我验证过多次的完整思路:用 CefSharp 把 Chromium 内核装进 C# 进程里当“浏览器级采集引擎”,再配合动态代理池解决 IP 限制问题,把过去那种“跟反爬体系死磕”的思路,变成纯粹的工程调度问题。
我尽量不堆术语,但该给代码的地方绝不吝啬。这里面的坑很多,有些是 Chromium 内核本身的行为,有些是代理链路的网络细节,我会把当时怎么发现问题、怎么排查、最后怎么解决的完整过程都写出来,方便你直接照着搭。
1. 为什么行情数据绕不开真浏览器:选型背后的真实原因
1.1 行情平台的技术形态决定了常规方案的边界
我做行情抓取的第一年,还是走老路子:先抓页面源码,再找接口。后来发现这个思路在主流行情平台上几乎走不通,原因是它们的架构已经变了。
现在的行情网站几乎都是单页应用(SPA),页面初始 HTML 只有一个空壳,真正的内容靠 JavaScript 在浏览器里异步请求接口,再动态渲染出来。你想看到完整的表格、K 线、买卖五档,就必须让 JS 跑起来。更麻烦的是,不少平台用了 WebSocket 推送行情,页面主逻辑会建立一个长连接,行情变化直接通过这个通道推送到前端回调函数里,再更新 DOM。HttpClient 这类直连方案根本接触不到这些数据。
有同学可能会说,那就直接模拟它的 WebSocket 协议,或者逆向接口签名。这条路我可以明确告诉你:能走,但维护成本极高。行情平台的签名算法经常变,而且往往结合了请求头、cookie、加密参数、本地指纹。你逆向一次可能要消耗一周时间,平台改一次版本你又得重新跟。对于以数据结果为导向的采集任务来说,用真实浏览器内核去加载页面,再在页面环境里读取数据,才是性价比最高的选择。
1.2 C# 生态下的主流方案对比
在 .NET 生态里,想在应用内“内置一个浏览器”,大概有这么几条路。
| 方案 | 内核 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| HttpClient / WebSocket 直连 | 无 | 快、轻量、并发高 | 需逆向签名、容易封 IP、无 JS 执行能力 | 公开接口、低频数据 |
| Selenium + WebDriver | 独立浏览器进程 | 和真实浏览器一致、社区资料多 | C# 集成体验一般、资源占用高、并发依赖多实例进程 | 中小规模采集、验证机制复杂的站点 |
| WebView2 | Edge Chromium | 微软官方维护、更新跟随系统 | 代理配置能力受限、控制粒度不如 CefSharp、对旧系统兼容一般 | 桌面端 UI 嵌入、常规页面加载 |
| CefSharp | Chromium (CEF) | C# 原生调用、可后台无窗口运行、可用命令行参数深度定制网络栈、进程模型可控 | 包体积大、内存占用偏高、部分 API 需要摸索 | 浏览器级采集、自动化操作、需要页面环境执行 JS 的场景 |
CefSharp 的底层是 CEF(Chromium Embedded Framework)的 .NET 封装。它的核心价值在于:Chromium 的进程模型、渲染能力、网络栈都暴露给了宿主程序。你可以不显示任何窗口,在后台加载完整页面;你可以向页面注入任意 JS,在页面环境里执行代码并拿回结果;你还可以通过命令行参数干预网络请求的出口路径。这些特性叠加在一起,正好命中行情抓取的全部需求。
我现在的默认选择就是 CefSharp。它虽然不是万能的,但在 C# 这个技术栈里,做“浏览器级采集”没有比它更合适的底子了。
2. CefSharp 初始化与页面数据提取:把 Chromium 变成可控的采集引擎
2.1 采集场景下必须调整的初始化参数
CefSharp 的初始化集中在CefSettings上。默认参数适合做普通桌面浏览器,但做采集时几个开关必须调整,否则要么弹窗、要么内存爆炸。
var baseDir = AppDomain.CurrentDomain.BaseDirectory; var settings = new CefSettings { BrowserSubprocessPath = Path.Combine(baseDir, "cef", "CefSharp.BrowserSubprocess.exe"), CachePath = Path.Combine(baseDir, "cef_cache"), LogSeverity = LogSeverity.Warning, WindowlessRenderingEnabled = true }; // 核心:让浏览器所有网络请求都走本地转发代理 settings.CefCommandLineArgs.Add("proxy-server", "http://127.0.0.1:10810"); // 禁用沙箱、GPU、插件,降低资源占用并减少异常崩溃 settings.CefCommandLineArgs.Add("no-sandbox"); settings.CefCommandLineArgs.Add("disable-gpu"); settings.CefCommandLineArgs.Add("disable-plugins"); settings.CefCommandLineArgs.Add("disable-extensions"); settings.CefCommandLineArgs.Add("disable-web-security"); // 不加载图片,行情页面用不到,能省不少带宽和时间 settings.CefCommandLineArgs.Add("blink-settings", "imagesEnabled=false"); Cef.Initialize(settings, shutdownOnProcessExit: true);有几个点需要解释。no-sandbox在采集场景基本是标配,因为 CefSharp 的浏览器子进程在严格的沙箱模式下可能出现权限问题,尤其是跑在 Windows 服务或者计划任务里的时候。disable-gpu是必须的,后台渲染本来就不需要 GPU,不关的话偶尔会触发显卡驱动异常导致整个进程崩溃。disable-web-security不是所有页面都需要,但某些行情站点会跨域请求接口,开这个省心。
另外提一句,WindowlessRenderingEnabled配合 OffScreen 模式可以让浏览器完全在后台运行,不弹出任何窗口,这是采集场景的基础能力。
2.2 页面加载完成与 JS 数据提取的时序陷阱
初始化之后,创建浏览器实例本身很简单:
var browser = new ChromiumWebBrowser("https://quote.example.com");难点在于确定“页面什么时候算真正加载完了”。很多初学者用LoadingStateChanged事件,这个事件在页面主文档加载完成后触发,但这不等于行情数据到位了。SPA 页面的主文档往往是一个空壳,真正的数据是在后续 JS 异步请求完成后才渲染的。
我的做法是:主文档加载完成后,先做一次探测性 JS 执行,确认目标 DOM 节点或者全局变量已经出现,再开始正式提取。
browser.LoadingStateChanged += async (sender, e) => { if (e.IsLoading) { return; } // 等待行情根节点出现,最多重试 30 次 var checkScript = "!!document.querySelector('#quote-table')"; for (var i = 0; i < 30; i++) { var result = await browser.GetMainFrame() .EvaluateScriptAsync(checkScript); if (result.Success && result.Result is bool ready && ready) { break; } await Task.Delay(500); } };这个探测循环很重要。行情页面的加载时间波动很大,网络快的时候两三秒数据就出来了,慢的时候可能拖到二十秒。固定等几秒不可靠,轮询探测最稳。
2.3 实时行情轮询提取的取舍
页面加载完成只是开始。行情数据是持续变化的,买卖五档、最新成交价、成交量都在高频更新。采集方案要考虑:是持续从 DOM 里取,还是从网络层直接截获。
我试过用 resource handler 拦截 XHR 请求的 JSON 响应,这条路速度最快、数据最完整,因为很多行情平台把核心数据放在某个接口的 JSON 里。但问题是要维护接口路径、解析字段、应对接口调整,本质上又回到逆向接口的老路上。所以我后来主推 DOM 轮询方案。
用一个后台定时器,每隔 1 到 3 秒执行一次 JS,读取页面里行情区域的内容,转成 JSON 字符串传回 C# 侧解析:
private async Task<string> ExtractQuoteSnapshotAsync(ChromiumWebBrowser browser) { var script = @" (function() { var el = document.querySelector('#quote-table'); if (!el) return null; var rows = el.querySelectorAll('tr'); var arr = []; rows.forEach(function(row) { var cells = row.querySelectorAll('td'); arr.push(Array.from(cells).map(function(c){return c.innerText;})); }); return JSON.stringify(arr); })(); "; var response = await browser.GetMainFrame().EvaluateScriptAsync(script); return response.Success ? response.Result?.ToString() : null; }DOM 轮询有两个好处:一是直接复用页面本身的渲染逻辑,不用关心接口怎么变;二是拿回来的数据天然对应页面展示层,方便做页面级校验。缺点是拿不到没渲染到页面上的隐藏字段,而且轮询间隔决定了数据精度,适合分钟级和秒级行情,不适合高频逐笔数据。如果你的场景需要毫秒级逐笔,那还是得走网络层拦截,或者改用 WebSocket 捕获,那是另一个话题了。
3. 动态代理的真正落点:本地代理转发层设计
3.1 为什么不能直接在 CefSharp 里热切换代理
行情平台对 IP 的访问频率控制相当严,同一个 IP 短时间内大量请求,很快就会被限制或者封禁。所以动态代理几乎是必需品。但 CefSharp 代理切换这个问题,坑了我很久。
CefSharp 的代理设置本质上是 Chromium 的命令行参数,也就是那个--proxy-server。这个参数是进程级的,在Cef.Initialize的时候写进命令行参数,启动之后就很难再改。CefSharp 也没有公开的“运行时切换全局代理”的一键 API。很多人在网上问,得到的回答无非是改参数然后重启进程。
如果你只是偶尔切换一次,重启进程方案也能接受。但如果你的任务是“海量行情数据”,每分钟要处理几十上百个页面实例、每个实例最好走不同出口 IP,重启整个 Cef 进程在工程上就是灾难。这里就需要换一个思路:不改 CefSharp 的代理,而是让 CefSharp 固定连一个本地转发代理,真正的动态出口在转发代理那一层去实现。
3.2 本地代理转发层的工作原理:CONNECT 代理链
当你在 CefSharp 里设置了proxy-server指向本机的某个端口之后,浏览器发起的所有 HTTP/HTTPS 请求都会先到达这个端口。对于 HTTPS 请求,浏览器会发送标准的CONNECT报文,告诉代理它要访问哪个主机和端口。代理收到后,可以选择自己直接连目标服务器,也可以再通过一个或多个上游代理建立链路。
我们要做的转发器,逻辑是这样的:
- 监听本机端口,接收 CefSharp 发来的连接请求。
- 解析出目标地址。
- 从动态代理池中选一个可用代理。
- 先连接这个上游代理,向上游转发
CONNECT请求。 - 上游代理返回 200 后,把“连接已建立”的响应回给 CefSharp。
- 后续浏览器与目标服务器之间的双向数据,直接在本地连接和上游连接之间转发。
这样 CefSharp 从头到尾只知道自己连了一个本地端口,它完全感知不到代理池的切换。每次连接都可以选择不同的上游代理,也就实现了“动态代理”。
核心的转发逻辑简化后大概长这样:
private async Task HandleClientAsync(TcpClient localClient) { using (localClient) using (var remote = new TcpClient()) { var localStream = localClient.GetStream(); // 第一步:从浏览器读取 CONNECT 报文中的目标主机 var connectLine = await ReadLineAsync(localStream); var target = ParseConnectTarget(connectLine); // 例如 "api.example.com:443" // 第二步:从代理池挑选一个上游代理 var upstream = ProxyPool.Rent(); // 第三步:连接上游代理,并转发 CONNECT await remote.ConnectAsync(upstream.Host, upstream.Port); await SendConnectAsync(remote.GetStream(), target); // 第四步:向上游确认成功后,回写 200 给浏览器 var response = await ReadResponseAsync(remote.GetStream()); if (!response.StartsWith("HTTP/1.1 200")) { return; } await WriteAsync(localStream, "HTTP/1.1 200 Connection Established\r\n\r\n"); // 第五步:双向桥接 var pump1 = PumpAsync(localStream, remote.GetStream()); var pump2 = PumpAsync(remote.GetStream(), localStream); await Task.WhenAny(pump1, pump2); } }需要提醒的是,上面的代码是教学级的简化版本。真实环境里你还要处理半关闭状态、数据缓冲、超时控制、DNS 解析模式、连接复用等大量的边界情况。特别是不能直接用StreamReader去读CONNECT报文的第一行,因为StreamReader有内部缓存,会连后面的数据一起吞掉,导致桥接时数据丢失。正确做法是自己手动实现按行读取的字节缓冲。
3.3 代理池的维护与动态分配策略
转发器能不能稳定工作,关键看代理池管理。代理池不只是存一堆 IP 地址,它得有发现、检测、评分、惩罚机制。
我的代理池结构大致是这样:
public class UpstreamProxy { public string Host { get; set; } public int Port { get; set; } public string UserName { get; set; } public string Password { get; set; } public long SuccessCount { get; set; } public long FailCount { get; set; } public long TotalLatencyMs { get; set; } public DateTime LastUsed { get; set; } public bool IsBlocked { get; set; } }维护逻辑有四个动作:
- 定期检测:用一个后台任务,每隔几十秒从代理池抽一批代理,尝试连接一个固定目标网站,测出连通性和握手耗时。
- 评分排序:连通成功率 60% 以下的降权,平均延迟高于某个阈值(比如 3000 毫秒)的降权,连续失败超过 5 次的直接拉入隔离区。
- 冷却机制:刚用过不久的代理短时间内不重复分配,避免同一出口太密集。
- 动态补充:在代理池低于阈值时从供应商接口拉取新代理,或者提醒运维手动补充。
分配策略则取决于你的采集压力。最简单的是随机分配加评分加权,效果已经不错。更精细一点的方案是“分桶路由”:给每个 CefSharp 实例分配一个固定的本地代理端口,这个端口的转发器只从代理池的某个子集里取 IP,保证同一个页面会话尽量走同一类出口,减少行情连接被中途换 IP 造成的波动。
public class ProxyPool { private readonly ConcurrentQueue<UpstreamProxy> _available = new(); private readonly object _lock = new(); public UpstreamProxy Rent() { lock (_lock) { // 按评分挑一个可用代理,跳过隔离区的 var candidates = _available .Where(p => !p.IsBlocked) .OrderByDescending(p => GetScore(p)) .ToList(); var proxy = candidates.FirstOrDefault(); proxy.LastUsed = DateTime.UtcNow; return proxy; } } }这套设计跑起来之后,CefSharp 侧的代码几乎不用再关心代理问题。切换 IP 的管理职责全部从“浏览器进程层”下沉到了“网络转发层”,这是整个项目架构上最关键的一个决策。
4. 海量抓取的工程化架构:任务、并发与数据落地
4.1 任务编排与并发控制
准备工作做完,剩下的就是堆量。行情行情,核心就是高频、海量。我当时的任务形态有两种:一种是按标的列表批量抓快照,比如一天要把几千只股票的当前盘口数据刷新一遍;另一种是按固定周期持续盯盘,比如每 5 秒抓一次某个合约的最新价。
不管哪种形态,任务编排我统一走“生产者-消费者”模型。生产者生成任务,消费者拿到任务后分配到一个可用的 CefSharp 实例执行。用 .NET 的Channel<T>或者简单点用BlockingCollection<T>都能实现:
var channel = Channel.CreateBounded<QuoteTask>(new BoundedChannelOptions(1000) { FullMode = BoundedChannelFullMode.Wait }); var consumers = new List<Task>(); for (var i = 0; i < maxInstances; i++) { consumers.Add(Task.Run(() => ConsumeAsync(channel.Reader))); }并发数量是这里最需要拿捏的参数。CefSharp 的每个浏览器实例在底层会有独立的渲染进程,每个实例在 C# 侧占用内存加上 Chromium 子进程的内存,轻松超过三四百兆。我见过有人一口气开 50 个实例,结果内存直接打满,Windows 开始疯狂换页,全部实例一起卡死。稳妥的做法是从小规模开始压,比如先跑 8 个实例,观察内存和响应时间的趋势,再逐步加。一般来说,16GB 内存的机器,稳定跑 20 到 30 个实例是比较合理的水位。
4.2 实例调度与异常回收
CefSharp 实例用久了会出现各种异常状况:页面假死、渲染进程崩溃、内存不释放。所以实例不能是“一锤子买卖”,要有回收和重建机制。
我的做法是给每个实例配一个健康状态字段,每轮任务执行后更新。如果连续 N 次任务超时、或者页面加载失败、或者 JS 执行一直不成功,就判定为不健康,把这个实例从调度池里摘除,调用browser.Dispose(),然后重新new ChromiumWebBrowser()替换它。
这个机制还解决了一个隐藏问题:CefSharp 实例对同一个站点,如果累计请求次数太多,即使代理在换,也可能被平台用浏览器指纹策略盯上。定期“新鲜”一下实例,等于连带刷新了渲染进程的指纹状态,客观上降低了被反爬识别的概率。
4.3 数据解析、去重与批量入库
从页面提取回来的 JSON 还需要解析、清洗、去重,然后入库。行情数据的特征决定了入库方案的取舍:量大、时间敏感、重复写入频繁。
我建议先走内存队列,把提取结果先丢进队列,由独立的落库线程批量处理,不要在采集线程里直接写数据库。批量入库用SqlBulkCopy或者 Dapper 的批量扩展都行,我习惯用SqlBulkCopy,在 SQL Server 下效率确实高:
using var bulk = new SqlBulkCopy(connectionString, SqlBulkCopyOptions.UseInternalTransaction); bulk.DestinationTableName = "dbo.QuoteSnapshot"; bulk.ColumnMappings.Add("Symbol", "Symbol"); bulk.ColumnMappings.Add("QuoteTime", "QuoteTime"); bulk.ColumnMappings.Add("Price", "Price"); bulk.ColumnMappings.Add("Volume", "Volume"); await bulk.WriteToServerAsync(dataTable);去重逻辑要注意。行情数据天然带时间戳,同一个标的、同一个时刻的快照可能被重复抓取。我建表时用Symbol + QuoteTime作为联合主键,配合处理重复行的策略,确保数据落库不会翻倍。
数据模型上,行情快照和逐笔成交要分开看。快照记录的是某一时刻的全量盘口,适合用关系库或者列式存储;逐笔成交量大且只增不改,更适合时序库。如果你现在用的是 SQL Server,先别急着为了行情单独引时序库,快照放关系库、逐笔拆到另一个表按月分表,已经能应付大部分场景。
5. 实测中翻过的车:六条值得记录的踩坑经验
5.1 代理参数与系统代理的实际冲突
第一个坑非常隐蔽。CefSharp 设置--proxy-server指向本地代理之后,浏览器子进程内部的某些请求(比如安全证书校验、自动更新检查)有时会绕过这个参数走系统代理,导致一部分请求没有进入转发层,直接走了默认网络出口。
排查过程很折腾:转发层日志里看到的连接数远少于页面实际发出的请求数,一查才发现是这个原因。解决的方案是在 CefSettings 里同时加上--no-proxy-server?不对,加了就和--proxy-server冲突了。正确的做法是确保系统代理没有配置任何值,或者用--proxy-bypass-list=<-loopback>把本地回环地址排除,让所有外部流量都强制走指定代理。这类参数细节,每个版本的 Chromium 行为都不完全一样,遇到连接不到转发器的时候,要第一时间想起这个方向。
5.2 桥接超时不熔断,整条链路被拖死
代理链路里最容易出问题的环节,是上游代理突然无响应。如果转发器在桥接循环里傻等,那么这一个连接会一直占着线程和 socket 不释放。并发一高,几十个卡死的连接就把线程池吃光了。
后来我在转发层加了一个超时机制:从建立连接开始,整个桥接生命周期超过 60 秒没有数据流动,直接强制断开。这个超时阈值不是拍脑袋定的,行情页面单次数据流的静默期很少超过几十秒,如果超时说明链路已经异常,断了重来比干等划算。
5.3 CefSharp 内存只涨不降的排查
运行几个小时后,内存占用一路走高,最终逼近几个 GB。这是 CefSharp 采集场景的常见病。根源有几个:缓存目录无限膨胀、页面自身的动态内容不断堆积、以及 OffScreen 渲染模式下有些资源没有及时回收。
我的处理组合拳是:
CachePath指向一个每次启动会清理的临时目录。- 定期调用
browser.GetBrowserHost().CloseBrowser(forceClose: true)后 Dispose 实例。 - 严格控制单个实例的存活时间,比如每处理 200 个页面就强制重建。
- 用
Cef.ClearCookieManager之类的清理方法定期清掉站点的 cookie 和 localStorage,防止站点在浏览器环境里长期累积数据。
这套组合下来,内存基本能稳定住。
5.4 EvaluateScriptAsync 的等待陷阱
EvaluateScriptAsync在页面 JS 执行卡住的时候,会一直等到 CefSharp 内部超时,这个超时时间在某些场景下长得离谱。更麻烦的是,如果你在LoadingStateChanged异步回调里直接await一个特别耗时的 JS 操作,可能把整个浏览器消息循环堵住,导致后续所有操作都排队。
经验是给 JS 执行加一个整体超时外壳,用Task.WhenAny搭配Task.Delay实现:
var task = browser.GetMainFrame().EvaluateScriptAsync(script); var completed = await Task.WhenAny(task, Task.Delay(5000)); if (completed != task) { // 处理超时,跳过本轮采集 return null; } var result = await task;这样即使页面 JS 卡死,采集侧也不会被拖住。
5.5 DOM 轮询与 WebSocket 数据不一致
最后一个坑最影响数据质量。有些行情平台的页面,WebSocket 推送的数据会先更新到一个内存对象里,再通过定时器刷新 DOM;甚至有些字段只在 JS 内部维护,从来不上 DOM。如果你只轮询 DOM,拿到的数据可能滞后一拍,极端情况下还缺字段。
我当时的排查方法是:在浏览器里手动打开 DevTools 的 Console,用一小段 JS 把页面里挂载的全局对象全部枚举出来,看哪个对象的属性里藏着行情数据。很多行情站都会暴露一个全局变量,里面存着最新的盘口快照。找到之后,轮询脚本可以直接读这个全局对象,比读 DOM 更快更全。
(function() { // 示例:平台可能暴露的数据对象 var g = window.quoteData || window.marketSnapshot || null; if (!g) return null; return JSON.stringify({ symbol: g.symbol, price: g.price, bid: g.bid, ask: g.ask, updateTime: g.time }); })();全局变量方案也有被平台改掉的风险,所以正式采集前要写一个启动诊断流程:先尝试全局变量,再回退到 DOM 解析,两套机制并存,其中一条失效时自动切换并告警。
整个项目跑通之后,我最大的体会是:浏览器级采集不是“能用 CefSharp 打开页面就算完事”,真正的复杂度全在工程细节里——页面时机的判断、代理链路的稳定性、实例的生命周期管理、数据入库的一致性。把这些细节一个个抠清楚,海量行情抓取就不再是看运气的事,而是一条可预测、可监控、可扩展的流水线。
如果让我给后来者一句忠告,那就是别急着上量。先把一个实例、一个页面、一条完整的数据链路打通跑稳,再考虑堆并发。我见过太多项目在没有建立健康监测的情况下直接开 20 个实例,最后全卡在同一个代理节点上,那种情况下你甚至连问题出在哪个环节都说不清。从 1 到 10 很容易,从 0 到 1 才是真正决定架构成败的部分。