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
提示将避免竞争条件。为了防止死锁,我建议在 ID
和 date
.
上使用聚集复合主键(或唯一索引)
下面的示例合并了这些更改,并使用 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.
我创建了一个存储过程来对我的 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
提示将避免竞争条件。为了防止死锁,我建议在 ID
和 date
.
下面的示例合并了这些更改,并使用 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.