要做什么:同一道题再答一遍
短链是一道被反复回答的经典题,需求很早就被定死了:长网址换 6 位 Base62 短码、并发插入靠唯一索引兜底重试、缓存挡读流量、302 跳回原址。这篇拿同一道题用 Minimal API + EF Core 再答一遍——不为评优劣,两份答卷放一起,各替你做了哪些决定才看得清。
| 指标 | 目标值 | 怎么测 |
|---|---|---|
| 跳转路径延迟 | 命中缓存不碰库,P99 与 Go 篇同口径(< 10ms) | wrk 对单短码压 30 秒 |
| 短码冲突率 | 0(唯一索引兜底) | 并发插入脚本打满重试上限 |
| 缓存命中率 | > 95% | 命中/未命中计数器 |
| 点击计数 | 不阻塞 302 响应 | 压测时看 P99 是否随计数波动 |
诚实声明:本文不写”本站实测”数字,指标只给验证方法,性能对比给定性与公开参考。
整体架构
GET /{code} 跳转(关键路径)
→ HybridCache(进程内存,击穿合并)
├─ 命中 → 直接 302(不碰数据库)
└─ 未命中 → EF Core 查 SQLite → 回填缓存 → 302
↓
Channel<string> → BackgroundService 批量累加点击
POST /shorten(非关键路径):校验 → Base62 → 唯一索引冲突重试
选型一句话:HTTP 用 Minimal API(路由与 DI 内建,无控制器样板);缓存用 HybridCache(.NET 9 起随框架提供,默认进程内存,自带击穿合并,比裸 IMemoryCache 省掉加锁);存储用 SQLite 人人可跑,换 MySQL 只换 Provider(Pomelo)。不要一上来就微服务,瓶颈在跳转路径。
难点一:短码生成与唯一约束重试
6 位 Base62 共 62^6 ≈ 568 亿组合,随机不可枚举,生成器用加密随机源:
const string Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
static string NewCode(int n = 6) =>
string.Create(n, 0, (span, _) => // string.Create 免中间分配
{
for (var i = 0; i < span.Length; i++)
span[i] = Alphabet[RandomNumberGenerator.GetInt32(62)]; // 加密随机
});
反模式一:应用层”先查再插”——两个请求同时查到”不存在”再同时插入,竞态必然出现。正确做法与 Go 篇一致:唯一性交给数据库唯一索引,插入失败重试:
app.MapPost("/shorten", async (string url, LinkDb db) =>
{
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)
|| (uri.Scheme != "http" && uri.Scheme != "https"))
return Results.BadRequest("url 必须是合法的 http(s) 地址");
for (var attempt = 0; attempt < 5; attempt++)
{
var code = NewCode();
db.Links.Add(new Link(code, url));
try
{
await db.SaveChangesAsync();
return Results.Ok(new { code });
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// 唯一索引冲突:换码重试;5 连撞概率约 (已用量/568亿)^5
db.ChangeTracker.Clear(); // 清掉失败实体,否则下次 Save 重复追踪
}
}
return Results.Conflict("短码空间告急");
});
// SQLite 约束冲突码 19;MySQL(Pomelo) 判 1062,与 Go 篇 isDupKey 同职责
static bool IsUniqueViolation(DbUpdateException ex) =>
ex.InnerException is SqliteException { SqliteErrorCode: 19 };
反模式二:catch DbUpdateException 不看内部异常就重试——连接失败、字段超长也会进来,盲重试只是把错误延迟五倍。when 过滤器把”冲突”从”故障”里剥出来。
难点二:缓存三件套一个不能少
用了缓存就必须把穿透、击穿、雪崩都想清楚,Go 篇如此,这里同样:
| 问题 | 本文解法 | 为什么 |
|---|---|---|
| 穿透(恶意打不存在的码) | 负缓存:未找到也缓存 60 秒 | 否则每次都打穿到数据库 |
| 击穿(热点码过期瞬间打垮库) | HybridCache 同键并发合并,工厂只跑一次 | 裸 IMemoryCache 的 GetOrCreate 不保证原子 |
| 雪崩(大量键同时过期) | TTL 加随机抖动 | 错峰过期,避免同一时刻集体回库 |
// 缓存值不用 null 表达"未找到",用显式 Found 标记
public record CachedLink(string Url, bool Found);
// options 每次现造:抖动必须逐键不同,共享一份等于全表同刻过期,雪崩自己造
static HybridCacheEntryOptions EntryOptions() => new()
{
Expiration = TimeSpan.FromSeconds(60 + Random.Shared.Next(-5, 5)),
LocalCacheExpiration = TimeSpan.FromSeconds(60 + Random.Shared.Next(-5, 5)) // L1/L2 都显式设
};
app.MapGet("/{code}", async Task<IResult> (string code, HybridCache cache, LinkDb db,
Channel<string> clicks) =>
{
var hit = await cache.GetOrCreateAsync($"link:{code}", async token =>
{
var link = await db.Links.AsNoTracking()
.FirstOrDefaultAsync(l => l.Code == code, token);
return link is null
? new CachedLink("", false) // 未找到也缓存(负缓存,挡穿透)
: new CachedLink(link.Url, true);
}, EntryOptions());
if (!hit.Found) return Results.NotFound();
clicks.Writer.TryWrite(code); // 计数只入队,不阻塞响应(见难点三)
return Results.Redirect(hit.Url, permanent: false); // 302,浏览器不永久缓存
});
与 Go 篇的一个真实差异:Go 篇用 Redis 做到同键双 TTL(命中 24 小时、空值 60 秒);HybridCache 的 options 是调用级参数,工厂无法按结果改 TTL。本文统一 60 秒,热点码每分钟最多回库一次;要 24 小时正缓存时,把正缓存与负缓存标记拆成两个键空间各配各的 options。
难点三:点击计数——Channel 代替 goroutine
Go 篇用一句 go incrClick(code) 把计数甩到后台;C# 没有 goroutine,对应物是 Channel + BackgroundService,且要显式做两件 Go 运行时帮你扛的事:背压与优雅关停。
// 有界队列:打满丢最旧——宁丢一次点击,不让队列膨胀拖垮跳转路径
builder.Services.AddSingleton(Channel.CreateBounded<string>(
new BoundedChannelOptions(10_000) { FullMode = BoundedChannelFullMode.DropOldest }));
public sealed class ClickWorker(Channel<string> channel, IServiceScopeFactory scopes)
: BackgroundService // 主构造函数(C# 12)
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var buffer = new Dictionary<string, int>();
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
var reading = channel.Reader.WaitToReadAsync(ct).AsTask();
var ticking = timer.WaitForNextTickAsync(ct).AsTask();
while (true)
{
// 两个等待赛跑:来数据就攒批,攒满 500 或 5 秒到点就 flush
if (await Task.WhenAny(reading, ticking) == reading)
{
while (channel.Reader.TryRead(out var code))
buffer[code] = buffer.GetValueOrDefault(code) + 1;
reading = channel.Reader.WaitToReadAsync(ct).AsTask();
if (buffer.Count >= 500) await Flush(buffer, ct);
}
else
{
ticking = timer.WaitForNextTickAsync(ct).AsTask();
await Flush(buffer, ct);
}
}
async Task Flush(Dictionary<string, int> buf, CancellationToken token)
{
if (buf.Count == 0) return;
using var scope = scopes.CreateScope(); // 后台无请求作用域,自建
var db = scope.ServiceProvider.GetRequiredService<LinkDb>();
foreach (var (code, n) in buf)
await db.Links.Where(l => l.Code == code)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.Clicks, l => l.Clicks + n), token);
buf.Clear();
}
}
}
反模式三:Task.Run 发后不理计数。进程退出点击全丢,异常没人接。Channel + BackgroundService 把中断点、背压、关停语义全摆在明面上——这是”没有 goroutine”的代价与换来的一切。
数据模型与启动
public class Link
{
public long Id { get; set; }
public string Code { get; set; } = "";
public string Url { get; set; } = "";
public long Clicks { get; set; }
}
public class LinkDb(DbContextOptions<LinkDb> opt) : DbContext(opt)
{
public DbSet<Link> Links => Set<Link>();
protected override void OnModelCreating(ModelBuilder b) =>
b.HasIndex(l => l.Code).IsUnique(); // 一切重试逻辑的前提
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSqlite<LinkDb>("Data Source=links.db"); // 换 MySQL 用 Pomelo
builder.Services.AddHybridCache();
builder.Services.AddHostedService<ClickWorker>();
var app = builder.Build();
using (var scope = app.Services.CreateScope()) // 演示建库;生产走 dotnet ef migrations
scope.ServiceProvider.GetRequiredService<LinkDb>().Database.EnsureCreated();
app.Run();
依赖只有两个包:Microsoft.EntityFrameworkCore.Sqlite 与 Microsoft.Extensions.Caching.Hybrid——Web 框架零包,ASP.NET Core 在 SDK 里。
验证与瓶颈定位
curl -s -X POST 'http://localhost:5000/shorten?url=https://blog.x-rush.cn/'
curl -i 'http://localhost:5000/<短码>' # 应见 302 + Location
wrk -t4 -c100 -d30s 'http://localhost:5000/<短码>' # 压缓存命中路径
| 阶段现象 | 瓶颈在哪 | 怎么改 |
|---|---|---|
| 低并发就慢 | 每请求开新连接/新作用域 | DbContext 池化(AddDbContextPool)、连接池开启 |
| 命中率上不去 | 负缓存挤掉热点或 TTL 过短 | 查缓存容量策略与 TTL 抖动 |
| P99 随流量线性涨 | 线程池饥饿(异步链混入同步阻塞) | grep .Result/.Wait(),全链路异步 |
Go 与 C# 对比
| 维度 | Go 篇实现 | 本篇 ASP.NET Core 实现 |
|---|---|---|
| 代码量(两文完整代码口径) | 约 200 行,多文件分层 | 约 150 行,单 Program.cs 无碍 |
| 并发模型 | goroutine + channel,发后不理一个 go 关键字 | async/await + Channel + BackgroundService,背压与关停显式 |
| 唯一约束重试 | isDupKey 判 1062,循环重试 | 异常过滤器判错误码,同构循环重试 |
| 部署产物 | 单静态二进制,交叉编译一条命令 | 自包含单文件(带运行时,体积大得多);或框架依赖+目标机装运行时;Native AOT 可再缩,但 EF Core 依赖反射,本栈不适合 |
| 内存口径 | 定性:.NET 分代 GC 常驻集通常高于 Go 但同一量级;公开基准(TechEmpower 历届)两家同场、名次互有胜负——性能不构成选型决定因素,生态与团队才是 | |
总结
- 并发正确性不靠语言靠约束:唯一索引 + 冲突重试,Go 与 C# 写法同构
- 缓存三件套与语言无关:负缓存、击穿合并、TTL 抖动,少一件就是埋雷
- goroutine 换成 Channel + BackgroundService,买回显式的背压与关停语义
- 异步链混进一个 .Result 就能拖垮 P99;两份答卷差异最大的是部署产物与生态,性能按公开基准与自身压测说话
本篇的 record、模式匹配、CancellationToken 速查见《C# 基础知识参考字典》;系列全貌见《C# / .NET 学习路线》。
版本核实(以官方文档为准):本文基于 .NET 10(当前 LTS)与 Minimal API;HybridCache 随 .NET 9 引入、默认进程内存;EF Core 10 支持 ExecuteUpdateAsync 批量更新;.NET 10 Minimal API 模板默认生成 OpenAPI 文档;Native AOT 支持 Minimal API 但 EF Core 不在其列;RandomNumberGenerator.GetInt32 为 .NET 6+。性能未在本站实测,只给定性与公开参考(TechEmpower)。