引言
在跨境电商选品和竞品监控中,Lazada 的商品页包含标题、价格、评分、库存等结构化字段,是比价与趋势分析的数据来源。爬虫把页面拉到内存只是流程的前半段:内存中的数据进程结束后即丢弃,无法支撑跨周期的查询、去重和增量更新。本文描述一条从采集到落库的完整链路,使用 PHP 作为采集与写入语言,MySQL 作为存储,全文覆盖表结构设计、代理接入、字段解析、批量 upsert 与容错策略。
一、为什么采集后必须落库
爬虫输出停留在内存或临时 CSV 时,每一次分析都要重新发起请求,既消耗带宽,又放大触达风控的概率。落库要解决三个具体的工程问题:
首先是幂等性。同一商品会被周期性重复抓取,写入逻辑必须保证重复执行不产生重复行,否则后续统计会被脏数据污染。
其次是可查询性。价格曲线、库存波动、类目分布都需要按时间维度和商品维度做聚合,关系型数据库比文件系统更胜任这件事。
最后是可追溯性。保留每条记录的抓取时间和来源站点,出现解析异常时能定位是哪一次采集引入了错误字段。
MySQL 在百万级商品规模下,配合合理索引和批量写入,吞吐和运维成本都可控,适合作为该类项目的主存储。
二、Lazada 的访问限制与亿牛云隧道代理
Lazada 对来源 IP 的请求频率敏感。单 IP 短时间高频访问会触发 403、验证码页或按 IP 的限流,部分接口还会校验 X-CSRF-TOKEN 之类的动态参数。直连采集几千条商品后失败率通常明显上升。
亿牛云(16yun)提供面向采集场景的代理产品,其中隧道代理(Tunnel)适合本场景:业务侧只配置一个固定的入口地址,由亿牛云在后端自动轮换出口 IP,开发者不需要自建 IP 池或做可用性探活。对 Lazada 这类按 IP 限流的站点,分散出口能直接降低单一 IP 被封的概率。
接入方式是带账密鉴权的代理 URL:
凭证应放在环境变量或配置文件中,不要硬编码进源码。需要留意两点:隧道代理相比直连会引入额外延迟,QPS 规划时要预留这部分开销;亿牛云按套餐限制并发通道数,采集并发度不能超过套餐上限,否则请求会在代理层排队。
三、MySQL 表结构设计
商品数据兼有宽表属性和缓慢变化特征:标题、类目相对静态,价格、库存频繁变动。设计两张表,主表存最新快照,历史表存价格时序。
```CREATE TABLE lazada_products (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id VARCHAR(32) NOT NULL COMMENT 'Lazada 商品ID',
title VARCHAR(512) NOT NULL DEFAULT '',
seller_name VARCHAR(128) NOT NULL DEFAULT '',
price DECIMAL(12,2) NOT NULL DEFAULT 0,
original_price DECIMAL(12,2) NOT NULL DEFAULT 0,
currency CHAR(3) NOT NULL DEFAULT 'USD',
rating DECIMAL(3,2) NOT NULL DEFAULT 0,
review_count INT UNSIGNED NOT NULL DEFAULT 0,
stock INT NOT NULL DEFAULT 0,
category VARCHAR(128) NOT NULL DEFAULT '',
image_url VARCHAR(1024) NOT NULL DEFAULT '',
url VARCHAR(1024) NOT NULL DEFAULT '',
country CHAR(2) NOT NULL DEFAULT 'SG' COMMENT '站点国家',
last_crawled DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_item (item_id, country)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE lazada_price_history (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
item_id VARCHAR(32) NOT NULL,
price DECIMAL(12,2) NOT NULL,
crawled_at DATETIME NOT NULL,
KEY idx_item (item_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计要点说明。字符集用 utf8mb4 而不是 utf8,因为 Lazada 商品标题会出现 emoji 和超出基本多文种平面的字符,utf8 会截断。金额用 DECIMAL 而非 FLOAT,避免二进制浮点带来的精度误差,价格比较和汇总才有确定结果。uk_item 以 item_id 加 country 作为唯一约束,是因为同一商品 ID 在不同国家站点(sg、my、ph 等)是不同货品,需要区分后各自 upsert。
四、PHP 采集与字段解析
Lazada 商品页是前端渲染的 SPA,核心数据放在页面的 __INITIAL_STATE__ 这个内联 JSON 状态块里,比解析 DOM 更稳定,因为 DOM 结构随改版变动更频繁。先安装依赖:
composer require guzzlehttp/guzzle symfony/dom-crawler symfony/css-selector
采集与解析代码:
```<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use Symfony\Component\DomCrawler\Crawler;
$proxy = sprintf(
'http://%s:%s@tunnel.16yun.cn:3100',
getenv('YINIU_USER'),
getenv('YINIU_PASS')
);
$client = new Client([
'proxy' => $proxy,
'timeout' => 15,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept-Language' => 'en-US,en;q=0.9',
],
]);
function fetchProduct(Client $client, string $url): array
{
$html = $client->get($url)->getBody()->getContents();
$crawler = new Crawler($html);
$script = $crawler->filter('script:contains("__INITIAL_STATE__")')->first()->text();
preg_match('/__INITIAL_STATE__\s*=\s*(\{.*?\});/', $script, $m);
$data = json_decode($m[1], true);
$p = $data['mods']['pageProduct'] ?? [];
return [
'item_id' => $p['itemId'] ?? '',
'title' => $p['title'] ?? '',
'price' => ($p['price'] ?? 0) / 100, // Lazada 金额以最小货币单位存储,需除以 100
'seller_name' => $p['sellerName'] ?? '',
'rating' => $p['ratingScore'] ?? 0,
'review_count'=> $p['review'] ?? 0,
'stock' => $p['stock'] ?? 0,
'image_url' => $p['image'] ?? '',
'url' => $url,
];
}
Guzzle 的 Client 实例复用连接,避免每条请求重建 TCP 握手。经隧道代理发出的请求由亿牛云轮换出口,配合真实 UA 和语言头,能通过 Lazada 的基础反爬校验。解析时需要注意,Lazada 接口返回的金额以最小货币单位(分)计,必须除以 100 还原成元,否则落库价格会放大一百倍。
五、数据清洗与批量 upsert
写入前先做清洗:修剪首尾空白、过滤 item_id 为空的记录、校验价格非负。然后用 INSERT ... ON DUPLICATE KEY UPDATE 做 upsert,命中 uk_item 时更新快照字段,未命中时插入新行:
$pdo = new PDO(
'mysql:host=127.0.0.1;dbname=lazada;charset=utf8mb4',
getenv('DB_USER'), getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
function persist(PDO $pdo, array $row, string $country)
{
$now = date('Y-m-d H:i:s');
$sql = "INSERT INTO lazada_products
(item_id, title, seller_name, price, currency, rating, review_count, stock, image_url, url, country, last_crawled)
VALUES (?,?,?,?, 'USD', ?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE
title=VALUES(title), price=VALUES(price), rating=VALUES(rating),
review_count=VALUES(review_count), stock=VALUES(stock),
last_crawled=VALUES(last_crawled)";
$pdo->prepare($sql)->execute([
$row['item_id'], $row['title'], $row['seller_name'],
$row['price'], $row['rating'], $row['review_count'],
$row['stock'], $row['image_url'], $row['url'], $country, $now
]);
// 仅当价格相对上次发生变化时记录历史,避免历史表被重复值撑大
$prev = $pdo->prepare("SELECT price FROM lazada_price_history WHERE item_id = ? ORDER BY crawled_at DESC LIMIT 1");
$prev->execute([$row['item_id']]);
if ($prev->fetchColumn() != $row['price']) {
$pdo->prepare("INSERT INTO lazada_price_history (item_id, price, crawled_at) VALUES (?,?,?)")
->execute([$row['item_id'], $row['price'], $now]);
}
}
参数化查询(? 占位符)是强制项,不能直接拼接 $row 到 SQL 字符串,否则商品标题中的特殊字符会破坏语句,甚至引入注入风险。价格历史只在价格相对上次记录发生变化时才写入,否则每天的全量巡检会把历史表撑成大量重复行,拖慢按 item_id 的检索。
批量场景用事务包裹,每累积 500 行提交一次,比逐条 autocommit 减少磁盘 fsync 次数,写入吞吐可提升数倍。注意事务不要开得过大,否则长事务会占用 undo 日志并阻塞备份。
六、性能优化与容错
连接复用。PDO 可加 PDO::ATTR_PERSISTENT => true 复用数据库连接,Guzzle Client 实例在循环外创建,两者都避免重复建连的开销。
并发与限流。Guzzle 的异步 Promise 或 Swoole 协程能把串行等待改成并发,但并发度要受亿牛云套餐通道数和 Lazada 频率限制的双重约束,不能只看本机 CPU。
失败重试。对 403 和超时做指数退避重试,间隔取 1s、2s、4s,并加入随机抖动避免多个任务同步重试形成尖峰。超过重试上限的记录写入 failed_queue 表,由独立任务延后补偿,不阻塞主流程。
校验与隔离。入库前校验 item_id 非空、price >= 0、评分在合理区间。不合规则的记录进异常表,不写主表,保证主表数据可直接用于分析。
增量调度。按 last_crawled 排序,只抓取超过设定阈值(如 24 小时)未更新的商品,整体请求量随之下降。
结语
从页面解析到 MySQL 落库,链路的每个环节都有明确的工程约束:字符集决定能否完整存下标题,DECIMAL 决定金额是否可信,唯一键决定能否安全 upsert,代理决定采集能否持续。把这些约束前置到表结构和代码里,后续的比价、预警和趋势分析才站得住脚。