最近在GitHub上看到一个号称"支持开店"的PHP商城源码,下载下来一看差点没把咖啡喷在显示器上——所谓的多店铺功能就是在数据库里加了个shop_id字段。这让我想起当年刚入行时写的第一个商城系统,那时候连Shopify是什么都不知道,现在回头看简直惨不忍睹。
今天就来聊聊怎么用PHP实现真正可用的多店铺商城系统。先看个真实案例:去年给某农产品平台做改造,原本的单店铺系统要支持200多个农户独立开店,结果发现原来的架构根本扛不住。
数据库设计是第一个坑。很多新手会这样设计商品表:
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(255),
price DECIMAL(10,2),
shop_id INT -- 就加这么个字段完事了?
);
这种设计在店铺数量少的时候还能凑合,但等到要查"所有店铺中价格低于50元的商品"时,索引直接罢工给你看。正确的做法是把店铺相关数据拆分到单独的表空间:
CREATE TABLE shops (
name VARCHAR(255) NOT NULL,
subdomain VARCHAR(63) UNIQUE,
status ENUM('active','pending','suspended') DEFAULT 'pending'
-- 其他店铺配置项
) ENGINE=InnoDB PARTITION BY HASH(id) PARTITIONS 16;
id BIGINT PRIMARY KEY,
shop_id INT NOT NULL,
-- 其他字段
FOREIGN KEY (shop_id) REFERENCES shops(id) ON DELETE CASCADE
) ENGINE=InnoDB;
看到没?分区表加外键约束,这才是专业玩家的操作。PARTITION BY HASH能让查询自动路由到对应的表分区,店铺数据再多也不怕。
接下来是URL路由这个重灾区。很多开源项目处理子域名的方式简直让人想砸键盘:
$subdomain = explode('.', $_SERVER['HTTP_HOST'])[0];
if($subdomain == 'www') {
// 主站逻辑
} else {
// 店铺逻辑
}
这种写法在Nginx反向代理后面会死得很难看。正确的打开方式应该是:
// 在bootstrap阶段初始化店铺上下文
$shop = null;
$host = parse_url($_SERVER['HTTP_HOST'], PHP_URL_HOST);
if(preg_match('/^(.)\.example\.com$/', $host, $matches)) {
$shop = Shop::where('subdomain', $matches[1])->first();
}
if(!$shop) {
abort(404, '店铺不存在或已关闭');
// 注册全局中间件
app()->instance(Shop::class, $shop);
现在说说店铺自定义模板这个需求。千万别学某些框架把PHP文件直接存数据库,除非你想体验服务器被黑的感觉。我们采用更安全的方式:
// 店铺模板表结构
CREATE TABLE shop_templates (
content TEXT NOT NULL, -- 存Twig/Blade模板
FOREIGN KEY (shop_id) REFERENCES shops(id)
);
// 渲染时做安全过滤
public function renderTemplate(Shop $shop, $templateName)
{
$template = $shop->templates()->where('name', $templateName)->first();
if(!$template) {
return view('default.'.$templateName);
}
try {
return \Blade::render($template->content, $this->data);
} catch (\Throwable $e) {
Log::error("店铺模板渲染失败: {$shop->id}");
}
缓存策略也是个技术活。曾经见过有人用店铺ID当缓存键前缀,结果Redis内存爆了。我们的解决方案是二级缓存:
// 店铺级别的缓存封装
class ShopCache {
private $shopId;
public function __construct($shopId) {
$this->shopId = $shopId;
public function remember($key, $ttl, $callback) {
$fullKey = "shop:{$this->shopId}:".md5($key);
// 第一层:内存缓存
if($data = apcu_fetch($fullKey)) {
return $data;
// 第二层:Redis集群
$data = Redis::cluster()->get($fullKey);
if(!$data) {
$data = value($callback);
Redis::cluster()->setex($fullKey, $ttl, serialize($data));
} else {
$data = unserialize($data);
}
apcu_store($fullKey, $data, min(300, $ttl));
return $data;
}
}
支付接口对接最容易出幺蛾子。某次升级时发现,有店铺的支付宝收款全进了平台账户,原因是在支付回调里没校验店铺参数:
// 错误示范
Route::post('/pay/notify', function(Request $request) {
$order = Order::find($request->input('out_trade_no'));
$order->pay(); // 完蛋,没验证店铺
});
// 正确姿势
$order = $shop->orders()->where('id', $request->input('out_trade_no'))->first();
if(!$order) {
Log::alert("可疑支付通知: ".$request->fullUrl());
abort(403);
}
$order->pay();
})->middleware('verify_shop_signature');
最后说说性能优化。当你有500个店铺时,N+1查询问题会被放大500倍。看看这个典型的错误案例:
// 获取所有店铺及其商品
$shops = Shop::all();
foreach($shops as $shop) {
echo $shop->name;
foreach($shop->products as $product) { // 这里每次循环都查一次数据库
echo $product->name;
}
}
解决方案是用预加载和查询作用域:
// 在Shop模型里定义
public function scopeWithActiveProducts($query)
{
return $query->with(['products' => function($q) {
$q->where('status', 'active')->select('id','shop_id','name');
}]);
}
// 使用时
$shops = Shop::withActiveProducts()->paginate(50);
多店铺系统最考验人的其实是数据隔离。去年遇到个奇葩bug:A店铺的管理员登录后能看到B店铺的订单。排查发现是Session处理有问题:
// 错误的会话处理
$_SESSION['shop_id'] = $shop->id; // 只存个ID不够
// 应该这样
session()->put('shop_context', [
'id' => $shop->id,
'name' => $shop->name,
'permissions' => $user->getShopPermissions($shop->id)
]);
// 中间件校验
public function handle($request, $next)
{
if($request->session()->get('shop_context.id') != $request->route('shop')) {
auth()->logout();
return redirect()->route('shop.login');
}
return $next($request);
}
说到这不得不提一嘴,有些开发者喜欢用数据库视图来解决多店铺查询问题。比如:
CREATE VIEW shop_products AS
SELECT p. FROM products p
JOIN shops s ON p.shop_id = s.id
WHERE s.status = 'active';
看起来很美是不是?但在高并发下这东西就是性能黑洞。我们的做法是用查询构建器动态生成SQL:
class ShopProductQueryBuilder {
public static function forShop(Shop $shop)
{
return Product::query()
->where('shop_id', $shop->id)
->when(!$shop->isAdmin(), function($q) {
$q->where('is_public', true);
});
}
}
日志处理也有讲究。把所有店铺日志都写到一个文件里,等出问题时你就知道什么叫大海捞针了。我们的日志策略:
// 在config/logging.php配置
'channels' => [
'shop' => [
'driver' => 'daily',
'path' => storage_path('logs/shops/shop_{shop_id}.log'),
'tap' => [ShopLogFormatter::class],
'days' => 14,
]
],
// 使用时
Log::channel('shop:'.$shop->id)->info('订单创建', $order->toArray());
最后给个忠告:如果你正在开发多店铺系统,千万别在代码里写死店铺数量限制。我见过最蠢的实现:
// 千万别这么干!
if(Shop::count() > 100) {
throw new Exception("超出店铺数量限制");
}
// 应该用配置控制
config('shop.max_shops', 1000);
// 或者在店铺注册逻辑里判断
if(Shop::active()->count() >= $plan->max_shops) {
throw new ShopLimitReachedException;
}
写代码就像开餐馆,你以为加几张桌子就能接待更多顾客,结果发现厨房根本忙不过来。多店铺系统开发也是这个道理,表面上看只是加个店铺ID的事,实际上要考虑的远比你想象的复杂得多。