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

时间:2017-06-18 04:27:17

标签: sql-server stored-procedures counter upsert

我创建了一个存储过程来对我的API实现速率限制,每秒调用大约5-10k次,每天我都会注意到计数器表中的欺骗行为。

enter image description here

它查找传入的API密钥,然后使用" UPSERT"检查计数器表和ID和日期组合。如果它找到了结果,它会执行UPDATE [count] +1,否则会插入一个新行。

计数器表中没有主键。

这是存储过程:

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后会发生一些锁定。

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

更新6/23/17:我删除了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

4 个答案:

答案 0 :(得分:7)

更新语句的HOLDLOCK提示将避免竞争条件。为了防止死锁,我建议IDdate上的群集复合主键(或唯一索引)。

以下示例包含这些更改,并使用SET <variable> = <column> = <expression>子句的SET形式,以避免需要后续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

答案 1 :(得分:2)

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

如果你想在SQL中保留每天api密钥的点击历史记录,请运行每日任务,将昨天的计数从Redis导入SQL。

数据集足够小,可以获得一个几乎没有(或关闭)的Redis实例。

答案 2 :(得分:1)

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

从您的帖子中我认为当前的问题是您的客户可能会在您的API上获得少量免费点击。例如,如果有两个请求进来并且看不到行,那么当你真正想要一个计数为2的行时,你将从两行开始计数为1,并且客户端当天可能最终得到1个免费的API命中。如果三个请求越过,你会得到三行,计数为1,他们可以获得2个免费的API命中等等。

修改

因此,您的链接表明您有两类可供选择的选项,首先尝试在SQL服务器中运行,其次是其他架构解决方案。

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

对于非SQL选项,正如您的链接所说,您可以查看排队的事情,显然这些将是更新/插入,因此您的选择将看到旧数据。这可能是可接受的,也可能是不可接受的,这取决于它们之间的距离,尽管如果您想要严格要求并在第二天或某事情上收取额外费用或取消API命中,您可以将其作为“最终一致”的解决方案。您还可以查看用于存储计数的缓存选项,如果您的应用程序已分发,则会变得更复杂但是有缓存解决方案。如果你使用缓存,你可以选择不坚持任何东西,但如果你的网站崩溃,你可能会放弃一大堆免费点击,但无论如何你可能还有更大的问题需要担心!

答案 3 :(得分:0)

在较高的层面上,您是否考虑过采用以下方案?

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

查询A:SQL Server相当于“INSERT IGNORE”(似乎有基于Google搜索的SQL Server的语义等价物),其值为(ID,TODAY(),1)。您还需要指定一个WHERE子句来检查API /限制表中实际存在的ID。

查询B:使用(ID,TODAY())作为主键更新行,设置count:= count + 1,并在同一个查询中,使用您的限制表执行内部联接,以便在where子句,您可以指定只有在计数&lt;限制。

如果您的大部分请求都是有效的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.