【问题标题】:SQL Server custom counter stored procedure creating dupesSQL Server 自定义计数器存储过程创建欺骗
【发布时间】:2017-11-20 13:24:32
【问题描述】:

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

它查找传入的 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之后会发生一些锁定。

骗子并没有破坏任何东西,但我很好奇我的代码是否存在根本性的问题,或者我是否应该在表中设置一个约束来防止这种情况发生。谢谢

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

【问题讨论】:

  • 我不会为此使用 MERGE 语句。在这种情况下,它隐藏了意图。使用显式事务和单独的选择/插入/更新
  • @MitchWheat 我希望使用 SQL 新引入的“UPSERT”方法比执行 UPDATE 和检查 @@rowcount 更有效,但似乎恰恰相反。
  • 为什么不能将id设置为表的唯一键
  • @SagarV 因为计数器在同一个表中跟踪多天。
  • 但我认为 ID 应该是唯一的

标签: sql-server stored-procedures counter upsert


【解决方案1】:

更新语句上的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

【讨论】:

  • 这看起来很有希望!我正要尝试一些非常相似的“UPDATE [counter] WITH (UPDLOCK, HOLDLOCK)”。我喜欢 SET @curCount 技巧来减少后续的 SELECT 但如果它只在 UPDATE 期间发生,在第一次调用/插入期间它不会是 NULL 吗?我想我可以声明 1 作为默认值来解决这个问题。
  • 不需要 UPDLOCK,因为更新将在更新的行上获取排他锁,或者在行不存在时获取排他范围锁。需要 HOLDLOCK 以避免在没有更新行时释放键范围锁。我将在示例代码中添加错误处理。
  • 您是否有意在 INSERT 之前省略了 @@ROWCOUNT 检查?我已经在 [ID] 和 [date] 上建立了一个聚集索引,但它不是唯一的,我只是修复了它,所以不会再发生欺骗,现在只是优化。
  • @bfritz,我在删除@@ERROR 时不小心删除了@@ROWCOUNT。对此感到抱歉。
【解决方案2】:

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

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

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

【讨论】:

  • SQL 服务器之前/前面的东西似乎是要走的路(无论是 Redis 还是其他东西)。一个真正智能的缓存甚至可以批量处理来自高级用户的少数 API 密钥的输入。
【解决方案3】:

这将是合并语句与自身进入竞争条件,即您的 API 被同一个客户端调用,并且两次合并语句都没有找到任何行,因此插入了一个。合并不是原子操作,尽管假设它是合理的。例如查看this bug report for SQL 2008,关于合并导致死锁,SQL Server 团队表示这是设计使然。

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

编辑

因此,正如您的链接所暗示的,您可以探索两类选项,首先尝试在 SQL Server 中使其工作,其次是其他架构解决方案。

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

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

【讨论】:

  • 我并不关心免费点击本身,但我确实需要一个不会减慢/锁定其他事务的并发解决方案。我在这里发现了一些关于大量并发更新/插入的有趣结果:michaeljswart.com/2011/09/…
【解决方案4】:

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

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

查询 A:使用值 (ID, TODAY(), 1) 执行 SQL Server 等效的“INSERT IGNORE”(似乎有基于 Google 搜索的 SQL Server 语义等效项)。您还需要指定一个 WHERE 子句来检查您的 API/limits 表中实际存在的 ID)。

查询 B:使用 (ID, TODAY()) 作为其主键更新行,设置 count := count + 1,并在同一个查询中,与您的限制表进行内部联接,以便在where 子句可以指定仅在 count

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

【讨论】:

    猜你喜欢
    • 2016-09-24
    • 1970-01-01
    • 1970-01-01
    • 2016-09-30
    • 2010-09-17
    • 2013-05-02
    • 2012-05-25
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多