文章探讨了在将单体数据库拆分为ShardingSphere分库分表集群后,导致搜索引擎收录与排名暴跌的问题。分析了因缺乏分片键导致全库广播,大量数据归并处理引发的资源耗尽与超时,导致蜘蛛抓取频繁失败,影响搜索排名。
两个月前,我们把文章主表从单台 MySQL 实例整体拆分到了基于 ShardingSphere-Proxy 的分库分表集群。当时主表的数据量刚刚迈过三千五百万行,单表索引文件膨胀到四十多吉字节,早晚高峰期的慢查询日志里塞满了各种扫描范围稍大一点的 SQL,数据库服务器的 CPU 使用率动不动就飙到 85% 以上。
拆分方案在测试环境压测得很漂亮。我们按业务的主键取模,把主表分到了 4 个物理 MySQL 实例、共计 32 张物理分片表中。上线第一周,内部接口的写吞吐量翻了三倍,按主键查询的 p99 延迟稳定在十几毫秒,大家都以为这次重构稳妥收工了。
结果第二周周一早上一查监控,百度搜索资源平台和 Google Search Console 里的抓取曲线几乎成了垂直跳水。两周时间里,站点的总索引量蒸发了将近十八万,百度蜘蛛的日抓取频次从每天二十多万次直接跌到不足五万次。更要命的是,抓取状态码统计里冒出了超过 34% 的 HTTP 504 Gateway Timeout,剩下的 200 响应里还混着大量的空数据和重复快照,核心长尾词的排名直接被甩出了前五页。
一个面向企业和普通读者的公开资讯站点,数据库层面承载高并发的改动,怎么会把前端搜索引擎的收录砸穿?我们抓了整整三天的 Nginx 访问日志与 ShardingSphere 的执行计划,才把这套分布式改造里的连环暗坑彻底理顺。
坑一:公共列表查询缺分片键,全库广播把 Proxy 内存与连接池直接打穿
分库分表的第一条铁律就是查询必须带分片键(Sharding Key)。我们的分片键选在了用户与租户标识上,用户在个人后台翻看自己的历史文章时,SQL 语句自带 WHERE user_id = ?,ShardingSphere 算完哈希直接路由到单台物理库的某张分片表,几毫秒就能返回。
公开页面的流量完全是另一套逻辑。
搜索引擎蜘蛛顺着全站的链接网络到处爬,抓取的目标是 /articles?category=tech&page=80 或者 /archive?year=2026&page=120。这类公共列表的 SQL 语句通常长这样:
SELECT id, title, summary, author_id, category_id, create_time
FROM t_article
WHERE status = 1
ORDER BY create_time DESC
LIMIT 2000, 20;
这条查询里没有任何分片键。ShardingSphere 拿不到哈希目标,只能把这条 SQL 转换成全库广播(Broadcast Routing),分发给所有 4 个数据库实例下的全部 32 张分表分别执行。
广播本身消耗资源,但真正致命的是分布式分页的底层实现机制。
在单体 MySQL 里,LIMIT 2000, 20 只需要走索引向后跳过两千行记录,再取二十行。在分库分表环境下,ShardingSphere 事先不知道这最新的二十条记录到底分散在哪个分片里。为了保证排序的绝对准确,代理层必须把 SQL 改写成向每个物理分片要前两千零二十条数据:
-- ShardingSphere 改写后发往各个物理分片的 SQL
SELECT id, title, summary, author_id, category_id, create_time
FROM t_article_0
WHERE status = 1
ORDER BY create_time DESC
LIMIT 0, 2020;
32 张分片表,每张分片表返回 2020 条完整数据,总计六万四千多行记录。这些记录通过网络集中涌入 ShardingSphere-Proxy 节点的堆内存,由代理层的排序归并引擎在内存中做合并排序,最后把前两千行丢弃,只把最后的二十行吐给上游应用。
如果页面翻到第五百页(LIMIT 10000, 20),单次查询就会向底层各个物理库索要超过三十二万行记录。
正常用户的浏览器很少会一口气点到几十甚至上百页,搜索蜘蛛的爬虫程序却不讲客气。Baiduspider 常常开着十几个并发线程沿着分页条死命往下扫。几轮并发下来,ShardingSphere-Proxy 的 JVM 老年代内存直接被占满,频繁触发十秒以上的 Full GC 停顿,上游 Nginx 的 proxy_read_timeout 默认只有 5 秒,超时直接断开连接并向爬虫吐出 504 错误。
搜索引擎蜘蛛发现抓取一个页面有三分之一的概率拿到 504 超时,算法会自动判定目标站点服务器过载或不可用,立即按比例收缩抓取配额(Crawl Budget)。没有了蜘蛛光顾,新文章得不到收录,老文章的死链得不到刷新,排名自然断崖式下滑。
坑二:非唯一字段排序在多节点归并时产生游标漂移,快照大面积撕裂与内容重复
熬过 504 的页面,抓取结果同样千奇百怪。
我们在百度资源平台的网页诊断里随机抽查了几个收录快照,发现大量的列表页快照里出现了相同的内容,有的文章在第 15 页出现过,翻到第 16 页又占了半个版面;还有几百篇权重很高的核心文章在列表页里凭空消失了。
问题的根源出在排序字段的选择上。原先的 SQL 排序规则是 ORDER BY create_time DESC。在批量导入或者编辑发布密集的场景下,多篇文章的发布时间可能在同一秒内,create_time 字段的值完全一样。
在单机 MySQL 里,相同时间戳的数据通常按照存储引擎的物理聚簇索引顺序输出,每次查询出来的结果相对固定。分库分表之后,32 个分片的数据是通过独立的 TCP 链接并发传回给代理层的。当两个不同物理分片里各有一条时间戳一模一样的记录时,ShardingSphere-Proxy 在做多路归并排序时无法判断先后顺序,哪个分片的数据包先到网络缓冲区,哪条数据就排在前面。
网络抖动只要差几毫秒,不同批次的返回顺序就会完全调换。蜘蛛抓取第 15 页时,某篇文章排在第 300 位;等到两秒后抓取第 16 页,由于底层数据包到达顺序微变,那篇文章又掉到了第 301 位,再次被爬虫抓了一遍。
这就造成了严重的分页快照漂移。爬虫抓取到的页面充满了高度相似的重复条目,同时原本应该排在第 301 位的文章被挤掉,导致那篇文章从未在列表页曝光,彻底成了没有内链导入的孤岛页面。百度算法对这类重复页面判定为低质采集内容,对整批 URL 进行了降权甚至清退。
坑三:跨库多表关联查询退化与静默容错,让正文页核心元素批量脱水
除了列表页,单篇文章的正文页也遭了殃。
单体架构时代,一篇文章的详情页通常需要挂载文章的分类名称、作者昵称以及关联的标签列表。以前是一条 LEFT JOIN 把 t_article、t_category、t_author 和 t_tag 一网打尽:
SELECT a.*, c.name AS category_name, u.nickname, t.name AS tag_name
FROM t_article a
LEFT JOIN t_category c ON a.category_id = c.id
LEFT JOIN t_user u ON a.author_id = u.id
LEFT JOIN t_article_tag at ON a.id = at.article_id
LEFT JOIN t_tag t ON at.tag_id = t.id
WHERE a.id = 100258;
分库之后,t_article 拆成了 32 张分表,而 t_category 和 t_tag 这类公共字典表当时由于数据量小,只有几千行,被随手放在了默认的第一个物理主库上。
在 ShardingSphere 中,只要涉及跨不同数据源、未绑定分片规则的表关联,SQL 就无法直接下推到物理库执行。这种跨库 JOIN 在早期的中间件里要么直接报错,要么在代理层拉取笛卡尔积。
研发团队为了避免上线报错,在业务层把这条大 SQL 拆成了四次独立查询,并对关联字典表的部分加上了异常捕获。一旦跨库调用在网络拥堵时耗时超过 500 毫秒,底层微服务就自动触发降级,把分类和标签字段替换为空数组。
代码表面上跑得很稳,前端页面在浏览器里依然能吐出 200 响应。实际上,每当蜘蛛在高并发窗口期访问详情页时,拿到的 HTML 源码里没有面包屑导航,没有分类外链,底部的标签网络也空空如也,只剩下一段孤零零的正文。
面包屑和标签是全站内链权重传递的关键枢纽。当成千上万个页面的内链结构因超时降级而集体消失时,整站的网状权重流动直接断开,页面的主题相关性评分断崖式下挫。
治本方案:游标分页、广播表与 CQRS 读模型隔离
找到病根之后,我们用了四天时间彻底重构了公网访问的底层数据通路。针对搜索引擎与公共访问的场景,单纯堆机器或者调大超时时间无济于事,必须把架构层面的职责剥离清楚。
1. 彻底废弃 OFFSET,改用组合游标分页
针对公共列表的深度扫描,我们彻底禁止了带 OFFSET 的分页语句,改用基于唯一排序键的 Keyset Pagination(游标分页):
-- 优化前:分片需要各自拉取 0 到 2020 行
SELECT * FROM t_article
WHERE status = 1
ORDER BY create_time DESC
LIMIT 2000, 20;
-- 优化后:利用复合索引直接定界,各分片永远只取 20 行
SELECT * FROM t_article
WHERE status = 1
AND (create_time < :last_time OR (create_time = :last_time AND id < :last_id))
ORDER BY create_time DESC, id DESC
LIMIT 20;
我们在各物理表上建立了 idx_status_time_id (status, create_time DESC, id DESC) 联合索引。
由于每一页都携带上一页最后一条记录的 create_time 和唯一的 id 作为界限,每个分片表只检索符合条件的头 20 条记录。32 个分片加起来最多向代理层推送 640 行数据,无论蜘蛛爬到第十页还是第一千页,内存开销和网络传输量都是恒定的常数级。
加入唯一的 id DESC 作为次级排序键后,时间戳相同的数据拥有了确定性的先后顺序,各分片归并时的漂移彻底消失,翻页快照完全稳定。
对于搜索引擎需要识别的翻页链接,前端 URL 改造成 /articles?cursor=eyJ0IjoxNzMxMjM0NTY3LCJpZCI6ODkyMzF9。针对历史上已经被收录的传统 /articles?page=N 链接,我们在服务端做了带缓存的游标映射,首次访问时通过缓存快速解析位置,同时输出规范网址标头:
add_header Link '<https://www.llbbs.cn/articles?cursor=...>; rel="canonical"';
2. 元数据表配置为广播表(Broadcast Table)
针对分类、标签、系统配置这类只读频率极高、修改频率极低的小表,坚决不能让它们独立分散在单个库上。
我们在 ShardingSphere 的配置里将它们显式声明为广播表:
rules:
- !SHARDING
tables:
t_article:
actualDataNodes: ds_${0..3}.t_article_${0..7}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: article_inline
broadcastTables:
- t_category
- t_tag
- t_article_tag_rel
声明为广播表后,ShardingSphere 在处理这几张表的写入时,会自动将数据同步复制到集群内的全部 4 个物理数据库实例中;而在读取这几张表时,可以在当前物理库本地直接与分片表做原生的 Local JOIN,消灭了跨网络查询和内存拼接开销,正文页的分类和标签再也没有出现过超时脱落的情况。
3. 公网爬虫流量与交易分片集群物理隔离
分库分表是典型的为写优化、为事务隔离设计的方案。把公共读流量、特别是搜索引擎爬虫这种无序且贪婪的扫描流量直接怼到分片代理上,本身就是架构上的错位。
我们引入了 CQRS 架构思想,把面向公网的读模型与底层的写集群完全隔开:
[后台写操作 / 业务写事务]
│
▼
[ShardingSphere 分库分表集群 (4 DBs / 32 Shards)]
│
▼ (Canal / Debezium 监听 Binlog)
[Kafka 变更事件队列]
│
▼ (消费拉平与聚合拼装)
[只读聚合数据库 (Elasticsearch / 单体只读 MySQL)]
▲
│
[公网只读 API / 爬虫渲染服务]
▲
│
[Nginx + 爬虫专属 micro-cache]
通过 Canal 监听底层分库的 binlog,把文章数据、分类数据与标签数据扁平化成完整的聚合文档,同步投递到专门的公开文章库。
所有无需登录鉴权的公共访问全部切到这个聚合库上,单次查询从 32 个分片的复杂跨网络归并,变成了基于高效索引的单点读取,平均响应时间从原本的 800 多毫秒压到了 12 毫秒以内。
4. Nginx 针对搜索引擎蜘蛛配置边缘微缓存
即使有了只读库,为了防止爬虫突发抓取打乱后端连接,我们在 Nginx 层为搜索引擎蜘蛛配置了独立的内存缓存:
# 识别合规蜘蛛,单独标记缓存策略
map $http_user_agent $is_spider {
default 0;
~*(Baiduspider|Googlebot|bingbot|Sogou) 1;
}
proxy_cache_path /dev/shm/nginx_spider_cache levels=1:2 keys_zone=spider_cache:50m max_size=1g inactive=30m use_temp_path=off;
server {
listen 443 ssl http2;
server_name www.llbbs.cn;
location / {
proxy_pass http://public_read_backend;
# 仅针对爬虫开启 10 分钟短缓存
proxy_cache spider_cache;
proxy_cache_bypass $cookie_nocache;
proxy_no_cache $cookie_nocache;
proxy_cache_valid 200 304 10m;
proxy_cache_valid 404 1m;
proxy_cache_key "$scheme$request_method$host$request_uri";
# 爬虫并发限制,防止突发扫盘
limit_req zone=spider_limit burst=30 nodelay;
proxy_connect_timeout 3s;
proxy_read_timeout 5s;
add_header X-Cache-Status $upstream_cache_status;
}
}
把缓存放在 Linux 的共享内存目录 /dev/shm 下,蜘蛛并发爬取同一批分页或者文章时,92% 以上的请求直接在 Nginx 层面毫秒级返回,后端的只读库连 CPU 波动都看不见。
上线这套整改方案之后,我们把修复后的 URL 结构通过 API 接口重新推送到百度搜索资源平台。
第三天下午,Nginx 日志里的 504 错误彻底归零。一周后,百度蜘蛛的日均抓取曲线回升到了三十五万次以上,此前因快照撕裂掉落的十八万页面重新被索引收录,主要技术关键词的排名在第十天基本回到了改版前的水准。
数据库架构的演进很容易让人沉浸在吞吐量、分片算法和物理节点的数字提升里。但对对外提供公共内容的站点而言,任何改动只要动了查询形态和响应时序,最先被测试出来的往往是敏感脆弱的搜索引擎收录。在把单体表拆开之前,先想清楚那些不带分片键的公共读请求到底该往哪里走。
文章标题:为了支撑千万级数据把单体数据库改造成分库分表(ShardingSphere),搜索收录与关键词排名却断崖式暴跌?排查广播路由跨库归并打爆超时致蜘蛛频报 504、深度分页游标乱序快照漂移与跨库关联缺失我踩过的几个深坑
文章链接:https://www.llbbs.cn/jishujaocheng/315.html
本站文章均为原创,未经授权请勿用于任何商业用途
评论一下?