(敲击粉笔的声音在空荡荡的讲厅回荡,粉笔灰在夕阳的光束中飞舞。我放下粉笔,拍了拍手上的灰,看着台下坐得满满当当、一脸迷茫的开发者们。)
咳咳,各位同学,下午好。我是你们今天的讲师,一个在 PHP 领域摸爬滚打了十几年,发际线虽然比三年前高了点,但经验值蹭蹭往上涨的“老司机”。
今天我们不聊框架,不聊 Composer,不聊 PHP 8.2 的 JIT 到底能不能让你那台装了虚拟机的老爷机起飞。今天我们聊点更扎心、更反直觉,甚至有点“违背祖宗”的话题。
《PHP 中的“微服务陷阱”:论如何在单体应用中利用模块化实现 50 万文章的高效治理》
听到这个标题,我知道你们里面有人已经在疯狂点头了:“终于有人说了!微服务就是扯淡!”也有人可能在低头刷手机:“50 万篇文章?那不得用 Elasticsearch?那不得用 Kafka?”
停,打住。
今天我要告诉你们的是:微服务不是万能药,单体才是你的亲爹。
第一部分:微服务的幻觉——为什么我们要往自己的脖子上套绞索?
在座的各位,或者你们公司的老板,可能都读过几篇《为什么微服务是大势所趋》的文章。文章里说:“单体应用就像一个巨大的泥球,难以维护、难以扩展、技术债务堆积如山。”然后他们一拍桌子:“老板,我们要拆分!我们要把用户服务、文章服务、评论服务拆开!我们要用 Docker!我们要用 Kubernetes!”
这听起来很酷,对吧?就像你在玩《文明》游戏,每一个单位都是一个独立的文明。但是,现实是残酷的。
想象一下,你的系统有 50 万篇文章。这是一个什么概念?如果你们公司有 50 个微服务,为了查看一篇文章的详情,你的请求需要经过:
- API 网关(这个路由规则配了三天三夜)。
- 用户鉴权服务(问问你今天登录没登录)。
- 文章服务(去数据库查)。
- 评论服务(查查这篇文章有多少杠精在评论)。
- 标签服务(查查这篇文章有什么标签)。
- 推荐服务(去查查大数据模型觉得你应该看什么)。
7 次网络请求,1 次数据库查询。
哪怕你们的服务器在同一个机架上,哪怕你们用了 FastCGI 的 Keep-Alive,这多出来的 6 次握手延迟,足以让你的用户在等待加载时骂娘。而对于 50 万篇文章这种数据密集型业务,数据的强一致性才是王道。分布式数据库的 ACID 特性?在单体里,你一个 BEGIN 和 COMMIT 就搞定了;在微服务里,你要搞分布式事务,搞两阶段提交,搞 Seata,最后搞到崩溃。
所以,“微服务陷阱”本质上是一个为了架构而架构的陷阱。你以为你在构建一个蚂蚁森林,其实你在搭建一个违章建筑。把一个 50 万文章的系统拆成微服务,就像是为了煮一碗面,你非要雇佣厨师、面点师、采购员、搬运工,最后发现面条都坨了。
我们要做的,是“模块化单体”。听懂了吗?Monolithic Modular Architeture (MMA)。这就像是组装汽车,引擎、变速箱、轮胎都在一个底盘上,互相关联,紧密配合,出问题了直接拆底盘修,而不是把整辆车报废。
第二部分:50 万文章的痛点——数据不是数字,是命脉
好,假设我们坚持了单体。那么,面对 50 万篇文章,我们要怎么治?很多初学者上来就是 SELECT * FROM articles,这绝对是作死行为。
50 万条记录,对于现代 MySQL 来说不算大,但对于一个写得烂的 SQL 来说,就是天灾。
痛点 1:N+1 查询问题
这是 PHP 开发者最容易犯的错。你的控制器拿到了文章列表,然后循环去查文章的作者、文章的分类、文章的标签。50 万篇文章,就是 50 万次额外的数据库往返。数据库会觉得你是在羞辱它。
痛点 2:索引失效
你在一个巨大的 VARCHAR 字段上建了索引,然后查询的时候没写对引号,或者字段类型不匹配,索引直接失效,全表扫描,CPU 瞬间飙红。
痛点 3:内存溢出
把 50 万条数据全部 foreach 进 PHP 数组,然后打印出来。你的内存不够,服务器直接报错,然后崩溃。
第三部分:架构设计——像剥洋葱一样拆分单体
怎么解决?我们要利用模块化。不是物理上的拆分,是逻辑上的隔离。
在我们的架构中,我们要划分清晰的边界。我是这么设计的,大家看这个目录结构:
/app
/Core // 核心层:公共逻辑,不要依赖业务
/Domain // 领域层:业务逻辑(文章、用户、评论)
/Article
/Entity
/Repository
/Service
/Interfaces
/User
/Infrastructure // 基础设施层:数据库、缓存、外部 API
/Database
/Cache
/Application // 应用层:用例编排
/Presentation // 表现层:控制器、路由
看,这就是解耦。Article 模块根本不知道它下面连的是 MySQL 还是 Redis,它只知道它有一个 ArticleRepositoryInterface。
代码示例 1:接口定义与依赖倒置
这是防止“屎山代码”的第一步。我们定义接口,然后实现接口。
// app/Domain/Article/Repository/ArticleRepositoryInterface.php
<?php
namespace AppDomainArticleRepository;
use AppDomainArticleEntityArticle;
interface ArticleRepositoryInterface
{
/**
* 获取单篇文章
* @param int $id
* @return Article|null
*/
public function findById(int $id): ?Article;
/**
* 获取文章列表(分页)
* @param int $page
* @param int $pageSize
* @return array
*/
public function getList(int $page = 1, int $pageSize = 20): array;
/**
* 保存文章
* @param Article $article
* @return bool
*/
public function save(Article $article): bool;
}
你看,这个接口很干净。不管我们后面是用 Eloquent ORM,还是用 Doctrine,甚至是自己手写 SQL,只要实现了这个接口,就能被我们的 Service 调用。
代码示例 2:优雅的数据获取(解决 N+1 问题)
好,现在我们在 Service 层来调用它。我们要禁止 SELECT *,只查需要的字段。
// app/Domain/Article/Service/ArticleService.php
<?php
namespace AppDomainArticleService;
use AppDomainArticleRepositoryArticleRepositoryInterface;
use AppDomainArticleEntityArticle;
use PDO;
class ArticleService
{
private $articleRepo;
private $pdo; // 假设我们有一个原生PDO连接用于复杂查询
public function __construct(ArticleRepositoryInterface $articleRepo, PDO $pdo)
{
$this->articleRepo = $articleRepo;
$this->pdo = $pdo;
}
/**
* 获取首页推荐文章,包含分类名称
*/
public function getHomePageArticles(): array
{
// 方式一:如果数据量小,用 ORM,但要注意预加载
// $articles = $this->articleRepo->getList(1, 10);
// foreach ($articles as $article) {
// $article->category; // 触发 N+1 查询!
// }
// 方式二:SQL JOIN 一步到位(高效!)
// 注意:这里不使用 *,只查询需要的字段,减少网络传输和内存占用
$sql = "
SELECT
a.id,
a.title,
a.summary,
a.created_at,
c.name as category_name
FROM articles a
INNER JOIN categories c ON a.category_id = c.id
WHERE a.status = 1
ORDER BY a.created_at DESC
LIMIT 20
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute();
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
// 组装对象(或者直接返回数组,视业务而定)
return $results;
}
}
各位看,这就叫高效治理。50 万篇文章,每次首页加载只查 20 条,通过 JOIN 把分类表一次性带出来。这是处理大数据量最朴实无华也最枯燥的技巧。
第四部分:缓存策略——给系统装个“外挂”
如果 50 万篇文章是“数据”,那你们用户就是“脑残粉”,每次刷新页面都要重新查数据库?别开玩笑了。
我们要用缓存。但是,微服务喜欢玩分布式缓存,比如 Redis Cluster。而在单体应用里,我们能不能用更简单的?当然能!
策略:内存缓存 + 数据库双层保险
在单体应用中,我们可以把 Redis 作为主缓存,直接附挂在主进程上。或者,如果你们用的是 PHP-FPM(多进程),那每个进程其实都有自己的内存空间,我们可以利用 APCu。
但是,对于 50 万这个量级,APCu 装不下。我们得用 Redis。
这里有一个经典的“缓存穿透”和“缓存击穿”的问题。
- 缓存穿透:查询一个不存在的 ID。黑客在凌晨三点查
id=999999999。如果数据库里没有,缓存里也没。每次请求都穿透到数据库,把数据库干挂。 - 缓存击穿:一个热点数据过期了(比如一篇爆文,
id=100)。几万个并发请求同时打到数据库。 - 缓存雪崩:50 万篇文章的过期时间都是 1 小时。1 小时后,50 万个 Key 同时失效,请求全量打库。
代码示例 3:防御型缓存代码
class ArticleRepository implements ArticleRepositoryInterface
{
private $redis;
private $db;
public function __construct(Redis $redis, PDO $db)
{
$this->redis = $redis;
$this->db = $db;
}
public function findById(int $id): ?Article
{
// 1. 先查 Redis
$key = "article:{$id}";
$data = $this->redis->get($key);
if ($data !== false) {
// Redis 没有返回 false,空字符串才返回 false
return json_decode($data, true);
}
// 2. Redis 没有,查 MySQL
$stmt = $this->db->prepare("SELECT * FROM articles WHERE id = ?");
$stmt->execute([$id]);
$article = $stmt->fetch(PDO::FETCH_ASSOC);
if ($article) {
// 3. 写入 Redis,并设置过期时间(随机化防止雪崩)
// TTL 设为 1 小时,但是加个 0-600 秒的随机偏移
$this->redis->setex($key, 3600, json_encode($article));
} else {
// 4. 防御穿透:即使数据库没有,也存一个空值,但是 TTL 设短一点
$this->redis->setex("article:notexist:{$id}", 60, 'NULL');
}
return $article;
}
}
看懂了吗?我们在代码里写了防御逻辑。对于不存在的数据,我们也要缓存一个“空值”,防止黑客无脑刷库。
第五部分:文章治理——处理脏数据的“特种部队”
50 万篇文章,不可能每一篇都是精品。可能有些是 2015 年写的垃圾文章,标题全是错的,数据库字段是空的,或者有重复的。
这时候,你们可能需要一个后台管理工具,或者一个“治理服务”。
在单体应用里,我们可以通过队列来异步处理这些任务。千万不要在用户点击“删除文章”或者“修复标题”的时候,就在前台页面跑 SQL UPDATE。那样用户体验极差,而且容易把数据库锁死。
代码示例 4:后台治理任务
假设我们要批量清理那些发布时间超过 3 年且阅读量为 0 的文章。
// app/Application/Tasks/CleanupOldArticles.php
<?php
namespace AppApplicationTasks;
use AppDomainArticleServiceArticleService;
class CleanupOldArticles
{
private $articleService;
public function __construct(ArticleService $articleService)
{
$this->articleService = $articleService;
}
public function execute()
{
// 分批处理,避免一次处理太多,导致 PHP 内存爆炸
$batchSize = 1000;
$currentPage = 1;
do {
// 1. 查询一批待删除的 ID
// 这里的逻辑是:status=0 (已删除) AND created_at < 2020-01-01 AND views=0
$articles = $this->articleService->getStaleArticles($currentPage, $batchSize);
if (empty($articles)) {
break;
}
$ids = array_column($articles, 'id');
// 2. 逻辑删除(软删除)
// 在数据库里把 deleted_at 设为当前时间
// 这里我们直接用原生 SQL,简单粗暴
$sql = "UPDATE articles SET deleted_at = NOW() WHERE id IN (" . implode(',', $ids) . ")";
// 执行 SQL ...
echo "Processed " . count($ids) . " articles.n";
$currentPage++;
} while (count($articles) === $batchSize);
}
}
这就叫高效治理。我们写一个脚本,扔到服务器上跑,或者放入 Cron Job,系统会自动帮你清理垃圾数据。你不需要为了这个功能去开发一套复杂的微服务架构,写个脚本就解决了。
第六部分:为什么 50 万篇文章不需要 Elasticsearch?
我知道,听到“50 万文章”这几个字,很多运维或者架构师马上就要说:“上 ES!全文搜索!高并发!”
先别急。
ES 是一个重量级组件。它需要独立的 Java 进程,需要大量的内存(几 G 起步),需要额外的运维成本。对于 50 万篇文章,如果你的业务场景不是让用户像查 Google 一样去搜“2023 年最好的 PHP 教程”,那么 MySQL 的全文索引(FULLTEXT INDEX)就足够了。
MySQL 5.6+ 引入了 InnoDB 的全文索引,支持中文分词(需要安装插件),性能非常强劲。
代码示例 5:MySQL 全文搜索
public function searchArticles(string $keyword)
{
$sql = "
SELECT
id,
title,
MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE) as score
FROM articles
WHERE MATCH(title, content) AGAINST(? IN NATURAL LANGUAGE MODE)
AND status = 1
ORDER BY score DESC
LIMIT 20
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$keyword, $keyword]);
return $stmt->fetchAll();
}
这行代码比写一个复杂的 ES 查询要快得多,也省得多。这叫技术债最小化。能用数据库解决的,为什么要给系统加个 Java 进程去喝西北风?
第七部分:单体应用的“手术刀”——如何在不拆分的情况下进行迭代
回到主题。我们既然不拆分微服务,那如果我要加个“评论点赞”功能,是不是要把代码全改一遍?
这就涉及到高内聚低耦合的极致追求。
我们要把“文章”和“评论”的关系通过接口解耦。文章模块只管发文章,评论模块只管评文章。它们之间通过事件总线或者简单的回调机制通信。
// 当文章被保存时,触发一个事件
class ArticleCreatedEvent
{
public $articleId;
}
// 订阅这个事件
class CommentService
{
public function handle(ArticleCreatedEvent $event)
{
// 自动给这篇文章添加一个默认评论:"欢迎新文章!"
$this->commentRepo->create([
'article_id' => $event->articleId,
'content' => '欢迎新文章!'
]);
}
}
这种设计,让你们的团队可以并行开发。前端同学改文章页面,后端同学改评论页面,只要接口定义好了,互不干扰。
第八部分:总结——拥抱单体,拒绝伪分布式
各位,我讲了很多,代码也写了。核心思想其实很简单:
- 不要为了分布式而分布式。微服务是手段,不是目的。如果 50 万篇文章不需要跨机房部署,不需要独立扩展,那就别拆。
- 模块化单体是单体应用的进化。通过接口、通过分层、通过队列,把单体应用改造成一个结构清晰、易于维护、高性能的系统。
- 性能优化从 SQL 和缓存开始。写好你的
SELECT *,用好你的 Redis,不要一上来就上消息队列,除非你真的需要削峰填谷。
在 PHP 生态里,我们有 Laravel,有 Symfony。这两个框架都在极力推动组件化和可测试性。不要把框架当束缚,要把它当工具。
当一个 50 万文章的网站跑得飞快,并发吞吐量很高,而且代码改动只需要十分钟就能部署上线时,你们就会感谢今天坐在这里听我讲课的你们自己。
记住,架构没有银弹。无论是单体还是微服务,能解决问题的就是好架构。别让那些设计模式书把你绕晕了,写代码是为了解决业务问题,不是为了在简历上写上“精通微服务架构”。
好了,今天的讲座就到这里。去写代码吧,别再想着拆分你的服务了!