1.2 选型对比:PHP 能不能扛秒杀

场景:语言选型会上吵了三小时

翻车之后开复盘会,第一个被质疑的就是语言:咱这秒杀为什么不直接上 Go 或者 Java?PHP 一个请求一个进程,能扛得住 20 万并发吗?争论了三轮,我们还是选择用 PHP 重写秒杀链路。理由不是 PHP 更快,而是综合成本最划算。

先说一条贯穿全书的结论:秒杀系统的瓶颈几乎从来不在语言本身,而在数据库、库存一致性和接入层削峰。PHP 8 加上 OPcache 与 FPM 调优,单机撑住每秒数千个动态请求没有问题,而这已经远超实际需要处理量——因为洪峰流量在到达 PHP 之前,就被 CDN 和限流削掉了大半。真正考验系统的是 Redis 库存扣减的原子性和 MySQL 的对账兜底,这两件事用什么语言都要做。

再补充一个很实际的角度:维护成本。秒杀活动不是天天有,秒杀链路的代码平时 99% 的时间在空转。如果为它单独引入一套 Go 微服务,意味着团队要长期维护两套技术栈、两套监控、两套发版流程,值班同学半夜被叫起来还得先想这是哪个语言的服务炸了。用 PHP 把秒杀链路写进既有 Laravel 项目里,监控、日志、发版全部复用,维护成本只有前者的零头。对小团队来说,这个差距比压测跑分重要得多。

方案:四种语言做秒杀的横向对比

我们当时认真评估了四个方案,对比维度包括团队学习成本、库存扣减性能、异步能力和基建成熟度,结论汇总如下:

方案 单机吞吐 团队迁移成本 异步能力 最终评价
PHP 8 + Laravel 中高 极低,全套现成 队列与协程可选 瓶颈不在语言,首选
Go 高 高,需重写全部业务 原生并发 性能好但团队要重新学
Java + Spring 高 高,依赖重 线程池成熟 中间件多,小团队运维不动
Node.js 中高 中 事件循环 单线程对 CPU 密集扣减不友好

选 PHP 的关键依据是:我们 90% 的业务逻辑(商品、订单、营销、售后)都跑在 Laravel 上。秒杀链路只有库存扣减这一个热点,为这一个热点换语言,等于把整个电商域重写一遍,风险远大于收益。正确做法是把热点路径隔离出来做专项优化,这也是本书的结构:业务归业务,热点归热点。另外提一句,PHP 与 Go 并不互斥,后来我们把消耗 CPU 的图片压缩和长链生成这种边缘活儿拆给了 Go 写的小工具,主链路仍然是 PHP,各取所长。

我们最终的技术分工是:云服务器 CVM 上跑 Laravel 应用,云数据库 Redis 承担库存扣减这一唯一的热点写操作,云数据库 MySQL 做商品与订单的持久化,CDN 与日志服务 CLS 分别负责静态加速与日志归集。这几个产品的文档入口分别是 cloud.tencent.com/document/product/213、cloud.tencent.com/document/product/239、cloud.tencent.com/document/product/236、cloud.tencent.com/document/product/228、cloud.tencent.com/document/product/614。

配置:让 Laravel 在秒杀场景跑得动的三个设置

语言选型定下来,有三个配置必须到位,否则 PHP 再快也白搭。

第一个是数据库读写分离。秒杀期间有大量核对类查询,读走从库可以显著降低主库压力,但必须开启 sticky,避免下单后立刻读从库、因主从延迟把真实库存误判为未扣:

// config/database.php:读写分离,写后立即读主库
'mysql' => [
    'read'  => ['host' => env('DB_READ_HOST')],
    'write' => ['host' => env('DB_WRITE_HOST')],
    'sticky' => true,
    'driver' => 'mysql',
],

第二个是把缓存与队列驱动切到 Redis,让热点路径完全跑在内存里:

// .env 关键项
QUEUE_CONNECTION=redis
CACHE_DRIVER=redis
SESSION_DRIVER=cookie  // 秒杀域不依赖 session,减少一次 Redis 往返

第三个是 OPcache。没有 OPcache 的 PHP 每次请求都要重新编译字节码,洪峰时 CPU 会率先成为瓶颈;开启后请求处理耗时能降低一半以上。这里有个数字可以参考:我们实测开启 OPcache 前后同样一台 8 核 CVM,处理能力从每秒约 1200 个请求提升到 2800 个,提升非常明显。注意发版用平滑重启而不是直接改文件,否则缓存校验失效那一瞬间的请求全部打到重新编译上:

; php.ini 秒杀场景推荐配置
opcache.enable=1
opcache.validate_timestamps=0
opcache.memory_consumption=256
opcache.max_accelerated_files=20000

FPM 进程数也必须配套调优。我们的经验值给在下面,核心思想是进程数不是越大越好,而是要和下游数据库连接数匹配:

参数 日常值 大促值 说明
pm.max_children 128 256 单机并发处理上限
pm.start_servers 16 32 启动即拉起,避免冷启动
pm.max_requests 1000 500 大促主动重启,释放可能泄漏的内存
request_terminate_timeout 30 10 秒杀接口 10 秒不返回必有异常

大促期间把它改成 10 秒是一个很关键的决策:秒杀接口正常耗时在 100 毫秒以内,超过 10 秒说明已经在等 Redis 或者排队,继续等下去只会拖垮整台机器。直接超时返回失败,用户体验上也是尽早知道结果,符合目标三的要求。

踩坑:选型路上栽过的三跤

坑一:拿语言裸跑压测做选型依据。 同样是扣库存,Go 跑 10 万 QPS、PHP 跑 2 万 QPS,看着 Go 赢。但线上洪峰在接入层就被削到 5000 QPS 以内,PHP 绰绰有余。选型要看真实系统链路的容量,而不是单点裸跑跑分,这条经验后来写进了团队的技术选型规范。

坑二:忽略 FPM 进程数与数据库连接数的关系。 早期我们把 FPM 的 pm.max_children 调到 512 想提高并发,结果云数据库 MySQL 的连接数被打满,整个电商域集体报错。算式很简单:单机 FPM 进程数乘机器数必须小于 MySQL 连接上限,比如 4 台机器各 256 进程就是 1024 个连接,MySQL 实例的最大连接数必须按这个数字预留。这条算式后来写进了上线清单,具体见 1.3 节。

坑三:协程化改造的诱惑。 中间有人提议上 swoole 把 Laravel 协程化,压测确实翻倍,但全局状态与单例污染查了两周没查干净,最后回退。小团队的核心约束是排障成本,不是极限性能。我们最终只在队列消费者那一层做了批量处理的优化,这个案例在 3.3 节展开。

坑四:忽略收集火焰图就拍脑袋优化。 某次压测看到 P99 飙到 800 毫秒,大家凭感觉说是 Redis 慢,调了两天连接池参数毫无变化。后来老老实实用抓取工具采了一次火焰图,发现耗时大头居然在 Laravel 的权限中间件里做了一次无用的数据库查询。从那以后团队立了规矩:任何性能优化必须先有数据支撑,没有火焰图不准改代码。这条规矩在 5.1 节压测演练里也有体现。

小结

PHP 能不能扛秒杀?答案是能,前提是把热点隔离、把接入层削峰做好、把 FPM 与连接数调对。语言选型真正的决定因素是团队既有资产与运维成本,而不是跑分榜单。如果是全新项目且团队 Go 人手充足,直接上 Go 也完全合理,可以参考 Go 微服务 CPS 实战 的做法;想看 PHP 在纯接口场景的表现,可以翻 PHP API 设计实战。

选型决策一旦做出,就要给它配套的运维预算。我们选 PHP 的同时就承诺了三件事:OPcache 必须开、FPM 参数每季度复查、热点接口必须有独立的进程池。没有这三条配套,任何语言选型都只是嘴上说说。选型不是选英雄,而是选一整套生活方式,团队得准备好为这个选择持续投入。回到最初那个问题,小团队的选型标准其实只有一条:哪种技术能让现有的五个人在晚上十一点前下班,就选哪种。语言之争可以留给社区,业务与团队状态才是决策天平上最重的砝码。我们的技术账本里记着这笔选择的收益:同样的机器预算,PHP 方案让我们把省下来的人力投向了业务迭代,这才是选型真正的回报。

延伸阅读