上一篇 下一篇 分享链接 返回 返回顶部

香港服务器运行Windows Server时,如何通过IIS与Redis缓存结合,优化跨境电商秒杀活动的并发处理?

发布人:Minchunlin 发布时间:2025-09-07 10:26 阅读量:602


跨境电商平台的秒杀倒计时只剩 20 分钟,我们把最后两台 Windows Server 2019 Datacenter 的补丁打完重启,IIS 应用池升为 AlwaysRunning,Redis 哨兵状态是 ok。

微信群里,前端同学在做最后的倒计时页压测;我这边盯着 IIS 每秒请求数、Redis ops、以及 SQL Server 等待类型。这次我们决心把“削峰填谷”做彻底:资格发放在 Redis,扣减在 Lua 原子脚本,订单入队 Redis Stream 异步落库。
3 点整,秒杀开闸。第一波峰值 18 万 QPS 打过来,我深吸一口气,看着 99 线延迟从 1.3s 慢慢回落到 280ms——这次,稳了。

1. 目标与思路

目标

  • 承载峰值 15–20 万 QPS 的瞬时读流量
  • 订单通道可控在 5–8 千 QPS,保证库存与“每人限购 1 件”的强一致
  • 把 99 线接口延迟压到 300ms 内,错误率 < 0.5%

思路

IIS 负责接入与静态加速,开启内核缓存、压缩、连接复用,扩充队列长度。

Redis 做“四板斧”:

  • 限流(IP/用户双维度)
  • 资格票(令牌桶/预发券)
  • 原子扣减(Lua 保证库存与去重)
  • 异步下单(Redis Stream 队列 + 后台消费者)

数据库只吃“平滑后的订单流”,避免被尖峰直击。

全链路观测(IIS 日志 + PerfCounter + Redis slowlog + 业务日志),及时熔断/降级。

2. 拓扑与硬件

2.1 逻辑拓扑

  • 边缘/CDN(秒杀静态页、倒计时、图片):降低跨境 RTT
  • L4 LVS/HAProxy(两台冗余,VIP 漂移):均衡到 IIS
  • 应用层:4 台 Windows Server 2019 + IIS 10(ASP.NET Core In-Process 托管)
  • 缓存层:3 节点 Redis(Ubuntu 22.04)+ Sentinel,1 主 2 从,专用内网
  • DB 层:SQL Server 2019 Always On(读写分离 + 只写主库)

2.2 设备清单(实际参数示例)

角色 数量 CPU 内存 磁盘 网卡 OS
LVS/HAProxy 2 Xeon Silver 4214R ×2 64 GB NVMe 960GB 10GbE Ubuntu 22.04
应用(IIS) 4 Xeon Gold 6226R ×2 128 GB NVMe 1.92TB 10GbE Windows Server 2019 Datacenter
Redis 3 Xeon Silver 4210 64 GB NVMe 960GB 10GbE Ubuntu 22.04
SQL 主 1 Xeon Gold 6248R ×2 256 GB SSD RAID10 7.6TB 10GbE Windows Server 2019
SQL 从 1 Xeon Gold 6248R ×2 256 GB SSD RAID10 7.6TB 10GbE Windows Server 2019

网络:香港多线 BGP,核心交换 10GbE,业务网与管理网隔离;Redis、DB 与应用在同一可用区内,RTT 0.2–0.4ms。

3. Redis 层设计

3.1 部署要点(Ubuntu)

# 基础安装
apt update && apt install -y redis-server

# redis.conf 关键参数
bind 0.0.0.0
protected-mode yes
requirepass <StrongPass>
maxmemory 8gb
maxmemory-policy allkeys-lru
appendonly yes
appendfsync everysec
tcp-backlog 65535
tcp-keepalive 60
save 900 1 300 10 60 10000

哨兵(sentinel.conf)示例

port 26379
sentinel monitor ms1 10.10.10.21 6379 2
sentinel auth-pass ms1 <StrongPass>
sentinel down-after-milliseconds ms1 5000
sentinel failover-timeout ms1 60000
sentinel parallel-syncs ms1 1

经验:秒杀时 Redis 的单核性能是瓶颈关键。尽量让热点 Key 落在同一槽位,并把 Lua 脚本做成短、小、定长参数,避免大对象序列化。

3.2 Key 设计(使用哈希标签避免 CROSSSLOT)

  • stock:{skuId} 库存数(String, int)
  • buyers:{skuId} 已购用户集合(Set)
  • orders:{skuId} 订单 Stream(XADD)
  • rate:ip:{ip}:{window}、rate:u:{uid}:{window} 限流计数(String)

说明:{skuId} 用花括号包裹,确保多 Key 操作在同一哈希槽,Lua 原子脚本才不会报 CROSSSLOT。

3.3 限流(漏斗/滑动窗口)

滑动窗口(1 秒 10 次/用户)Lua:

-- KEYS[1] = rate key
-- ARGV[1] = windowSec
local cur = redis.call('INCR', KEYS[1])
if cur == 1 then
  redis.call('EXPIRE', KEYS[1], tonumber(ARGV[1]))
end
return cur

3.4 原子扣减 + 去重 + 入队(Lua)

-- KEYS[1]=buyers:{skuId} KEYS[2]=stock:{skuId} KEYS[3]=orders:{skuId}
-- ARGV[1]=uid ARGV[2]=orderId ARGV[3]=buyersTTL
if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then
  return -1 -- 已购
end
local s = tonumber(redis.call('GET', KEYS[2]) or "0")
if s <= 0 then
  return 0 -- 无库存
end
redis.call('DECR', KEYS[2])
redis.call('SADD', KEYS[1], ARGV[1])
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
redis.call('XADD', KEYS[3], '*', 'uid', ARGV[1], 'oid', ARGV[2])
return 1

4. IIS 与 Windows 2019 调优

4.1 IIS 站点与应用池

应用池:

  • Start Mode = AlwaysRunning
  • Idle Time-out (min) = 0
  • Queue Length = 50000(默认 1000,秒杀容易打满 503)

回收:固定时间错峰(比如 4:30),避免活动期回收

  • 站点 Limits:
  • Connection Timeout = 120
  • maxConnections = 0 (Unlimited)

开启 HTTP/2(Windows 2019 支持,减少握手开销)

内核缓存与压缩

<system.webServer>
  <caching enabled="true" enableKernelCache="true">
    <profiles>
      <add extension=".js" policy="CacheForTimePeriod" duration="01:00:00" kernelCachePolicy="CacheForTimePeriod"/>
      <add extension=".css" policy="CacheForTimePeriod" duration="01:00:00" kernelCachePolicy="CacheForTimePeriod"/>
    </profiles>
  </caching>
  <httpCompression>
    <scheme name="gzip" dll="%Windir%\system32\inetsrv\gzip.dll" />
    <dynamicTypes>
      <add mimeType="application/json" enabled="true" />
      <add mimeType="text/*" enabled="true" />
    </dynamicTypes>
  </httpCompression>
</system.webServer>

4.2 TCP 参数(注册表)

目标:扩展临时端口、降低 TIME_WAIT 占用、保持长连

注册表路径 HKLM\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters:

  • MaxUserPort = 65534 (DWORD)
  • TcpTimedWaitDelay = 30 (DWORD)
  • TcpKeepAliveInterval = 1 (DWORD)
  • TcpKeepAliveTime = 600000 (DWORD, 10 分钟)

更改后重启生效。IIS 与 Redis 客户端也要开启 KeepAlive。

4.3 ASP.NET Core 托管(In-Process)

web.config 关键片段:

<configuration>
  <system.webServer>
    <handlers>
      <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" resourceType="Unspecified"/>
    </handlers>
    <aspNetCore processPath="dotnet" arguments="Seckill.Web.dll" stdoutLogEnabled="true" hostingModel="InProcess">
      <environmentVariables>
        <add name="ASPNETCORE_ENVIRONMENT" value="Production" />
        <add name="DOTNET_GCServer" value="1" />
      </environmentVariables>
    </aspNetCore>
  </system.webServer>
</configuration>

5. 应用层实现(C# / ASP.NET Core)

5.1 依赖与连接

// Program.cs
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IConnectionMultiplexer>(sp =>
{
    var cfg = ConfigurationOptions.Parse("10.10.10.21:6379,10.10.10.22:6379,10.10.10.23:6379,password=StrongPass");
    cfg.AbortOnConnectFail = false;
    cfg.ConnectRetry = 5;
    cfg.KeepAlive = 60;   // 秒
    cfg.AllowAdmin = false;
    cfg.SyncTimeout = 5000;
    cfg.ConnectTimeout = 2000;
    return ConnectionMultiplexer.Connect(cfg);
});

builder.Services.AddSingleton<IDatabase>(sp => sp.GetRequiredService<IConnectionMultiplexer>().GetDatabase());

var app = builder.Build();

5.2 中间件:双维度限流(IP + 用户)

app.Use(async (ctx, next) =>
{
    var db = ctx.RequestServices.GetRequiredService<IDatabase>();
    var ip = ctx.Connection.RemoteIpAddress?.ToString() ?? "0.0.0.0";
    var uid = ctx.User?.Identity?.Name ?? ctx.Request.Headers["X-UID"].ToString();
    var nowSec = DateTimeOffset.UtcNow.ToUnixTimeSeconds();

    string ipKey = $"rate:ip:{ip}:{nowSec}";
    string uKey  = $"rate:u:{uid}:{nowSec}";

    // 1 秒窗,IP 50 次,用户 10 次
    var script = LuaScript.Prepare(@"
        local cur = redis.call('INCR', KEYS[1])
        if cur == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end
        return cur
    ");

    var ipCnt = (long)db.ScriptEvaluate(script, new RedisKey[] { ipKey }, new RedisValue[] { 1 });
    var uCnt  = (long)db.ScriptEvaluate(script, new RedisKey[] { uKey  }, new RedisValue[] { 1 });

    if (ipCnt > 50 || uCnt > 10)
    {
        ctx.Response.StatusCode = StatusCodes.Status429TooManyRequests;
        ctx.Response.Headers["Retry-After"] = "1";
        await ctx.Response.WriteAsync("Too Many Requests");
        return;
    }
    await next();
});

5.3 原子扣减 + 入队下单

app.MapPost("/api/seckill/{skuId}", async (HttpContext ctx, string skuId, IDatabase db) =>
{
    var uid = ctx.User?.Identity?.Name ?? ctx.Request.Headers["X-UID"].ToString();
    if (string.IsNullOrEmpty(uid)) return Results.Unauthorized();

    var buyers = (RedisKey)$"buyers:{{{skuId}}}";
    var stock  = (RedisKey)$"stock:{{{skuId}}}";
    var stream = (RedisKey)$"orders:{{{skuId}}}";

    var orderId = Guid.NewGuid().ToString("N");
    var script = LuaScript.Prepare(@"
        if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then return -1 end
        local s = tonumber(redis.call('GET', KEYS[2]) or '0')
        if s <= 0 then return 0 end
        redis.call('DECR', KEYS[2])
        redis.call('SADD', KEYS[1], ARGV[1])
        redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
        redis.call('XADD', KEYS[3], '*', 'uid', ARGV[1], 'oid', ARGV[2])
        return 1
    ");

    var ret = (long)db.ScriptEvaluate(script,
        new RedisKey[] { buyers, stock, stream },
        new RedisValue[] { uid, orderId, 86400 });

    return ret switch
    {
        -1 => Results.Conflict("Duplicate purchase"),
        0  => Results.Gone("Sold out"),
        1  => Results.Accepted($"/api/order/{orderId}", new { orderId }),
        _  => Results.StatusCode(500)
    };
});

5.4 后台消费者(订单落库)

// Worker: 预先创建消费组 XGROUP CREATE orders:{sku} g1 $ MKSTREAM
while (true)
{
    var res = await db.StreamReadGroupAsync($"orders:{{{skuId}}}", "g1", "c1",
        new RedisValue(">").ToString(), count: 200, autoAcknowledge: false);

    foreach (var msg in res)
    {
        var uid = msg.Values.First(v => v.Name == "uid").Value.ToString();
        var oid = msg.Values.First(v => v.Name == "oid").Value.ToString();
        // 幂等落库(以订单号为幂等键)
        await SaveOrderIfNotExistsAsync(oid, uid, skuId);
        await db.StreamAcknowledgeAsync($"orders:{{{skuId}}}", "g1", msg.Id);
    }
}

6. 压测与效果

6.1 测试场景

  • 读流量:倒计时页 + 商品详情 + 库存查询,峰值 180k QPS
  • 下单接口:峰值 8k QPS(被资格与队列限流后)
  • 网络:中国内地多省移动/联通/电信,香港回源 RTT 30–80ms

6.2 前后对比

指标 优化前 优化后
峰值下单 99 线延迟 1.8 s 280 ms
错误率(5xx/4xx) 3.2% 0.37%
IIS 队列溢出(503) 频繁 (QueueLength 50k)
Redis CPU 峰值 95%(单核打满) 62%(脚本瘦身 + 同槽)
SQL 写入峰值 11k QPS(抖动) 6.5k QPS(平滑)

7. 现场遇到的坑与解法

IIS 503 突发

现象:活动开始第 3 分钟出现少量 503。

原因:应用池队列满(默认 1000)。

处理:Queue Length = 50000,同时前置限流,使应用层保持可用。

Redis -CROSSSLOT 报错

现象:早期用普通 key,Lua 多 Key 操作报错。

解法:统一使用 buyers:{skuId}、stock:{skuId}、orders:{skuId},哈希标签强制同槽。

Redis CPU 飙升

现象:XADD 字段过多 + Lua 拼接大 JSON。

解法:Stream 只写必要字段(uid, oid),大对象放 DB;脚本出参改短整型。

TIME_WAIT 堆积

现象:应用服务器临时端口耗尽导致连接失败。

解法:注册表扩端口 + 降低 TcpTimedWaitDelay,并强制启用 KeepAlive。

应用池回收卡点

现象:IIS 默认回收导致瞬时 502。

解法:把回收时间错峰到活动结束后;开启 Overlapped Recycling 但避免在高峰触发。

DB 死锁与热点

现象:订单插入时对库存表竞争严重。

解法:库存以 Redis 为主,DB 仅做最终一致;订单表分区 + 索引覆盖 + 幂等写。

8. 运营/风控与降级策略

  • 多级限流:CDN 边缘(WAF/IP 黑白)、LVS(连接)、IIS(队列)、应用(用户/接口)
  • 灰度/熔断:下单通道降至 3k QPS、只读详情页继续服务
  • 幂等:订单以 orderId 或 (uid, skuId) 做幂等键
  • 降级页:预置“排队中”页面 + Retry-After 指引

观测:

  • IIS:Requests/Sec、Current Connections、Queue Length
  • Redis:instantaneous_ops_per_sec、used_cpu_sys、rejected_connections、slowlog
  • 业务:Lua 返回码分布、Stream 堵塞长度、消费者滞后

9. 标准化上线清单(可直接对表执行)

Redis

  •  主从 + Sentinel 正常切换演练
  •  maxmemory、appendonly、appendfsync everysec
  •  热点 Key 使用 {} 哈希标签
  •  slowlog 开启并阈值 ≤ 5ms

IIS / Windows

  •  应用池 AlwaysRunning、QueueLength >= 50000、回收错峰
  •  HTTP/2、内核缓存、Gzip 动态压缩
  •  注册表 MaxUserPort、TcpTimedWaitDelay、KeepAlive
  •  文件句柄/事件日志预留磁盘空间(压测中很容易爆)

应用

  •  双维度限流中间件
  •  Lua 原子脚本 + Stream 入队
  •  消费者幂等、重试、死信监控
  •  降级开关(配置中心/环境变量)

压测

  •  读压峰值 ≥ 预期 1.2 倍
  •  下单峰值以“资格”控制在可处理区间
  •  99 线延迟、错误率、GC 次数达标

10. FAQ 小抄

为什么不用数据库扣减?
秒杀是“尖峰 + 冲突”典型场景,DB 原子扣减写放大、锁竞争严重;把强一致前置到 Redis(原子 + 单线程)是更稳的思路,DB 只负责最终一致与审计。

Redis Stream vs List?
Stream 有消费组、位点管理、可观测性更好;List 简单但容错差。我们线上用 Stream。

StackExchange.Redis 连接池需要吗?
官方 Multiplexer 已复用连接,不要每次请求创建连接。统一 Singleton。

结尾|清晨 4:15 的出风口

风口的冷气有点呛,我把最后一条告警静音,Redis 的 ops 从 20 万/秒慢慢降下来,IIS 的队列也见底了。群里开始刷用户晒单与客服感谢。
这夜我们没有奇技淫巧,只是在Windows 2019 + IIS这条老牌栈上,把“限流—资格—原子—异步”的路径走到了极致。
隔着机房玻璃,我看见港铁的第一班车穿过黑夜。那一刻我知道,下次秒杀来时,这套系统仍然能稳住——因为它是用一晚上、一行行日志、一遍遍压测换来的。

附:可复用配置与代码清单(便于拷贝)

1) redis.conf 关键项

bind 0.0.0.0
requirepass <StrongPass>
maxmemory 8gb
maxmemory-policy allkeys-lru
appendonly yes
appendfsync everysec
tcp-backlog 65535
tcp-keepalive 60

2) 哨兵 sentinel.conf

sentinel monitor ms1 10.10.10.21 6379 2
sentinel auth-pass ms1 <StrongPass>
sentinel down-after-milliseconds ms1 5000
sentinel failover-timeout ms1 60000

3) IIS web.config 片段(In-Process)

<aspNetCore processPath="dotnet" arguments="Seckill.Web.dll" stdoutLogEnabled="true" hostingModel="InProcess"/>

4) 限流中间件(C#) — 见上文 5.2

5) 原子扣减 Lua — 见上文 3.4

6) 订单消费者骨架 — 见上文 5.4

如果你也要在香港服务器 + Windows Server 2019 + IIS + Redis上扛一场跨境秒杀,照着本文把拓扑—参数—代码—压测四件事走通,基本就能把系统从“能跑”推到“能扛”。

目录结构
全文