SQL 服务器自定义计数器存储过程创建欺骗

SQL Server custom counter stored procedure creating dupes

我创建了一个存储过程来对我的 API 实施速率限制,这被称为每秒大约 5-10k 次,每天我都注意到计数器中的骗局 table。

它查找传入的 API 键,然后使用 "UPSERT" 使用 ID 和日期组合检查计数器 table,如果找到结果,它会执行UPDATE [count]+1,如果不是,它将插入一个新行。

计数器中没有主键table。

存储过程如下:

USE [omdb]
GO
/****** Object:  StoredProcedure [dbo].[CheckKey]    Script Date: 6/17/2017 10:39:37 PM ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
ALTER PROCEDURE [dbo].[CheckKey] (
@apikey AS VARCHAR(10)
)
AS
BEGIN

SET NOCOUNT ON;

DECLARE @userID as int
DECLARE @limit as int
DECLARE @curCount as int
DECLARE @curDate as Date = GETDATE()

SELECT @userID = id, @limit = limit FROM [users] WHERE apiKey = @apikey

IF @userID IS NULL
    BEGIN
        --Key not found
        SELECT 'False' as [Response], 'Invalid API key!' as [Reason]
    END
ELSE
    BEGIN
        --Key found
        BEGIN TRANSACTION Upsert
        MERGE [counter] AS t
        USING (SELECT @userID AS ID) AS s
        ON t.[ID] = s.[ID] AND t.[date] = @curDate
        WHEN MATCHED THEN UPDATE SET t.[count] = t.[count]+1
        WHEN NOT MATCHED THEN INSERT ([ID], [date], [count]) VALUES (@userID, @curDate, 1);
        COMMIT TRANSACTION Upsert

        SELECT @curCount = [count] FROM [counter] WHERE ID = @userID AND [date] = @curDate

        IF @limit IS NOT NULL AND @curCount > @limit
            BEGIN
                SELECT 'False' as [Response], 'Request limit reached!' as [Reason]
            END
        ELSE
            BEGIN
                SELECT 'True' as [Response], NULL as [Reason]
            END
    END
END

我也认为引入此 SP 后会发生一些锁定。

这些骗子没有破坏任何东西,但我很好奇我的代码是否存在根本性错误,或者我是否应该在 table 中设置约束以防止这种情况发生。谢谢

2017 年 6 月 23 日更新: 我删除了 MERGE 语句并尝试使用 @@ROWCOUNT 但它也导致了错误

BEGIN TRANSACTION Upsert
UPDATE [counter] SET [count] = [count]+1 WHERE [ID] = @userID AND [date] = @curDate
IF @@ROWCOUNT = 0 AND @@ERROR = 0
INSERT INTO [counter] ([ID], [date], [count]) VALUES (@userID, @curDate, 1)
COMMIT TRANSACTION Upsert

这将是合并语句与自身进入竞争状态,即您的 API 被同一个客户端调用,两次合并语句都找不到行,因此插入一个。合并不是原子操作,尽管可以合理地假设它是。例如,参见 SQL 2008 的 this bug report,关于合并导致死锁,SQL 服务器团队说这是设计使然。

从你的 post 我认为最直接的问题是你的客户可能会在你的 API 上获得少量免费点击。例如,如果有两个请求进入并且看不到任何行,那么当您实际上想要一行计数为 2 时,您将从计数为 1 的两行开始,而客户端最终可能会免费获得 1 API那天打。如果三个请求交叉,你会得到三行,计数为 1,他们可以获得 2 个免费 API 命中,等等

编辑

因此,正如您的 link 建议您可以探索两类选项,首先尝试在 SQL 服务器上运行它,其次是其他架构解决方案。

对于 SQL 选项,我会取消合并并考虑提前预填充您的客户,每晚一次或更少一次,一次几天,这将为您留下一个更新而不是 merge/update 并插入。然后您可以确认您的更新和 select 都已完全优化,即具有必要的索引并且它们不会导致扫描。接下来,您可以查看调整锁定,以便仅在行级别锁定,请参阅 this 了解更多信息。对于 select,您还可以考虑使用 NOLOCK,这意味着您可能会得到稍微不正确的数据,但这在您的情况下应该无关紧要,您将使用始终针对单行的 WHERE。

对于非 SQL 选项,正如您的 link 所说,您可以考虑排队,显然这些是 updates/inserts,所以您的 selects会看到旧数据。这可能会或可能不会被接受,具体取决于它们相距多远,尽管如果您想要严格并收取额外费用或起飞 API 第二天或其他什么,您可以将此作为“最终一致”的解决方案。您还可以查看缓存选项来存储计数,如果您的应用程序是分布式的,这会变得更加复杂,但是有缓存解决方案。如果您使用缓存,您可以选择不保留任何内容,但如果您的网站出现故障,您可能会放弃大量免费点击,但无论如何您可能会有更大的问题需要担心!

更新语句的HOLDLOCK 提示将避免竞争条件。为了防止死锁,我建议在 IDdate.

上使用聚集复合主键(或唯一索引)

下面的示例合并了这些更改,并使用 SET 子句的 SET <variable> = <column> = <expression> 形式来避免对最终计数器值的后续 SELECT 的需要,从而提高性能。

ALTER PROCEDURE [dbo].[CheckKey]
    @apikey AS VARCHAR(10)
AS

SET NOCOUNT ON;
--SET XACT_ABORT ON is a best practice for procs with explcit transactions
SET XACT_ABORT ON; 

DECLARE
      @userID as int
    , @limit as int
    , @curCount as int
    , @curDate as Date = GETDATE();

BEGIN TRY;

    SELECT
          @userID = id
        , @limit = limit 
    FROM [users] 
    WHERE apiKey = @apikey;

    IF @userID IS NULL
    BEGIN
        --Key not found
        SELECT 'False' as [Response], 'Invalid API key!' as [Reason];
    END
    ELSE
    BEGIN
        --Key found
        BEGIN TRANSACTION Upsert;

        UPDATE [counter] WITH(HOLDLOCK) 
        SET @curCount = [count] = [count] + 1 
        WHERE
            [ID] = @userID 
            AND [date] = @curDate;

            IF @@ROWCOUNT = 0
            BEGIN    
                INSERT INTO [counter] ([ID], [date], [count]) 
                    VALUES (@userID, @curDate, 1);
            END;

        IF @limit IS NOT NULL AND @curCount > @limit
        BEGIN
            SELECT 'False' as [Response], 'Request limit reached!' as [Reason]
        END
        ELSE
        BEGIN
            SELECT 'True' as [Response], NULL as [Reason]
        END;

        COMMIT TRANSACTION Upsert;

    END;

END TRY
BEGIN CATCH
    IF @@TRANCOUNT > 0 ROLLBACK;
    THROW;
END CATCH;
GO

可能不是您正在寻找的答案,但对于速率限制计数器,我会在点击 API 之前在中间件中使用像 Redis 这样的缓存。在性能方面,它非常棒,因为 Redis 不会有负载问题,您的数据库也不会受到影响。

如果您想在 SQL 中保留每天每个 api 键的命中历史记录,运行 每天执行一项任务,将昨天的计数从 Redis 导入 SQL.

数据集足够小,可以得到一个几乎不需要任何成本(或关闭)的 Redis 实例。

在高层次上,您是否考虑过以下场景?

重组:将 table 上的主键设置为 (ID, date) 的组合。可能更好,只需使用 API 密钥本身而不是您分配给它的任意 ID。

查询 A:执行 SQL 服务器等效于 "INSERT IGNORE"(似乎有 SQL 服务器基于 Google 搜索的语义等效项),其值为 ( ID,今天(),1)。您还需要指定一个 WHERE 子句来检查 ID 是否确实存在于您的 API/limits table) 中。

查询 B:使用 (ID, TODAY()) 作为其主键更新行,设置计数 := 计数 + 1,并在同一个查询中,使用您的限制进行内部连接 ​​table,以便在 where 子句中您可以指定仅当计数 < 限制时才更新计数。

如果您的大部分请求都是有效的 API 请求或限速请求,我将对每个请求按以下顺序执行查询:

Run Query B.
If 0 rows updated:
 Run query A.
 If 0 rows updated:
  Run query B.
  If 0 rows updated, reject because of rate limit.
  If 1 rows updated, continue.
 If 1 rows updated:
  continue.
If 1 row updated:
 continue.

如果您的大多数请求都是无效的 API 请求,我会执行以下操作:

Run query A.
 If 0 rows updated:
  Run query B.
  If 0 rows updated, reject because of rate limit.
  If 1 rows updated, continue.
 If 1 rows updated:
  continue.