从低库存通知到事件驱动架构:一次真实业务中的系统设计思考
前言:一个“简单”功能的开始
前段时间我接手第一个电商项目时,产品经理丢来一个需求:
“商品库存低于阈值时,给商家发个微信提醒,最好再来封邮件。”
听起来再简单不过。我花半小时写了个 decreaseStock() 方法,里面顺手调了微信接口 —— 测试通过,上线。但接下来的三个月,我至少在这个“简单功能”上改了七八次代码,每次改都心惊胆战。直到后来我接触到事件驱动架构,才恍然大悟:原来一个通知功能,竟然是检验系统设计的绝佳试金石。
这篇文章不会教你怎么“用 Laravel 实现低库存通知”,因为这样的代码一搜一大把。我想带你走一遍真实的演进之路:从一个粗糙的实现开始,一步步暴露问题,再逐步引入解耦、幂等、频控、队列等工程思想,最终让它成长为一个可靠的事件驱动通知系统。
希望读完后,你再看“发通知”这类需求时,眼里不再是“几行 if 和 API 调用”,而是一幅可以持续演进的系统蓝图。

一、业务背景:一个朴素的库存提醒需求
1.1 需求描述
在商城后台,商家维护商品库存。当某个商品的库存数量跌到预设的预警阈值以下时,系统需要主动通知商家。
具体场景:
-
商品:iPhone 15
-
当前库存:3 件
-
预警阈值:5 件
-
触发动作:通过微信、邮件发送“库存不足”提醒
通知内容通常包括商品名称、当前库存、阈值,可能还有补货链接。
1.2 初期实现思路(我曾经的写法)
我当时的直觉是:库存扣减完,顺手判断一下,如果低于阈值,就直接调用通知接口。
流程可以画成一条直线:
text
用户下单 → 扣减库存 → 判断库存 < 阈值 ? → 调用微信API → 发送邮件
对应的 Laravel 代码大致长这样:
php
public function decreaseStock(Product $product, int $quantity)
{
$product->stock -= $quantity;
$product->save();
if ($product->stock < $product->threshold) {
// 直接发送微信
WechatService::sendTemplateMessage(
$product->merchant->wechat_openid,
"商品 {$product->name} 库存不足,当前仅剩 {$product->stock} 件"
);
// 直接发送邮件
Mail::to($product->merchant->email)->send(
new LowStockMail($product)
);
}
}
上线之后,确实能发通知。但很快,噩梦开始了。
二、直接调用通知服务:埋下的四个深坑
2.1 业务代码与通知实现“焊”在一起
库存模块的 decreaseStock() 里,既要知道微信怎么发(需要 openid、模板 ID),又要知道邮件怎么发(要构造 LowStockMail 对象)。将来产品说“再加个短信通知”或者“接入企业微信机器人”,我就得回来改 decreaseStock()。
违反的正是开闭原则(对扩展开放,对修改关闭)。库存模块本不该关心“用什么渠道发通知”,它只需要说一句“库存低了”就够了。
2.2 通知失败会拖垮核心交易
有一次微信接口突然超时(整整 3 秒才返回错误),而 decreaseStock() 是同步调用的,导致用户下单接口响应时间从 200ms 暴涨到 3.2 秒。更可怕的是,因为异常未捕获,整个事务回滚了 —— 库存没扣成,订单却创建了(因为订单在另一个事务里)。最终库存超卖。
通知不该影响核心业务,这是血的教训。
2.3 无法应对复杂的业务规则
几个月后,产品又提了新需求:
-
同一商家,每天最多通知 10 次(防止骚扰);
-
同一商品,30 分钟内不重复提醒(避免反复发送);
-
商家可以在后台“关闭通知”。
如果继续往 decreaseStock() 里塞这些逻辑,代码很快就变成一锅粥。
2.4 扩展性为零
后来公司引入了新的通知渠道(如钉钉、APP 推送),每次都要修改库存模块。通知渠道的变更,竟然要牵动核心业务代码,这显然不合理。
这些问题其实都可以归结为两个字:耦合。
三、引入事件驱动:第一次优雅转身
3.1 事件驱动是什么?
事件驱动的核心思想很简单:一个业务动作发生后,只发布一个“事件”,至于这个事件被谁处理、怎么处理,发布者一概不管。
用库存通知为例:
-
库存扣减完成后,如果低于阈值,就发布一个
LowStockEvent; -
至于谁负责发微信、发邮件、记日志,那是事件监听器的事。
库存模块从此“两耳不闻窗外事”,只负责自己的核心职责。
3.2 新架构的示意图
text
┌─────────────┐ 发布事件 ┌─────────────────┐
│ 库存服务 │ ───────────────▶ │ LowStockEvent │
│ (扣减库存) │ └────────┬────────┘
└─────────────┘ │
┌───────┴────────┐
│ 事件监听器 │
└───────┬────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│微信通知 │ │邮件通知 │ │日志记录 │
└──────────┘ └──────────┘ └──────────┘
3.3 Laravel 中的落地代码
定义事件(它只是一个携带数据的 DTO):
php
namespace App\Events;
use App\Models\Product;
class LowStockEvent
{
public Product $product;
public function __construct(Product $product)
{
$this->product = $product;
}
}
在库存扣减方法中发布事件:
php
public function decreaseStock(Product $product, int $quantity)
{
$product->stock -= $quantity;
$product->save();
if ($product->stock < $product->threshold) {
event(new LowStockEvent($product)); // 仅发布事件
}
}
创建监听器(独立于库存服务):
php
namespace App\Listeners;
use App\Events\LowStockEvent;
use App\Services\NotificationService;
class LowStockListener
{
public function handle(LowStockEvent $event)
{
NotificationService::sendLowStockAlert($event->product);
}
}
然后在 EventServiceProvider 中注册监听关系。从此,库存服务不再知道“通知”二字。将来增加短信、钉钉,只需要添加新的监听器,库存代码纹丝不动。
效果:耦合解除了,但问题也才刚刚开始。
四、通知模块职责划分:别造“万能类”
解耦之后,我把所有通知逻辑塞进了 NotificationService,它既判断重复、又统计次数、还负责选渠道、发消息。两个月后,这个类膨胀到 800 行,改一个渠道可能影响另一个,测试都不敢动。
4.1 错误的设计
text
┌──────────────────────────────────────┐
│ NotificationService │
│ - 判断库存是否低于阈值(重复业务) │
│ - 查询今日已发次数 │
│ - 检查是否重复发送 │
│ - 选择发送渠道 │
│ - 调用微信/邮件/短信 API │
│ - 记录发送日志 │
└──────────────────────────────────────┘
这个类既管“该不该发”,又管“怎么发”,违反单一职责。
4.2 正确的分层
我把它拆成两层:
业务决策层(监听器 / 服务)
负责判断“是否应该发送”:
-
库存真的低于阈值了吗?
-
今天发送次数超限了吗?
-
距离上次发送是否超过 30 分钟?
-
商家关闭通知了吗?
通知执行层(渠道服务)
只负责“如何发送”:
-
WechatChannel::send($message) -
EmailChannel::send($message) -
SmsChannel::send($message)
这样,决策逻辑与渠道实现彻底分离。以后调整频控策略,只改业务层;更换短信供应商,只改渠道层。
php
// 监听器(业务决策)
class LowStockListener
{
public function handle(LowStockEvent $event)
{
if (! $this->shouldSend($event->product)) {
return;
}
$message = $this->buildMessage($event->product);
app(NotificationDispatcher::class)->send($message);
}
private function shouldSend(Product $product): bool
{
// 检查商家是否开启通知、今日次数、重复间隔……
return true;
}
}
// 分发器(选择渠道)
class NotificationDispatcher
{
public function send(Message $message)
{
foreach ($this->channels as $channel) {
$channel->send($message);
}
}
}
五、消息幂等:如何避免商家被“轰炸”
5.1 重复通知的源头
商品库存不是一次性从 100 降到 0,而是一点点减少。比如:
-
10:00 库存从 10 变为 4(低于阈值 5) → 发送一次;
-
10:05 库存从 4 变为 3(还是低于阈值) → 又发送一次;
-
10:10 库存从 3 变为 2 → 第三次……
商家在 10 分钟内收到三条几乎一样的“库存不足”提醒,必然投诉。
5.2 幂等方案:通过通知记录去重
引入一张 notification_records 表,记录每一次已发送的通知关键信息:
| merchant_id | product_id | type | sent_at |
|---|---|---|---|
| 1001 | 88001 | low_stock | 2026-08-08 10:00:00 |
在发送前,查询最近 30 分钟内是否已有相同商品、相同类型的通知记录。若有,则跳过本次发送。
php
private function shouldSend(Product $product): bool
{
$recent = NotificationRecord::where('merchant_id', $product->merchant_id)
->where('product_id', $product->id)
->where('type', 'low_stock')
->where('sent_at', '>=', now()->subMinutes(30))
->exists();
return ! $recent && /* 其他条件 */;
}
5.3 幂等思想的价值
幂等性(Idempotence)是分布式系统里的核心概念:同一个操作,执行一次和执行多次,结果保持不变。在支付回调、消息队列消费、订单处理中,幂等设计是防重复的关键。在这里我们只不过把思想用到了一个“小通知”上,但它背后的工程价值是相通的。
简单的去重,背后是严谨的幂等哲学。
六、频控设计:别把第三方服务打垮
6.1 为什么要限流?
假设某天由于系统异常,库存服务一次性生成了 10000 个 LowStockEvent,监听器会疯狂调用微信和邮件接口。微信接口限流,直接封禁我们的 IP;邮件服务商也报警了。更严重的是,商家手机被 10000 条微信炸得死机。
6.2 频控应该放在哪?
错误做法:把限流逻辑放到 WechatChannel 里,让渠道服务自己计数。但渠道服务不该理解“今天能发几条”这种业务规则。
正确做法:放在业务决策层(监听器或专门的中介服务),在调用渠道之前进行频控检查。
php
private function shouldSend(Product $product): bool
{
// 1. 检查重复
// 2. 检查今日总次数
$todayCount = NotificationRecord::where('merchant_id', $product->merchant_id)
->where('type', 'low_stock')
->whereDate('sent_at', today())
->count();
if ($todayCount >= 10) {
return false;
}
return true;
}
频控策略可以根据商户等级动态配置,未来甚至可以做到滑动窗口等更复杂的算法,但无论如何,决策层要清晰。
七、系统进一步演进:消息队列与异步化
当业务量再上一个台阶,事件监听器同步执行可能成为瓶颈。比如一次库存变化触发了 5 个监听器(微信、邮件、日志、数据分析、风控),总执行时间可能超过 1 秒,影响用户体验。
这时候,我们可以将事件发布到消息队列,由独立的消费者异步处理。
7.1 异步架构图
text
┌─────────────┐ 发布事件 ┌──────────────┐ 投递 ┌─────────────┐
│ 库存服务 │────────────▶│ 消息队列 │───────────▶│ 通知消费者 │
└─────────────┘ │ (Redis/Kafka)│ └──────┬──────┘
└──────────────┘ │
┌───────┴───────┐
│ 微信 / 邮件 │
└───────────────┘
7.2 Laravel 中的队列化
Laravel 的事件系统天然支持队列,只需让监听器实现 ShouldQueue 接口:
php
use Illuminate\Contracts\Queue\ShouldQueue;
class LowStockListener implements ShouldQueue
{
public function handle(LowStockEvent $event)
{
// 该监听器将被推送到队列,异步执行
}
}
优势:
-
异步:库存扣减后立即返回,不等待通知完成,响应更快。
-
失败重试:如果微信接口临时不可用,队列可以自动重试(可配置次数和间隔)。
-
削峰填谷:大量通知堆积时,消费者以可控速度消费,保护下游依赖。
-
可观测性:队列监控可以直观看到积压情况,方便扩容。
当然,异步也带来新挑战(如消息丢失、重复消费、顺序性问题),但这些都有成熟的解决方案,是工程师成长路上的必修课。
八、从一个通知功能,看后端工程能力成长地图
回顾整个过程,我把它整理成一条成长曲线:
| 阶段 | 做法 | 问题 | 解决方案 |
|---|---|---|---|
| 阶段1 | 在业务方法里直接调 API | 耦合严重、影响核心业务 | 事件驱动解耦 |
| 阶段2 | 把所有逻辑塞进一个通知类 | 职责混乱、难以维护 | 分层设计(决策层/执行层) |
| 阶段3 | 重复发送,骚扰商家 | 体验差、浪费资源 | 幂等 + 通知记录 |
| 阶段4 | 瞬时流量打垮第三方 | 系统不可用 | 频控 + 限流 |
| 阶段5 | 同步执行拖慢响应 | 性能瓶颈 | 消息队列异步化 |
每一个阶段都是对“工程思维”的锤炼。从“能跑就行”到“可靠、可维护、可扩展”,这个转变不是靠背诵设计模式,而是靠一次次踩坑后的反思。
真正优秀的后端工程师,不是能最快实现需求的人,而是能在设计阶段就预见到变化、给系统留出呼吸空间的人。
九、总结:从“堆代码”到“做设计”
写这篇文章时,我一直在想:如果当初接手那个“简单库存提醒”时,我能有现在的认知,会少走多少弯路?
一个看似不起眼的通知功能,串联起了事件驱动、解耦、幂等、频控、异步队列等一整套思想。这些思想并不仅限于“库存通知”,它们是构建任何可靠分布式系统的基石。
下次当你接到一个“发消息”的需求时,不妨问自己几个问题:
-
如果通知服务挂了,会影响核心业务吗?
-
重复发消息怎么办?
-
流量洪峰来了,下游扛得住吗?
-
今天加短信,明天加钉钉,我的代码需要改多少?
把这些问题的答案写进你的设计文档,而不是堆进代码里。你的系统,会感谢你。
希望这篇文章能给你带来一些启发。如果你也在实践中遇到过类似的困惑,欢迎在评论区交流你的故事。 🚀
更多推荐




所有评论(0)