C#WEB 防重复提交控制

生产环境由于业务较为频繁,若网络或设备出现卡顿可能存在重复提交等问题,该问题通过前端的防止重复提交已经不管用,需要在后端或数据库增加控制。

若项目已经上线一段时间后发生此类问题,整体改造系统的成本较大,可以通过增加拦截器,拦截请求,设定1秒或者一个合理的时间周期为控制条件,如果同一个账户同一个请求路径再次期间内连续请求则拦截本次请求并返回前端错误信息,一定程度上避免业务数据混乱。

本次从.net frameowrk 4.5 级.net Core -.Net 各版本入手,设定公共且兼容的方法来应对并发问题。

本次功能逻辑范围:

1、通过拦截器调用此方法,传入用户、请求路径信息

2、设置白名单,将不需要控制的路径填写在此处,系统判断并发时不会处理白名单内的请求

3、使用内存记录请求时间

4、增加定期内存垃圾处理,将超过10秒未更新的请求记录清空

封装一个方法类:RequestThrottler.cs

复制代码
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

public static class RequestThrottler
{
    // 白名单路径(不区分大小写)
    private static readonly HashSet<string> WhitelistPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
    {
        "/api/health",
        "/login",
        "/logout",
        "/static/",
        // 可扩展
    };

    // 存储结构:key -> { LastAccessTime, LastRequestTime }
    private static readonly ConcurrentDictionary<string, TimestampEntry> Cache =
        new ConcurrentDictionary<string, TimestampEntry>();

    // 清理任务控制
    private static Timer _cleanupTimer;
    private static readonly TimeSpan CleanupInterval = TimeSpan.FromSeconds(10);   // 每10秒清理一次
    private static readonly TimeSpan ExpiryThreshold = TimeSpan.FromSeconds(10);   // 超过10秒未使用则清除

    // 静态构造函数:启动清理定时器
    static RequestThrottler()
    {
        // 使用 Timer 兼容 .NET Framework 4.5 和 .NET Core+
        _cleanupTimer = new Timer(CleanupExpiredEntries, null, CleanupInterval, CleanupInterval);
    }

    public static bool IsAllowed(string userId, string requestPath)
    {
        if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(requestPath))
            return true; // 容错:允许

        var normalizedPath = NormalizePath(requestPath);

        if (IsWhitelisted(normalizedPath))
            return true;

        var key = $"{userId}|{normalizedPath}";
        var now = DateTime.UtcNow;

        // 获取或添加条目,并更新最后访问时间
        var entry = Cache.GetOrAdd(key, new TimestampEntry { LastRequestTime = now });
        
        // 原子性检查是否在1秒内重复请求
        var lastReq = entry.LastRequestTime;
        if ((now - lastReq).TotalSeconds < 1.0)
        {
            // 更新最后访问时间(用于清理判断),但不更新请求时间(防止绕过限流)
            Interlocked.Exchange(ref entry.LastAccessTime, now.Ticks);
            return false;
        }

        // 允许请求:更新请求时间和访问时间
        var newEntry = new TimestampEntry
        {
            LastRequestTime = now,
            LastAccessTime = now.Ticks
        };
        Cache[key] = newEntry; // 覆盖旧值(线程安全)
        return true;
    }

    // 后台清理逻辑
    private static void CleanupExpiredEntries(object state)
    {
        try
        {
            var cutoffTicks = (DateTime.UtcNow - ExpiryThreshold).Ticks;
            var keysToRemove = new List<string>();

            foreach (var kvp in Cache)
            {
                // 如果最后访问时间早于阈值,则标记删除
                if (kvp.Value.LastAccessTime < cutoffTicks)
                {
                    keysToRemove.Add(kvp.Key);
                }
            }

            foreach (var key in keysToRemove)
            {
                Cache.TryRemove(key, out _);
            }
        }
        catch
        {
            // 忽略清理异常,避免 Timer 崩溃
        }
    }

    private static string NormalizePath(string path)
    {
        if (string.IsNullOrEmpty(path)) return path;
        var idx = path.IndexOf('?');
        if (idx >= 0) path = path.Substring(0, idx);
        return path.ToLowerInvariant();
    }

    private static bool IsWhitelisted(string path)
    {
        if (string.IsNullOrEmpty(path)) return false;

        if (WhitelistPaths.Contains(path)) return true;

        foreach (var prefix in WhitelistPaths)
        {
            if (prefix.EndsWith("/") && path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
                return true;
        }

        return false;
    }

    // 内部结构体:存储两个时间戳
    private class TimestampEntry
    {
        public DateTime LastRequestTime; // 上次发起有效请求的时间(用于限流判断)
        public long LastAccessTime;      // 上次被访问的时间(用于清理判断,用 Ticks 提升性能)
    }
}

1、NET Framework 4.5(ASP.NET MVC / Web API)全局拦截(推荐) :通过 ActionFilterAttribute

复制代码
public class ThrottleAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var user = filterContext.HttpContext.User?.Identity?.Name ?? "anonymous";
        var path = filterContext.HttpContext.Request.Path;

        if (!RequestThrottler.IsAllowed(user, path))
        {
            filterContext.Result = new HttpStatusCodeResult(429, "请求过于频繁");
        }
        base.OnActionExecuting(filterContext);
    }
}

2. .NET Core 3.1 / .NET 6 / .NET 7 / .NET 8(ASP.NET Core)

Action Filter(局部控制)
复制代码
public class ThrottleActionFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        var userId = context.HttpContext.User?.Identity?.Name ?? "anonymous";
        var path = context.HttpContext.Request.Path;

        if (!RequestThrottler.IsAllowed(userId, path))
        {
            context.Result = new ObjectResult("请求过于频繁") { StatusCode = 429 };
        }
    }

    public void OnActionExecuted(ActionExecutedContext context) { }
}

// 注册
services.AddScoped<ThrottleActionFilter>();

// 使用
[ServiceFilter(typeof(ThrottleActionFilter))]
public IActionResult Submit() { ... }
相关推荐
菜鸟‍2 小时前
【前端学习】阿里前端面试题
前端·javascript·学习
Jonathan Star2 小时前
LangFlow前端源码深度解析:核心模块与关键实现
前端
用户47949283569152 小时前
告别span嵌套地狱:CSS Highlights API重新定义语法高亮
前端·javascript·css
无责任此方_修行中2 小时前
一行代码的“法律陷阱”:开发者必须了解的开源许可证知识
前端·后端·开源
合作小小程序员小小店2 小时前
web网页开发,在线物流管理系统,基于Idea,html,css,jQuery,jsp,java,SSM,mysql
java·前端·后端·spring·intellij-idea·web
Elnaij2 小时前
从C++开始的编程生活(12)——vector简单介绍和迭代器
开发语言·c++
GISer_Jing3 小时前
OSG底层从Texture读取Image实现:readImageFromCurrentTexture
前端·c++·3d
饼干,3 小时前
第23天python内容
开发语言·python
数学难3 小时前
Java面试题2:Java线程池原理
java·开发语言