大猫OPC
项目记录

单机 2 核,跑 3,281 个地区站

长期维护 架构设计 / 站群系统 更新:2026-11

3,281 个地区站、约 13.7 万个页面,跑在一台 2 核 3.5GB 的机器上:一个 ASP.NET Core 进程、一个 1.5MB 的 SQLite 业务库,服务端渲染 1.5–2.6 ms,压测 194.3 req/s。

背景与决策.

一个工厂站,要覆盖 3,281 个地区词

石笼网、格宾网这类产品,客户搜索时几乎都带地名:

北京石笼网厂家
北京格宾网价格
北京东城石笼网多少钱一平米

搜索意图很明确:找本地的、能供货的厂家。行业里常见的做法,是按行政区划铺开站群——每个省、市、区县一个独立子域,各自有一套标题、正文和内链。

站点规模算下来:35 个省级行政区 + 333 个地级市 + 下辖区县,最终启用 3,281 个站。每个站约 42 个页面,合计约 13.7 万个页面(后台统计 137,802)。

如果按传统方式生成静态文件:13.7 万 × 平均 40 KB ≈ 5.5 GB,还不含图片。更新一次全站电话,就要重新生成 5.5 GB 并全量同步。这条路在单机上走不通。

项目概况

项目 说明
站点规模 3,281 个地区站,约 13.7 万个页面
运行环境 2 核 / 3.5 GB,单机 nginx + 单进程
业务数据 11 个产品、3 个产品栏目、8 个案例、10 条资讯、16 条问答
技术栈 ASP.NET Core + SQLite + nginx
状态 长期维护

三个必须先回答的问题

  1. 站点怎么放:3,281 套模板和文件,还是 1 套模板按域名渲染?
  2. 每页怎么不一样:正文如果相同,搜索引擎会判定镜像站,整站降权。
  3. 爬虫怎么观测:搜索引擎和 AI 到底来没来,不能靠登录后台才看得到。

北京省站首页

图 1:同一个进程渲染出的省站首页,标题、正文、发货时效都随地区变化(联系人已打码)

主站首页

图 2:主站首页没有地区变量,和 3,281 个子域共用一个进程(联系人已打码)

目标.

  • 一个进程、一个数据库承载 3,281 个站,不生成静态文件
  • 每个站的标题、正文、内链、结构化数据都吃到地区变量
  • 后台改完内容即时生效,不重新生成、不全量同步
  • 2 核机器上,服务端渲染保持毫秒级
  • 爬虫访问可观测,运维不登录后台也能看

请求路径

图 3:请求路径——泛解析域名经 nginx 进入同一个 Kestrel 进程,按 Host 解析地区;未知子域直接 404

执行阶段.

STEP 01 收敛:1 个站点 + 3,281 个 Host

站与站的差别,本质上只有「地区变量」这一层。既然如此,就不该在磁盘上把它们分开:

DNS:    *.example.com                         → 同一个 IP
Nginx:  server_name example.com *.example.com → 同一个 upstream
应用:   读 Host 头 → 解析地区 → 同一套模板渲染

一个 ASP.NET Core 进程监听 127.0.0.1:5080,nginx 把泛域名全部转给它。Host 解析是一个很短的中间件:

var host = ctx.Request.Host.Host;
var region = locator.Resolve(host, _mainDomain, out var isMain);

// 未知子域直接 404,不回落主站
if (!isMain && region == null && !isAdminPath)
{
    ctx.Response.StatusCode = 404;
    return;
}

这条 404 是整个设计的地基。很多站群栽在这里:子域写错一个字母,返回的是主站首页,搜索引擎于是收录了一堆内容相同的镜像页。

产品列表页

图 4:同一个模板渲染出的产品列表——面包屑、H1、产品名、侧栏都带地区(联系人已打码)

STEP 02 数据:启动时读一次,之后只读内存

3,281 条地区、11 个产品、3 个栏目、8 个案例、10 条资讯、16 条问答,加起来不到 2 MB。没有理由每个请求都查一次库。

启动时全量加载到内存快照,之后所有页面渲染都读内存:

public sealed class ContentSnapshot
{
    public IReadOnlyList<Region> Regions { get; init; }
    public IReadOnlyDictionary<string, Region> RegionByCode { get; init; }
    // ...
}

在这之上再叠一层按 Host 分键的输出缓存:

o.AddBasePolicy(b => b.SetVaryByHost(true).Expire(TimeSpan.FromMinutes(10)));

SetVaryByHost 是必须的——少了它,A 站的页面会被缓存后发给 B 站,这是站群最容易出的线上事故。

后台改完内容,重新加载快照即可,不需要重启,也不会出现「改了一半」的中间态。

STEP 03 日志:业务库和爬虫库分开

站群上线后要回答一个运维问题:搜索引擎和 AI 爬虫到底有没有来?

爬虫日志和业务数据的特征完全相反:

业务数据 爬虫日志
写入频率 人工、低频 爬虫洪峰、高频追加
生命周期 永久 30 天滚动删除
查询方式 全量读 按时间范围聚合

放在同一个 SQLite 文件里,结果是文件被日志撑大、WAL 频繁 checkpoint、前台渲染跟着变慢。所以拆成两个库:

Data/xiongshun.db    业务库  1.5 MB
Data/crawl.db        日志库  独立增长,30 天 / 20 万条封顶

写入路径做成异步——请求线程绝不碰数据库:

请求 → UA 识别 → 有界 Channel 入队 → 立即返回
                     ↓
     后台服务每 2 秒或攒够 200 条批量 INSERT

队列容量 2 万条,满了丢最旧的一条。爬虫洪峰打不垮站点,最多丢几条日志。

后台爬虫记录

图 5:后台按站点聚合爬虫访问,能看出哪个站被爬得多、哪只爬虫来得勤(账号名已打码)

站点多了以后,运维不可能每次都登录后台,所以另做了一个免登录的只读报表页:URL 带 32 位随机令牌,令牌不对一律 404(不是 403,避免暴露页面存在),页面本身不含任何后台入口,支持 15 / 30 / 60 秒自动刷新。

免登录爬虫报表

图 6:免登录只读报表,手机能看、可按站点下钻

STEP 04 内容:让每一页真的不一样

3,281 个站如果用同一份正文,价值是负的。所以模板里的每个可变位置都必须吃到地区数据:

位置 变量来源
标题 {region}石笼网厂家 / 格宾网仓储配送商 / 雷诺护垫现货仓储
发货时效 该地区的 DestinationHours(北京 24h、偏远地区 48h)
常用规格 该地区的 LocalSpecs
主要工程类型 该地区的 LocalIndustry
服务区域 省站 = 下辖市列表;区县站 = 所属市区县列表
应用领域 9 类场景,每类都带地区前缀

地区表是这个系统的核心表,不是一张「名称字典」:

Regions
  Code               -- 拼音短码,同时是子域名:beijing / dongcheng / qiandongnan
  Name / FullName    -- 「东城」/「北京东城」
  Level              -- Province / City / County
  ParentCode         -- 层级关系
  DestinationHours   -- 到货时效
  LocalSpecs         -- 本地常用规格
  LocalIndustry      -- 主要工程类型
  IsEnabled          -- 停站开关
  IsIndexable        -- noindex 开关

一个真实的坑:地名规范化

用公开行政区划数据生成地区表时,踩了一个很隐蔽的问题:本意是处理「神农架林区」,结果把带「林区」「矿区」二字的地名全吃了。

北林区   → 北      子域名 bei     ←「北石笼网厂家」不是任何地区词
碑林区   → 碑      子域名 bei
万柏林区 → 万柏    子域名 wanbai

共有 112 个区县名被压成单字。修法不是打补丁,而是把「通名剥离」的规则重写:

  1. 只剥尾部单字(市 / 县 / 区 / 旗 / 盟)
  2. 「林区 / 矿区」这类通名用白名单点名处理(神农架林区 → 神农架、井陉矿区 → 井陉)
  3. 剥离后长度小于 2,说明它本身就是完整地名,回退原名(宾县 → 宾县,不是「宾」)
  4. 「城区 / 郊区 / 矿区」单独构不成地区词,自动补市名(阳泉城区)
  5. 开发区 / 产业园不是行政区划,直接不建站(94 个)

省站域名则用显式覆盖表,因为拼音库处理不了多音字:

重庆 chongqing   陕西 shaanxi   山西 shanxi
厦门 xiamen      六安 luan      乐山 leshan

省站域名直接影响 SEO,不能交给自动拼音。

结构差异也要落到页面上

地区变量填进标题很容易,填进页面结构才是难点。以产品详情页为例,同一个模板在 3,281 个站上要生成:

  • H1 带地区前缀(北京石笼网箱 2×1×1m)
  • 元信息里出现「所在地区:北京」
  • 产品特点列表
  • 6 张带图注与说明的实拍图
  • 规格参数表
  • 「结构与施工要点」段落开头带地区导语
  • 同分类相关产品也带地区前缀

产品详情页

图 7:产品详情页——H1、所在地区、相关产品都随站点变化(联系人已打码)

内链:站群最容易做成孤岛的地方

13.7 万个页面如果互不相连,搜索引擎只能靠 sitemap 慢慢发现,权重也传不过去。内链做了三层:

层级 位置 链接规模
全局 页脚「城市分站」 34 个省级行政区,每个站都有
索引 /city/ 3,281 条子域链接,按省分组、按市分行
局部 同分类产品 / 相关案例 / 侧栏热门 每页 6–12 条

关键点:每个站点的 /city/ 都是全量索引,不是只列本地。这样任何一个页面到任意其他页面,最多两跳。

页脚城市分站

图 8:每个页面页脚都带 34 个省级入口(联系人、邮箱已打码)

全国地区索引

图 9:全国索引页输出 3,281 条子域链接,按省分组、按市分行

STEP 05 面向 AI 抓取

站群如果只优化给搜索引擎,会漏掉增长最快的一类流量:AI 助手的答案引用。豆包、DeepSeek、Kimi、元宝、通义、智谱、GPTBot、PerplexityBot、ClaudeBot 抓取页面的方式和 Google 有两处根本不同:

1. 按片段检索,不按页面。 AI 把页面切成 200–300 字的块建索引,用户问「北京石笼网厂家哪家好」,命中的可能是页面中间某一小段。所以地区词不能只写在标题和 H1 里,必须均匀分布到每个段落。

2. 依赖 llms.txt 这类显式声明。 每条站独立生成一份站点摘要:

# 北京石笼网厂家 · 雄顺丝网

> 北京地区石笼网、格宾网、雷诺护垫现货供应,执行 YB/T 4190…

## 产品
- 石笼网箱 2×1×1m:网孔 80×100mm,热镀锌丝径 2.7mm…

## 联系方式
- 服务范围:北京及下辖 16 个区县,24 小时内安排发货

配合结构化数据,AI 能直接拿到可核验的事实:

{
  "@type": "LocalBusiness",
  "name": "北京石笼网厂家(雄顺丝网)",
  "legalName": "安平县雄顺丝网制造有限公司",
  "areaServed": ["北京", "北京东城", "北京西城", "..."],
  "address": { "addressRegion": "河北", "addressLocality": "衡水" }
}

这里有一个取舍:name 是地区化的,legalName 和 address 是真实的。 地区化是为了匹配本地搜索意图;真实地址是为了可核验——AI 和工商信息对得上,才敢引用。编造本地工厂地址属于虚假宣传,对方一实地核验就崩。

STEP 06 几个容易被忽略的工程细节

  • Cookie 的 Path:后台认证 Cookie 从 Path=/ 改成 Path=/admin,公开页请求完全不带登录态。站群场景下这个细节被放大 13.7 万倍。
  • 输出缓存必须排除后台:输出缓存会丢弃 Set-Cookie,后台登录页一旦进缓存,防伪令牌必然校验失败,登录永远 200 不跳转。
  • nginx 的 add_header 不继承:子级只要定义了自己的 add_header,父级全部失效,表现为首页抓不到 HSTS、错误页却有。
  • 反向代理要传 X-Forwarded-Proto:只传 X-Real-IP 的话,应用会以为自己在 HTTP 上跑,Secure Cookie 和 HSTS 都不会生效。
  • 登录限流要按真实 IP:在反向代理后面,RemoteIpAddress 全是 127.0.0.1,一个人爆破能把全站锁死。
  • CSP 与内联事件:严格 CSP 会拦掉 onerror 兜底,改成 data- 属性 + 外部脚本统一处理。
  • Kestrel 只监听 127.0.0.1:否则客户端可以伪造 X-Forwarded-For,绕过登录限流。

成果与证据.

服务端渲染耗时

所有数据来自线上环境:2 核 / 3.5 GB、单机 nginx + 单进程 Kestrel + SQLite。

页型 首字节 体积 正文(中文)
首页 2.1 ms 65 KB 6,468 字
产品列表 2.0 ms 28 KB 1,453 字
栏目页 1.6 ms 42 KB 4,360 字
产品详情 2.6 ms 38 KB 3,251 字
问答页 2.2 ms 40 KB 5,906 字
全国索引 2.4 ms 383 KB 8,194 字

全国索引页要输出 3,281 个链接、383 KB HTML,服务端 2.4 ms 完成——因为链接全部来自内存快照,零数据库查询。

渲染耗时

图 10:各页型服务端渲染耗时与页面体积

并发压测(含公网 RTT 与 TLS 握手)

请求总数 : 200(并发 10)
成功     : 200 / 200
吞吐     : 194.3 req/s
延迟 avg : 36.3 ms
     p50 : 22.6 ms
     p95 : 233.3 ms

p95 偏高是公网 RTT 与 TLS 握手造成的,不是服务端瓶颈——服务端渲染本身在毫秒级。

资源占用

进程内存   约 130 MB
业务库     1.5 MB
爬虫库     独立,30 天 / 20 万条封顶

3,281 个站的完整内容,1.5 MB。

后台仪表盘

图 11:后台仪表盘:3,281 个站、13.7 万个页面,业务数据与爬虫日志分开统计(账号名已打码)

已知边界

  • SQLite 是单写者。后台写入与前台读取在极端并发下会有锁竞争;本场景写入是人工操作、频率极低,没有影响。
  • 输出缓存在内存里,进程重启后需要重新预热(实测首次请求 30 ms 内完成)。
  • 3,281 个站点的 sitemap 需要按需分包生成,一次性输出会超过搜索引擎 5 万条的上限。

问题与复盘.

三个判断

这套系统的核心不是「用了什么框架」,而是三个判断:

  1. 站点的差别在数据,不在文件 —— 所以不生成静态站点,一个进程 + Host 解析。
  2. 数据量小到可以全进内存 —— 所以放弃每次查库,渲染做到毫秒级。
  3. 写多读少的日志必须分库 —— 所以业务库永远保持 1.5 MB。

适合与不适合

适合 不适合
长尾词按行政区划 / 行业细分铺开 每个站需要完全独立的内容运营和设计
内容结构统一,地区间差异主要在变量 需要独立部署、独立发布的多团队协作
服务器预算有限(单机 2 核即可) 站点数量几十个以内——静态生成更简单

这个项目体现了哪些能力

能力 在本案例中的体现
架构设计 单进程 + Host 解析承载 3,281 个站;业务库与日志库分离
性能优化 内存快照 + 输出缓存;服务端渲染 1.5–2.6 ms
数据治理 地区表建模;112 个被压成单字的地名修复;多音字显式覆盖
SEO / GEO 每页地区化变量、三层内链、llms.txt、LocalBusiness 结构化数据
运维可观测 爬虫异步落库、按站点聚合、免登录令牌报表
工程细节 Cookie Path、缓存排除后台、反向代理头、登录限流、CSP

规模本身不稀奇,稀奇的是用正确的抽象把规模消化掉。

有类似的问题?

说明你的行业、现状和目标,我会判断哪些能做、哪些不该做。