php+最新的swoole OTA 升级分发服务
·
OTA(Over-The-Air)的核心矛盾:百万设备 + 大文件(几百 MB) + 灰度策略 + 防刷带宽 + 断点续传 + 安全签名 + 失败回滚。
---
一、整体流程(大白话)
【上传侧】
研发推固件包 →服务端计算 SHA256 + 签名 + 生成差分包 →存对象存储 →入库
【设备侧】
①设备启动 →携带 (device_id, model, current_version, region) 调 /ota/check
②服务端命中策略:
- 当前版本是否需要升级?
- 灰度白名单/百分比?
- 地域/机型/渠道匹配?
- 是否优先发差分包(节省 90% 带宽)?
③返回:升级包 URL + 大小 + SHA256 + 签名 + 版本号 + 强制/可选
↓
④设备走 /ota/download(支持 Range 断点续传)
- 服务端限流(令牌桶按 device 维度)
- 走对象存储 CDN 直连 / 服务端代理
↓
⑤设备本地校验 SHA256 + 数字签名(防中间人篡改)
↓
⑥设备安装重启 →调 /ota/report 上报结果
↓
⑦服务端汇总成功率,异常率 > 阈值 →自动暂停灰度
↓
⑧全量发布 / 紧急回滚
核心思想:策略匹配 + 差分包 + 断点续传 + 灰度自动熔断。
---
二、最佳技术选型
┌────────────┬──────────────────────────────────┬─────────────────────────────────────────────┐
│ 层 │ 库/技术 │ 原因 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 运行时 │ Swoole 6.x │ 协程 + sendfile 零拷贝 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 策略 DSL │ symfony/expression-language │ version < "1.2.3" and region in ["CN","HK"] │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 文件存储 │ league/flysystem + S3/OSS 适配器 │ 一套 API 切换本地/对象存储 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 大文件下载 │ Swoole Response::sendfile │ 零拷贝,几 G 文件无内存压力 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 差分包 │ bsdiff/bspatch(shell 调用) │ 业界标准,节省 80-95% 流量 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 设备认证 │ firebase/php-jwt │ 标准 JWT │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 签名 │ openssl_sign / Ed25519 │ 固件签名防篡改 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 限流 │ Redis Lua 令牌桶 │ 按 device_id 维度 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 热数据 │ Swoole\Table │ 版本/策略缓存 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 路由 │ nikic/fast-route │ 极快 │
├────────────┼──────────────────────────────────┼─────────────────────────────────────────────┤
│ 监控 │ Prometheus PHP │ 实时大盘 │
└────────────┴──────────────────────────────────┴─────────────────────────────────────────────┘
composer require swoole/ide-helper symfony/expression-language league/flysystem league/flysystem-aws-s3-v3
firebase/php-jwt nikic/fast-route monolog/monolog
---
三、完整代码
1. 入口:Server 启动
<?php
// server.php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
use App\OTA\Container;
use App\OTA\Router;
$c = Container::boot();
$server = new Server('0.0.0.0', 9503, SWOOLE_PROCESS);
$server->set([
'worker_num' => swoole_cpu_num() * 2,
'task_worker_num' => 8,
'task_enable_coroutine' => true,
'enable_coroutine' => true,
'max_request' => 100000,
'hook_flags' => SWOOLE_HOOK_ALL,
'open_http2_protocol'=> true,
'send_yield' => true, // 发包过快时自动让出协程
'socket_buffer_size'=> 128 * 1024 * 1024, // 大文件下载需要
'buffer_output_size'=> 128 * 1024 * 1024,
'package_max_length'=> 512 * 1024 * 1024,
'http_compression' => false, // 固件已压缩,别再压
'enable_static_handler' => false,
'upload_tmp_dir' => '/tmp',
'upload_max_filesize'=> '2G',
]);
$server->on('WorkerStart', function($s, $wid) use ($c) {
$c->initPools();
$c->loadVersions(); // 版本+策略加载到内存
$c->loadSigningKey(); // 加载固件签名私钥
});
$server->on('Request', function(Request $req, Response $res) use ($c, $server) {
(new Router($c, $server))->dispatch($req, $res);
});
// Task:异步统计上报、灰度自动熔断
$server->on('Task', function($s, $task) use ($c) {
(new \App\OTA\ReportProcessor($c))->handle($task->data);
});
$server->on('Finish', fn() => null);
$server->start();
解释:
- send_yield:发包速度超过客户端接收能力时自动协程让出,避免吃满内存
- socket_buffer_size 128M:大文件下载需要更大缓冲
- http_compression=false:固件包本身已经压缩(.zip/.bin),再 gzip 只会浪费 CPU
- open_http2_protocol:H2 多路复用,移动端弱网体验好
---
2. Container:连接池 + 内存表 + 签名密钥
<?php
// src/OTA/Container.php
namespace App\OTA;
use Swoole\Coroutine\Channel;
use Swoole\Coroutine\Redis;
use Swoole\Coroutine\MySQL;
use Swoole\Table;
use League\Flysystem\Filesystem;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use Aws\S3\S3Client;
class Container
{
public Table $versions; // 所有可分发版本(共享)
public Table $policies; // 灰度策略(共享)
public Table $grayStats; // 灰度成功率统计(共享)
public Channel $redisPool;
public Channel $mysqlPool;
public Filesystem $storage; // 对象存储
public string $signPrivKey;
public string $signPubKey;
public static function boot(): self
{
$c = new self();
$c->versions = new Table(4096);
$c->versions->column('app_id', Table::TYPE_STRING, 64);
$c->versions->column('version', Table::TYPE_STRING, 32);
$c->versions->column('build_no', Table::TYPE_INT, 8);
$c->versions->column('file_path', Table::TYPE_STRING, 256);
$c->versions->column('file_size', Table::TYPE_INT, 8);
$c->versions->column('sha256', Table::TYPE_STRING, 64);
$c->versions->column('signature', Table::TYPE_STRING, 512);
$c->versions->column('force', Table::TYPE_INT, 1);
$c->versions->column('min_from', Table::TYPE_STRING, 32); // 兼容的最低旧版本
$c->versions->column('release_notes',Table::TYPE_STRING, 2048);
$c->versions->column('status', Table::TYPE_STRING, 16); // gray/full/paused
$c->versions->create();
$c->policies = new Table(4096);
$c->policies->column('version_key',Table::TYPE_STRING, 96);
$c->policies->column('expression', Table::TYPE_STRING, 2048);
$c->policies->column('percent', Table::TYPE_INT, 4);
$c->policies->create();
$c->grayStats = new Table(4096);
$c->grayStats->column('attempt', Table::TYPE_INT, 8);
$c->grayStats->column('success', Table::TYPE_INT, 8);
$c->grayStats->column('failed', Table::TYPE_INT, 8);
$c->grayStats->create();
// 对象存储(可换 OSS/COS/MinIO,API 完全一致)
$s3 = new S3Client([
'version' => 'latest',
'region' => 'us-east-1',
'endpoint'=> 'http://127.0.0.1:9000', // MinIO 示例
'use_path_style_endpoint' => true,
'credentials' => ['key'=>'minio','secret'=>'minio123'],
]);
$c->storage = new Filesystem(new AwsS3V3Adapter($s3, 'ota'));
return $c;
}
public function initPools(): void
{
$this->redisPool = new Channel(64);
for ($i=0;$i<64;$i++) {
$r = new Redis(); $r->connect('127.0.0.1', 6379);
$this->redisPool->push($r);
}
$this->mysqlPool = new Channel(16);
for ($i=0;$i<16;$i++) {
$db = new MySQL();
$db->connect(['host'=>'127.0.0.1','user'=>'root','password'=>'','database'=>'ota']);
$this->mysqlPool->push($db);
}
}
public function loadVersions(): void
{
$this->withMySQL(function($db) {
$rows = $db->query("SELECT * FROM versions WHERE status IN('gray','full')");
foreach ($rows as $r) {
$key = $r['app_id'].':'.$r['version'];
$this->versions->set($key, [
'app_id' => $r['app_id'],
'version' => $r['version'],
'build_no' => (int)$r['build_no'],
'file_path'=> $r['file_path'],
'file_size'=> (int)$r['file_size'],
'sha256' => $r['sha256'],
'signature'=> $r['signature'],
'force' => (int)$r['force'],
'min_from' => $r['min_from'] ?? '',
'release_notes'=> $r['release_notes'] ?? '',
'status' => $r['status'],
]);
}
$rows = $db->query("SELECT * FROM upgrade_policies");
foreach ($rows as $p) {
$this->policies->set((string)$p['id'], [
'version_key'=> $p['app_id'].':'.$p['version'],
'expression' => $p['expression'],
'percent' => (int)$p['percent'],
]);
}
});
}
public function loadSigningKey(): void
{
// 真生产从 KMS / Vault 拉,这里读文件演示
$this->signPrivKey = file_get_contents('/etc/ota/ed25519.priv');
$this->signPubKey = file_get_contents('/etc/ota/ed25519.pub');
}
public function withRedis(callable $fn) {
$r = $this->redisPool->pop();
try { return $fn($r); } finally { $this->redisPool->push($r); }
}
public function withMySQL(callable $fn) {
$db = $this->mysqlPool->pop();
try { return $fn($db); } finally { $this->mysqlPool->push($db); }
}
}
解释:
- 三张 Swoole Table:版本元数据、灰度策略、灰度统计 ——全在共享内存,所有 worker 0 RTT 访问
- Flysystem + S3 适配:无缝切换 MinIO / 阿里 OSS / AWS S3,代码不动
- Ed25519 签名:比 RSA 快 10 倍,签名只有 64 字节,移动端验证开销极小
---
3. 路由
<?php
// src/OTA/Router.php
namespace App\OTA;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Http\Server;
class Router
{
public function __construct(private Container $c, private Server $server) {}
public function dispatch(Request $req, Response $res): void
{
$uri = $req->server['request_uri'];
try {
match($uri) {
'/ota/check' => (new CheckHandler($this->c))->handle($req, $res),
'/ota/download' => (new DownloadHandler($this->c))->handle($req, $res),
'/ota/report' => (new ReportHandler($this->c, $this->server))->handle($req, $res),
'/ota/upload' => (new UploadHandler($this->c))->handle($req, $res),
'/ota/rollback' => (new RollbackHandler($this->c))->handle($req, $res),
default => $this->notFound($res),
};
} catch (\Throwable $e) {
$res->status(500);
$res->end(json_encode(['code'=>500,'msg'=>$e->getMessage()]));
}
}
private function notFound(Response $res): void
{
$res->status(404);
$res->end(json_encode(['code'=>404,'msg'=>'not found']));
}
}
---
4. CheckHandler:版本检查(核心)
<?php
// src/OTA/CheckHandler.php
namespace App\OTA;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
class CheckHandler
{
private ExpressionLanguage $expr;
public function __construct(private Container $c)
{
$this->expr = new ExpressionLanguage();
}
public function handle(Request $req, Response $res): void
{
$res->header('Content-Type','application/json');
// 1. 设备身份验证(JWT)
$deviceId = $this->verifyJwt($req);
if (!$deviceId) {
$res->status(401); $res->end(json_encode(['code'=>401,'msg'=>'unauthorized']));
return;
}
$data = json_decode($req->rawContent(), true) ?? [];
$ctx = [
'device_id' => $deviceId,
'app_id' => $data['app_id'] ?? '',
'version' => $data['current_version'] ?? '0.0.0',
'build_no' => (int)($data['build_no'] ?? 0),
'model' => $data['model'] ?? '',
'os' => $data['os'] ?? '',
'region' => $data['region'] ?? '',
'channel' => $data['channel'] ?? '',
'rom_size_mb'=> (int)($data['rom_size_mb'] ?? 0),
];
// 2. 找到该 app 最新的版本
$target = $this->pickTargetVersion($ctx);
if (!$target) {
$res->end(json_encode(['code'=>0,'has_update'=>false]));
return;
}
// 3. 灰度策略匹配
if (!$this->matchPolicy($target, $ctx)) {
$res->end(json_encode(['code'=>0,'has_update'=>false,'reason'=>'not_in_gray']));
return;
}
// 4. 优先尝试差分包(节省 80%+ 带宽)
$patch = $this->findPatch($ctx['app_id'], $ctx['version'], $target['version']);
// 5. 生成下载令牌(防盗链,5 分钟内可下载)
$downloadToken = $this->issueDownloadToken($deviceId, $target['version'], (bool)$patch);
// 6. 统计灰度尝试数
$key = $target['app_id'].':'.$target['version'];
$stats = $this->c->grayStats->get($key) ?: ['attempt'=>0,'success'=>0,'failed'=>0];
$this->c->grayStats->incr($key, 'attempt');
$res->end(json_encode([
'code' => 0,
'has_update' => true,
'force' => (bool)$target['force'],
'version' => $target['version'],
'build_no' => $target['build_no'],
'file_size' => $patch['size'] ?? $target['file_size'],
'sha256' => $patch['sha256'] ?? $target['sha256'],
'signature' => $target['signature'],
'is_patch' => (bool)$patch,
'download_url'=> "/ota/download?token={$downloadToken}",
'release_notes' => $target['release_notes'],
]));
}
private function pickTargetVersion(array $ctx): ?array
{
$best = null;
foreach ($this->c->versions as $key => $v) {
if ($v['app_id'] !== $ctx['app_id']) continue;
if ($v['status'] === 'paused') continue;
// 旧版本太老,不在兼容窗口内 →跳过(或强制提示官网下载)
if ($v['min_from'] && version_compare($ctx['version'], $v['min_from'], '<')) continue;
if (version_compare($v['version'], $ctx['version'], '<=')) continue;
if (!$best || version_compare($v['version'], $best['version'], '>')) $best = $v;
}
return $best;
}
private function matchPolicy(array $target, array $ctx): bool
{
$matched = false; $needMatch = false;
foreach ($this->c->policies as $p) {
if ($p['version_key'] !== $target['app_id'].':'.$target['version']) continue;
$needMatch = true;
try {
// 表达式 DSL:支持 model/region/channel/rom_size_mb 等组合
if ($this->expr->evaluate($p['expression'], $ctx)) {
// 百分比灰度:device_id 哈希 →0-99
$bucket = crc32($ctx['device_id']) % 100;
if ($bucket < $p['percent']) { $matched = true; break; }
}
} catch (\Throwable $e) {
error_log("policy expr error: ".$e->getMessage());
}
}
// 没有策略 + 全量发布 →默认匹配
if (!$needMatch && $target['status'] === 'full') return true;
return $matched;
}
private function findPatch(string $appId, string $from, string $to): ?array
{
return $this->c->withMySQL(function($db) use ($appId,$from,$to) {
$stmt = $db->prepare(
"SELECT file_path,file_size AS size,sha256 FROM patches
WHERE app_id=? AND from_version=? AND to_version=? LIMIT 1"
);
$rows = $stmt->execute([$appId, $from, $to]);
return $rows[0] ?? null;
});
}
private function issueDownloadToken(string $deviceId, string $version, bool $isPatch): string
{
$token = bin2hex(random_bytes(16));
$this->c->withRedis(fn($r) => $r->setEx(
"ota:dl:$token", 300,
json_encode(['device'=>$deviceId,'version'=>$version,'patch'=>$isPatch])
));
return $token;
}
private function verifyJwt(Request $req): ?string
{
$auth = $req->header['authorization'] ?? '';
if (!str_starts_with($auth, 'Bearer ')) return null;
try {
$payload = \Firebase\JWT\JWT::decode(
substr($auth, 7),
new \Firebase\JWT\Key('your-jwt-secret', 'HS256')
);
return $payload->device_id ?? null;
} catch (\Throwable $e) { return null; }
}
}
解释:
- 匹配最新可升级版本用 version_compare,自动支持 1.2.3-beta 这类语义化版本
- 灰度算法 = 表达式过滤 + 百分比哈希分桶:crc32(device_id) % 100 < percent,同一设备结果稳定(不会今天命中明天不命中)
- 下载令牌:/check 返回带 token 的 URL,5 分钟有效,防止 URL 被分享出去吃流量
- min_from:跨大版本升级时强制走全量包(差分包对不上)
---
5. DownloadHandler:大文件下载(零拷贝 + 断点续传 + 限流)
<?php
// src/OTA/DownloadHandler.php
namespace App\OTA;
use Swoole\Http\Request;
use Swoole\Http\Response;
class DownloadHandler
{
public function __construct(private Container $c) {}
public function handle(Request $req, Response $res): void
{
$token = $req->get['token'] ?? '';
$info = $this->c->withRedis(fn($r) => $r->get("ota:dl:$token"));
if (!$info) {
$res->status(403); $res->end('token expired'); return;
}
$info = json_decode($info, true);
$deviceId = $info['device'];
// 限流:同一设备 5 分钟最多 3 次下载
if (!$this->rateLimit($deviceId)) {
$res->status(429); $res->end('too many requests'); return;
}
// 取版本元数据
[$appId, $ver] = explode(':', $info['version']); // 简化:实际从 token 内取
$version = $this->c->versions->get($info['version']) ?? null;
if (!$version) { $res->status(404); $res->end('not found'); return; }
$filePath = '/data/ota/' . basename($version['file_path']); // 本地缓存路径
// 真实场景:如果是 S3,先生成 presign URL 302 重定向;走内网则代理读
if (!is_file($filePath)) {
$this->downloadToCache($version['file_path'], $filePath);
}
$size = filesize($filePath);
$start = 0; $end = $size - 1;
// ★HTTP Range:断点续传
$range = $req->header['range'] ?? '';
if (preg_match('/bytes=(\d+)-(\d*)/', $range, $m)) {
$start = (int)$m[1];
$end = $m[2] === '' ? $size - 1 : (int)$m[2];
$res->status(206);
$res->header('Content-Range', "bytes $start-$end/$size");
}
$res->header('Content-Type', 'application/octet-stream');
$res->header('Content-Length', (string)($end - $start + 1));
$res->header('Accept-Ranges', 'bytes');
$res->header('X-OTA-SHA256', $version['sha256']);
$res->header('X-OTA-Signature', $version['signature']);
// ★Swoole sendfile:零拷贝,内核直接 socket 发,几 G 文件无 PHP 内存占用
$res->sendfile($filePath, $start, $end - $start + 1);
// 异步打点
\Swoole\Coroutine::create(function() use ($deviceId, $info) {
$this->c->withRedis(fn($r) => $r->incr("ota:dl_count:".date('Ymd')));
});
}
private function rateLimit(string $deviceId): bool
{
return (bool)$this->c->withRedis(function($r) use ($deviceId) {
$key = "ota:rl:$deviceId";
$n = $r->incr($key);
if ($n === 1) $r->expire($key, 300);
return $n <= 3;
});
}
private function downloadToCache(string $remotePath, string $localPath): void
{
$stream = $this->c->storage->readStream($remotePath);
$fp = fopen($localPath, 'wb');
stream_copy_to_stream($stream, $fp);
fclose($fp);
}
}
解释:
- sendfile 是 OTA 服务的命脉:内核直接把文件丢给 socket,PHP 进程内存 0 占用,100MB 固件和 10KB 文件同等开销
- HTTP Range 断点续传:移动网络断网重连必备,大文件没这个就是噩梦
- 限流 3 次/5 分钟:防止恶意刷流量(OTA 是企业最贵的带宽支出之一)
- 响应头携带 SHA256 + Signature:设备下载完立即校验
---
6. ReportHandler:升级结果上报 + 自动熔断
<?php
// src/OTA/ReportHandler.php
namespace App\OTA;
use Swoole\Http\Request;
use Swoole\Http\Response;
use Swoole\Http\Server;
class ReportHandler
{
public function __construct(private Container $c, private Server $server) {}
public function handle(Request $req, Response $res): void
{
$data = json_decode($req->rawContent(), true) ?? [];
$key = ($data['app_id']??'').':'.($data['version']??'');
$ok = ($data['result'] ?? '') === 'success';
if ($ok) $this->c->grayStats->incr($key, 'success');
else $this->c->grayStats->incr($key, 'failed');
// 异步落库 + 触发熔断判断
$this->server->task([
'type' => 'report',
'app_id' => $data['app_id'] ?? '',
'version' => $data['version'] ?? '',
'device_id' => $data['device_id'] ?? '',
'result' => $data['result'] ?? '',
'error_msg' => $data['error_msg'] ?? '',
'duration_ms'=> (int)($data['duration_ms'] ?? 0),
]);
$res->end(json_encode(['code'=>0]));
}
}
<?php
// src/OTA/ReportProcessor.php —在 task 进程跑
namespace App\OTA;
class ReportProcessor
{
public function __construct(private Container $c) {}
public function handle(array $data): void
{
$this->c->withMySQL(function($db) use ($data) {
$stmt = $db->prepare(
"INSERT INTO upgrade_reports(app_id,version,device_id,result,error_msg,duration_ms,created_at)
VALUES(?,?,?,?,?,?,NOW())"
);
$stmt->execute([
$data['app_id'],$data['version'],$data['device_id'],
$data['result'],$data['error_msg'],$data['duration_ms']
]);
});
// ★灰度自动熔断:失败率超过 5% 且样本数 ≥100 →暂停发布
$key = $data['app_id'].':'.$data['version'];
$s = $this->c->grayStats->get($key);
if ($s && ($s['success']+$s['failed']) >= 100) {
$failRate = $s['failed'] / max($s['success']+$s['failed'], 1);
if ($failRate > 0.05) {
$this->c->withMySQL(fn($db) =>
$db->query("UPDATE versions SET status='paused' WHERE app_id='{$data['app_id']}' AND
version='{$data['version']}'")
);
$v = $this->c->versions->get($key);
if ($v) { $v['status']='paused'; $this->c->versions->set($key, $v); }
error_log("[ALERT] auto-pause $key, fail rate=$failRate");
// 触发飞书/钉钉告警(略)
}
}
}
}
解释:
- 失败率自动熔断是 OTA 最关键的安全网:新版本搞挂 5% 设备就自动停发,避免事故扩大
- 不熔断的后果:可能凌晨 3 点把全网设备变砖,无法回滚(设备已经更新了)
- Swoole Table 的 incr 是原子操作,跨 worker 累加无锁安全
---
7. UploadHandler:研发上传新固件
<?php
// src/OTA/UploadHandler.php
namespace App\OTA;
use Swoole\Http\Request;
use Swoole\Http\Response;
class UploadHandler
{
public function __construct(private Container $c) {}
public function handle(Request $req, Response $res): void
{
// 内部管理 API,需鉴权(略)
$file = $req->files['firmware'] ?? null;
if (!$file) { $res->status(400); $res->end('no file'); return; }
$appId = $req->post['app_id'] ?? '';
$version = $req->post['version'] ?? '';
$minFrom = $req->post['min_from'] ?? '';
$tmpPath = $file['tmp_name'];
// 1. 计算 SHA256
$sha = hash_file('sha256', $tmpPath);
// 2. Ed25519 签名(防中间人篡改 + 防设备执行非官方固件)
$sig = '';
sodium_crypto_sign_detached($sha, $this->c->signPrivKey); // 简化
$sig = base64_encode(sodium_crypto_sign_detached(
file_get_contents($tmpPath),
$this->c->signPrivKey
));
// 3. 上传对象存储
$remotePath = "firmware/$appId/$version.bin";
$this->c->storage->writeStream($remotePath, fopen($tmpPath, 'rb'));
// 4. 入库 + 同步内存
$this->c->withMySQL(function($db) use ($appId,$version,$remotePath,$sha,$sig,$minFrom,$file) {
$stmt = $db->prepare(
"INSERT INTO
versions(app_id,version,build_no,file_path,file_size,sha256,signature,status,min_from,force,release_notes)
VALUES(?,?,?,?,?,?,?,'gray',?,0,'')"
);
$stmt->execute([
$appId, $version, time(), $remotePath, $file['size'],
$sha, $sig, $minFrom
]);
});
$this->c->loadVersions();
// 5. 异步生成差分包(对所有兼容旧版本)
\Swoole\Coroutine::create(function() use ($appId,$version,$tmpPath) {
(new PatchBuilder($this->c))->buildPatchesFor($appId, $version, $tmpPath);
});
$res->end(json_encode([
'code'=>0, 'sha256'=>$sha, 'signature'=>$sig, 'path'=>$remotePath
]));
}
}
---
8. PatchBuilder:差分包生成(省 80% 带宽)
<?php
// src/OTA/PatchBuilder.php
namespace App\OTA;
class PatchBuilder
{
public function __construct(private Container $c) {}
public function buildPatchesFor(string $appId, string $newVersion, string $newFilePath): void
{
// 取最近 5 个老版本,对每个生成差分包
$olds = $this->c->withMySQL(fn($db) =>
$db->query("SELECT version,file_path FROM versions
WHERE app_id='$appId' AND version<>'$newVersion'
ORDER BY build_no DESC LIMIT 5")
);
foreach ($olds as $o) {
$oldLocal = "/tmp/old_{$o['version']}.bin";
$patchPath = "/tmp/patch_{$o['version']}_to_$newVersion.patch";
$this->c->storage->writeStream($oldLocal, $this->c->storage->readStream($o['file_path']));
// bsdiff 调用(业界标准差分算法,Chrome/Android OTA 都用它)
$cmd = sprintf('bsdiff %s %s %s', escapeshellarg($oldLocal),
escapeshellarg($newFilePath), escapeshellarg($patchPath));
exec($cmd, $out, $code);
if ($code !== 0) continue;
$sha = hash_file('sha256', $patchPath);
$size = filesize($patchPath);
$remote = "patches/$appId/{$o['version']}_to_$newVersion.patch";
$this->c->storage->writeStream($remote, fopen($patchPath, 'rb'));
$this->c->withMySQL(function($db) use ($appId,$o,$newVersion,$remote,$size,$sha) {
$stmt = $db->prepare(
"INSERT INTO patches(app_id,from_version,to_version,file_path,file_size,sha256)
VALUES(?,?,?,?,?,?)"
);
$stmt->execute([$appId, $o['version'], $newVersion, $remote, $size, $sha]);
});
unlink($oldLocal); unlink($patchPath);
}
}
}
解释:
- bsdiff/bspatch 是 Google Chrome 增量更新、Android Recovery OTA、iOS App Thinning 用的同款算法
- 典型效果:200MB 完整包 →5-20MB 差分包,省 90% 带宽
- 服务端预先对最近 5 个版本生成 patch,覆盖 95%+ 设备
- 设备拿差分包后:bspatch oldFirmware.bin newFirmware.bin patch.bin
---
9. DDL
CREATE TABLE versions (
id INT PRIMARY KEY AUTO_INCREMENT,
app_id VARCHAR(64) NOT NULL,
version VARCHAR(32) NOT NULL,
build_no INT NOT NULL,
file_path VARCHAR(256) NOT NULL,
file_size BIGINT NOT NULL,
sha256 CHAR(64) NOT NULL,
signature VARCHAR(512) NOT NULL,
force TINYINT DEFAULT 0,
min_from VARCHAR(32) DEFAULT '',
status ENUM('draft','gray','full','paused') DEFAULT 'draft',
release_notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk(app_id, version)
) ENGINE=InnoDB;
CREATE TABLE upgrade_policies (
id INT PRIMARY KEY AUTO_INCREMENT,
app_id VARCHAR(64), version VARCHAR(32),
expression TEXT, -- region in ['CN','HK'] and model startsWith 'Pixel'
percent TINYINT DEFAULT 100
);
CREATE TABLE patches (
id INT PRIMARY KEY AUTO_INCREMENT,
app_id VARCHAR(64), from_version VARCHAR(32), to_version VARCHAR(32),
file_path VARCHAR(256), file_size BIGINT, sha256 CHAR(64),
UNIQUE KEY uk(app_id, from_version, to_version)
);
CREATE TABLE upgrade_reports (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
app_id VARCHAR(64), version VARCHAR(32), device_id VARCHAR(64),
result ENUM('success','failed','cancelled'),
error_msg VARCHAR(512), duration_ms INT,
created_at DATETIME,
KEY idx_ver(app_id, version, result),
KEY idx_device(device_id)
) ENGINE=InnoDB;
---
四、调用流程示例
# 1. 设备检查升级
curl -X POST http://127.0.0.1:9503/ota/check \
-H "Authorization: Bearer <设备JWT>" \
-d '{"app_id":"smart_lock","current_version":"1.2.0","build_no":120,
"model":"SL-X1","os":"linux/arm64","region":"CN","channel":"oem-A","rom_size_mb":64}'
# 返回
{"code":0,"has_update":true,"force":false,"version":"1.3.0","build_no":130,
"file_size":8500000,"sha256":"...","signature":"...","is_patch":true,
"download_url":"/ota/download?token=abc...","release_notes":"修复指纹识别bug"}
# 2. 下载(可断点续传)
curl -H "Range: bytes=0-1048575" "http://127.0.0.1:9503/ota/download?token=abc..." -o patch.bin
# 3. 上报结果
curl -X POST http://127.0.0.1:9503/ota/report \
-d '{"app_id":"smart_lock","version":"1.3.0","device_id":"dev-123",
"result":"success","duration_ms":12500}'
---
五、性能参考
┌──────────────────────┬─────────────────────────────────────────┐
│ 指标 │ 数值 │
├──────────────────────┼─────────────────────────────────────────┤
│ /ota/check QPS │ 3-5w / 单机 │
├──────────────────────┼─────────────────────────────────────────┤
│ /ota/download 并发 │ 5000+ 长连接 / 单机(sendfile 零拷贝) │
├──────────────────────┼─────────────────────────────────────────┤
│ 平均节省带宽(差分包) │ 80-95% │
├──────────────────────┼─────────────────────────────────────────┤
│ 灰度熔断响应时间 │ < 1 分钟(100 个失败上报后) │
├──────────────────────┼─────────────────────────────────────────┤
│ 内存占用 │ 每 worker ~100MB(不含 OS sendfile 缓存) │
└──────────────────────┴─────────────────────────────────────────┘
---
六、安全清单(必做)
┌────────────────┬────────────────────────────────────────────┐
│ 风险 │ 防御 │
├────────────────┼────────────────────────────────────────────┤
│ 设备伪造下载 │ JWT 设备身份 + 一次性下载 token │
├────────────────┼────────────────────────────────────────────┤
│ 中间人改包 │ Ed25519 签名,设备本地验证 │
├────────────────┼────────────────────────────────────────────┤
│ 老版本被打回 │ version_compare 严格 >,DB 唯一约束 │
├────────────────┼────────────────────────────────────────────┤
│ URL 被分享盗刷 │ 下载 token 5 分钟过期 │
├────────────────┼────────────────────────────────────────────┤
│ 灰度名单被绕过 │ crc32(device_id) 稳定哈希,改不了 │
├────────────────┼────────────────────────────────────────────┤
│ 同设备刷流量 │ Redis 限流 3 次/5 分钟 │
├────────────────┼────────────────────────────────────────────┤
│ 上传接口被滥用 │ 上传走内网管理 API + IP 白名单 + 双因子 │
├────────────────┼────────────────────────────────────────────┤
│ 升级砖机 │ 失败率 5% 自动熔断 + 强制保留 N-1 版本回滚 │
└────────────────┴────────────────────────────────────────────┘
---
七、踩坑提示
┌───────────────────────────────┬────────────────────────────────────────────────────────┐
│ 坑 │ 解决 │
├───────────────────────────────┼────────────────────────────────────────────────────────┤
│ 大文件读到 PHP 内存 OOM │ 必须用 sendfile,不要 readfile / echo file_get_contents │
├───────────────────────────────┼────────────────────────────────────────────────────────┤
│ 同步生成差分包卡住上传 │ Coroutine::create 异步,或扔到 task 进程 │
├───────────────────────────────┼────────────────────────────────────────────────────────┤
│ 灰度百分比抖动 │ 用 device_id 稳定哈希,别用 random │
├───────────────────────────────┼────────────────────────────────────────────────────────┤
│ 老设备一直收不到推送 │ 加 min_from,大版本跨度强制走全量 + 引导官网升级 │
├───────────────────────────────┼────────────────────────────────────────────────────────┤
│ Redis 主从切换导致 token 失效 │ token 写入主库,读主库;或用 Redis Cluster │
├───────────────────────────────┼────────────────────────────────────────────────────────┤
│ OSS 国内下载慢 │ CDN 回源 + 边缘缓存(check 接口返回 CDN URL) │
├───────────────────────────────┼────────────────────────────────────────────────────────┤
│ 设备时间不准 JWT 验签失败 │ 用 device_id 自签短 token,服务端校 │
└───────────────────────────────┴────────────────────────────────────────────────────────┘
---
八、可扩展方向
1. P2P 分发:大型 OTA 让设备间互传(类似 Telegram、Steam),省骨干带宽 50%+
2. AB 双分区升级:Android A/B Partition 思路,升级失败自动回滚到 B 分区
3. 强制窗口:某些固件必须在凌晨 2-5 点升,服务端按时间过滤
4. 流量调度:check 接口根据 region 返回不同 CDN 节点(分省调度)
5. 机器学习预测:基于历史成功率预测某机型升级风险,主动降级灰度比例
6. 签名密钥轮转:支持多公钥并存,平滑替换
7. Delta-on-Delta:连续小版本叠加差分,月级累积省带宽 99%
更多推荐



所有评论(0)