电商秒杀在香港机房部署:Nginx 微缓存、Kafka 异步化与 MySQL 主从延迟治理

香港葵涌的机房里,我站在 18U 的半柜前,手心全是汗。倒计时 12 分钟,Slack 上的最后一条广播写着:“开关在灰度 50%,风控阈值按方案 B。”我抬头看了眼 ToR 交换机上闪烁的绿灯,脑子里把整条链路又过了一遍:BGP 入口—L4—Nginx 微缓存—应用—Kafka—MySQL 主—只读从—监控告警。这是我们把秒杀活动迁到香港机房后的第一战,成败在此一举。
1. 业务背景与目标
- 场景:百万 DAU 的电商应用,秒杀活动峰值 40–60 万 QPS 的静态读(商品详情、倒计时、库存只读)+ 3–5 万 QPS 的下单写入,主要来源于内地用户,跨境访问对链路质量极其敏感。
- 迁移动机:合规与网络路径优化,选择香港机房(直连 CN2/GIA/CMI 混合线路,延迟可控)。
- 目标:L7 微缓存命中率 ≥ 80%;2) 下单核心路径 P99 ≤ 120ms;3) MySQL 从库延迟 ≤ 300ms 并具备延迟感知路由;4) 全链路 可回滚、可演练、可观测。
- 操作系统:CentOS 7.9(核心业务内核参数已贴近生产最佳实践)。
2. 机房与网络拓扑
2.1 物理与网络
结构:
互联网用户 (CN/SEA)
|
Anycast/BGP 边界 (2 x ASR1001-X)
| 双上联 2 x 10G
[L4 LVS/Keepalived VIP]
| ECMP 4 x 25G
┌───────────────────────┐
| ToR (25G) |
└───────────────────────┘
| | |
[Nginx] [App] [Kafka]
| | |
[Redis] [MySQL 主] [监控/日志]
| |
[只读从 x2] [备份/归档]
- 入口:BGP Anycast + 两台边界路由器,黑洞与清洗策略对接运营商。
- L4:LVS/IPVS + Keepalived,VIP 漂移 < 1s,ECMP 到 Nginx 池。
- 交换:ToR 25G,上行 100G 聚合,MLAG。
2.2 硬件清单(关键节点)
| 角色 | 型号/规格 | CPU | 内存 | 存储 | 网卡 | 数量 |
|---|---|---|---|---|---|---|
| Nginx | Dell R650 | 2 x Intel 6348 | 128GB | 2 x 480G SATA | 2 x 25G SFP28 | 6 |
| 应用 | Dell R650 | 2 x Intel 6338 | 256GB | 2 x 960G NVMe | 2 x 25G SFP28 | 12 |
| Kafka Broker | Dell R740xd | 2 x Intel 6348 | 256GB | 8 x 1.92TB NVMe | 2 x 25G | 5 |
| MySQL 主 | Dell R750 | 2 x Intel 8352Y | 512GB | 8 x 3.84TB NVMe | 2 x 25G | 1 |
| MySQL 从 | Dell R750 | 2 x Intel 8352Y | 512GB | 8 x 3.84TB NVMe | 2 x 25G | 2 |
| Redis | Dell R650 | 2 x Intel 6338 | 256GB | 4 x 1.92TB NVMe | 2 x 25G | 3 |
说明:Kafka 使用 NVMe 本地盘,避免网络存储尾延迟;MySQL 单机 NVMe RAID10,控制器直通。
3. 系统与内核参数(CentOS 7)
网络栈:
# /etc/sysctl.d/99-surge.conf
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 250000
net.ipv4.tcp_max_syn_backlog = 262144
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 10000 65535
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_synack_retries = 2
net.ipv4.neigh.default.gc_thresh1 = 4096
net.ipv4.neigh.default.gc_thresh2 = 8192
net.ipv4.neigh.default.gc_thresh3 = 16384
net.nf_conntrack_max = 4194304
文件句柄与中断绑核:
# /etc/security/limits.conf
* soft nofile 1048576
* hard nofile 1048576
# irqbalance 关闭,手动绑核 (示例,视 NIC 队列调整)
for i in {0..15}; do echo $i > /proc/irq/$((80+i))/smp_affinity_list; done
4. Nginx 微缓存(Microcaching)
4.1 策略设计
- 缓存对象:商品详情 GET、SKU 价格/库存只读接口(非下单所用库存),倒计时与页面切片。
- TTL:1–3 秒,可按接口分级。核心详情页 2s,价格接口 1s。
- Key:`$scheme$host$request_uri$is_args$args|VARY:lang,device`(移除登录态 Cookie;多语言/端区分)。
- 防击穿:`proxy_cache_lock on` + `lock_timeout 5s`;`stale-while-revalidate` 风格回源。
- 一致性:库存写路径不读缓存;重要读(如“我的订单”)强制走主库。
4.2 配置片段(OpenResty/Nginx 1.20+)
proxy_cache_path /data/nginx/micro levels=1:2 keys_zone=MICRO:512m \
max_size=40g inactive=3s use_temp_path=off;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_background_update on; # 后台刷新
map $http_cookie $bypass_login {
default 0;
~*(session|token)= 1; # 登录态绕过缓存
}
map $http_accept_language $v_lang { default "zh-CN"; }
map $http_user_agent $v_device { default "mobile"; ~*"Windows|Mac" "pc"; }
server {
listen 80 reuseport;
server_name seckill.example.hk;
set $cache_key "$scheme$host$request_uri$is_args$args|$v_lang|$v_device";
location /api/v1/item/detail {
proxy_cache MICRO;
proxy_cache_key $cache_key;
proxy_cache_valid 200 206 2s;
proxy_cache_bypass $bypass_login $arg_nocache;
proxy_no_cache $bypass_login $arg_nocache;
proxy_ignore_headers Set-Cookie;
add_header X-Cache-Status $upstream_cache_status;
proxy_next_upstream error timeout http_502 http_504;
proxy_connect_timeout 1s;
proxy_read_timeout 0.8s;
proxy_send_timeout 0.8s;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_pass http://app_backend_pool;
}
# 防刷:
limit_req_zone $binary_remote_addr zone=req_100:20m rate=100r/s;
limit_req_status 429;
location / {
limit_req zone=req_100 burst=200 nodelay;
}
}
upstream app_backend_pool {
server 10.0.1.11:8080 max_fails=2 fail_timeout=3s;
server 10.0.1.12:8080 max_fails=2 fail_timeout=3s;
keepalive 512;
}
4.3 效果与指标
| 指标 | 活动前(无微缓存) | 活动后(开启微缓存) |
| 详情页平均 QPS(单节点) | 15k | 85k |
| Nginx CPU 占用(单核等效) | 70% | 42% |
| 回源比例 | 92% | 18% |
| 详情页 P99 | 180ms | 48ms |
坑 1:初期忽略了 `Set-Cookie` 的回源打标,导致命中率被登录态污染。解决:`proxy_ignore_headers Set-Cookie` 并基于 Cookie 映射 cache\_bypass。
坑 2:瞬时击穿导致上游连接耗尽。解决:`proxy_cache_lock` + 合理 `burst`,并对 SKU 热点做静态化预热(后台更新)。
5. Kafka 异步化
5.1 主题与分区
* `order.created`(48 分区,RF=3)
* `order.paid`(24 分区,RF=3)
* `inventory.decr`(96 分区,RF=3,按 SKU Key 保序)
5.2 Broker/Topic 关键参数
| 参数 | 值 | 说明 |
num.partitions |
48/96 | 按峰值与消费并行数计算 |
replication.factor |
3 | 三副本抗单点 |
min.insync.replicas |
2 | 与 acks=all 配合 |
log.segment.bytes |
1GB | 减少段切换 |
log.retention.hours |
72 | 活动三天回溯窗口 |
unclean.leader.election.enable |
false | 保证数据一致性 |
5.3 Producer(Java)
Properties p = new Properties();
p.put("bootstrap.servers", "kafka1:9092,kafka2:9092,kafka3:9092");
p.put("acks", "all");
p.put("enable.idempotence", true);
p.put("max.in.flight.requests.per.connection", 5);
p.put("retries", Integer.MAX_VALUE);
p.put("compression.type", "lz4");
p.put("linger.ms", 5);
p.put("batch.size", 262144); // 256KB
p.put("delivery.timeout.ms", 120000);
KafkaProducer<String, byte[]> producer = new KafkaProducer<>(p);
producer.send(new ProducerRecord<>("order.created", userId, payload));
5.4 Consumer(Go,按 SKU 分区保序)
cfg := sarama.NewConfig()
cfg.Version = sarama.V2_8_0_0
cfg.Consumer.Group.Rebalance.Strategy = sarama.BalanceStrategyRange
cfg.Consumer.Fetch.Default = 2 * 1024 * 1024
cfg.Net.MaxOpenRequests = 5
// 每分区一个 worker,单分区内串行,避免库存扣减的乱序
5.5 实战要点
- 回压:消费者端限速 + Dead Letter Topic(失败重试 3 次入 `*.dlq`)。
- 幂等:订单号为业务幂等键,MySQL `UNIQUE(order_no)` 避免重复入库。
- 再均衡抖动:活动期间固定消费组并关闭自动伸缩,避免频繁 rebalance。
> 坑 3:批量太大(512KB)导致单批延迟上升。调整到 256KB + 5ms `linger`,吞吐/延迟平衡更佳。
6. MySQL 主从延迟治理
6.1 版本与参数
版本:MySQL 8.0.30(GTID + 并行复制显著优于 5.7)。
复制:半同步 + 并行复制
# /etc/my.cnf
server_id = 1001
gtid_mode = ON
enforce_gtid_consistency = ON
binlog_format = ROW
binlog_row_image = FULL
sync_binlog = 1
rpl_semi_sync_master_enabled = ON
rpl_semi_sync_master_timeout = 1000
rpl_semi_sync_slave_enabled = ON
# InnoDB
innodb_buffer_pool_size = 360G
innodb_flush_log_at_trx_commit = 1
innodb_flush_method = O_DIRECT
# 并行复制
slave_parallel_type = LOGICAL_CLOCK
slave_parallel_workers = 16
relay_log_recovery = ON
# 连接池
max_connections = 40000
6.2 延迟感知与读写路由
策略:
- 读优先路由到从库,但当从库延迟 > 300ms 或活跃事务数 > 阈值时自动切回主库;
- 秒杀核心读(库存、下单页)活动窗口内强制读主;
- Cache 旁路:热点 SKUs 使用 Redis 只读缓存,不影响下单路径。
实现:ProxySQL + 心跳表 + `Seconds_Behind_Master` 与 `gtid_executed` 差值双指标判定。
-- 心跳
CREATE TABLE cluster_heartbeat (ts TIMESTAMP NOT NULL, id TINYINT NOT NULL, PRIMARY KEY (id)) ENGINE=InnoDB;
-- 应用侧每 100ms 读取 ts 差值估算复制延迟
6.3 库存扣减模型(强一致)
写路径:应用发起下单 → MySQL 主悲观锁扣减库存(或 `UPDATE ... WHERE stock > 0`)→ 成功后写 `order.created` → 异步后置处理(发券、消息、物流)。
热点冲突:对超级热点 SKU 使用Redis + Lua 预扣(库存阈值-100 的安全水位),回写 MySQL 主。一旦 Redis 与主不一致,优先以主为准并短期关闭该 SKU 的预扣。
-- Redis 预扣示例
local k = KEYS[1]
local decr = tonumber(ARGV[1])
local stock = tonumber(redis.call('GET', k) or '0')
if stock >= decr then
redis.call('DECRBY', k, decr)
return 1
else
return 0
end
> 坑 4:从库并行复制 `slave_parallel_workers` 过高会出现锁竞争和回放抖动。最佳点在 16(我们的数据/事务形态下),高于 24 反而抖动增加。
> 坑 5:仅凭 `Seconds_Behind_Master` 不可靠,在 I/O 线程卡顿时会“看起来很好”。必须结合 GTID 差值与心跳表。
7. 风控与限流
7.1 体系
- 设备指纹 + IP 信誉 + UA 规则 + 行为评分(点击速率、页面序、Referer 连贯性)
- 多级开关:灰度、阈值、白名单、黑名单、验证码、排队页
7.2 Nginx 层限速/连接
# IP 限速
limit_req_zone $binary_remote_addr zone=ip_50:20m rate=50r/s;
# 并发连接限制
limit_conn_zone $binary_remote_addr zone=addr:10m;
server {
location /api/ {
limit_req zone=ip_50 burst=100 nodelay;
limit_conn addr 50;
}
}
7.3 行为风控(应用侧)
- 超阈值直接丢到排队页(静态资源,CDN/微缓存友好)。
- 风控事件入 `risk.events` 主题,实时侧写到 ClickHouse 做回溯。
8. 演练与上线清单
8.1 演练项目
- 缓存命中回归:逐接口压测,命中率≥80%
- 回源保护:关闭后端 50% 实例,验证 `proxy_cache_lock` 有效
- Broker 故障:下线 1 台 Kafka,验证 ISR 收敛与消费无感
- 主从延迟注入:从库人为延迟 1s,验证路由回切主库
- 入口黑洞:模拟 DDoS,运营商清洗回注流
- 回滚:一键切换到老链路(CDN → 老机房),DNS/路由 TTL 验证
8.2 上线变更单(节选)
| 步骤 | 动作 | 验收点 |
| 1 | 开启 Nginx 微缓存 20% 灰度 | 命中率≥50%,5 分钟稳定 |
| 2 | Kafka Producer 改为 acks=all |
无报错,端到端延迟 < 30ms |
| 3 | ProxySQL 延迟阈值从 800ms→300ms | 读主比例短时上升,错误率不增 |
| 4 | 打开风控方案 B(验证码动态开) | 5xx 不升、429 轻微上升可接受 |
9. 压测结果(节选)
| 场景 | QPS | P50 | P90 | P99 | 错误率 |
| 详情页(微缓存 ON) | 500k | 9ms | 18ms | 48ms | 0.02% |
| 下单(读主+Redis 预扣) | 35k | 22ms | 55ms | 118ms | 0.15% |
| Kafka 端到端(order.created→消费者) | 100k 消息/s | 7ms | 14ms | 26ms | - |
| MySQL 主从延迟 | - | - | - | ≤ 220ms | - |
压测工具:wrk/ghz + 自研流量回放;生产流量样本脱敏后重放。
10. 监控与告警
- Nginx:`$upstream_cache_status`、`accepted/s`、`active connections`、`499/502/504` 比例、后端回源率
- Kafka:ISR 变化、`RequestQueueTimeMs`、`Purgatory` 长度、`UnderReplicatedPartitions`
- MySQL:`Seconds_Behind_Master`、GTID 差、`Innodb_row_lock_time`、`Threads_running`
- 系统:NIC 丢包、软中断占比、上下文切换、磁盘写放大
11. 现场坑点与快速修复
- TIME\_WAIT 暴涨:Nginx 后端未启用 HTTP/1.1 keepalive,修复 `proxy_http_version 1.1` + `Connection: ""`。
- conntrack 满:入口 L4 未调 `nf_conntrack_max`,峰值被打爆,扩容到 4M 并分配足够哈希桶。
- Kafka Rebalance 风暴:监控探针误把消费者进程重启为“修复”,导致一小时内 7 次再均衡。修复:活动窗口内禁止自动滚动升级。
- NVMe 温度告警:机柜散热不足,改风道、加盲板并限速后台批处理 IO。
- MySQL 自增热点:订单号自增热点页锁,改为随机前缀雪花 ID,B+Tree 分散。
12. 回滚与故障预案
- 灰度闸门:所有变更可在 30 秒内回到“老路径”(CDN → 老机房集群)。
- 数据一致性:订单写主—Kafka—异步消费者,若需回滚,确保消费者幂等并可从时间点重放(基于时间戳/偏移量)。
- 只读降级:详情页降级到纯静态,库存展示延迟提示“约 1–2 秒”。
13. 凌晨 2:17 的那通电话
活动开始 8 分钟后,监控台一片绿色,命中率攀到 83%,下单 P99 稳在 110ms。Kafka 的 ISR 纹丝不动,MySQL 从库延迟 180–220ms 波动。我靠着机柜坐下,给同城的同事打了个电话:“可以把灰度拉到 100% 了。”
他笑,说:“刚才风控把一个 Selenium 军团拦了 70%。”
我看着屏幕上那条细细的延迟曲线,像维港夜里一条平静的航线。第二天清晨,我们把关键参数、演练记录和异常处置全写进了 Runbook。不是因为这次毫无波折,而是因为每一个被修掉的坑,都是下一次的护城河。
这篇记录给未来的我:记住微缓存只是第一层,真正的稳定来自可回滚的架构、可验证的演练,以及对现场细节的偏执。
14. 附:参数速查表
Nginx
- proxy_cache_valid 200 206 2s
- proxy_cache_lock on` / `proxy_cache_background_update on
- limit_req rate=100r/s burst=200
- keepalive 512` / `reuseport
Kafka
- replication.factor=3` / `min.insync.replicas=2
- enable.idempotence=true` / `acks=all
- linger.ms=5` / `batch.size=262144` / `compression.type=lz4
MySQL 8.0
- slave_parallel_workers=16
- binlog_format=ROW` / `gtid_mode=ON
- innodb_flush_log_at_trx_commit=1
- sync_binlog=1
如果你正在准备把秒杀迁到新的机房,愿这些一手坑与参数,能让你少走两步弯路。