用 ASP.NET Core 重写短链服务:与 Go 实现同场对比

要做什么:同一道题再答一遍

短链是一道被反复回答的经典题,需求很早就被定死了:长网址换 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)。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇