语法

1
INCR key

可用版本

≥ 1.0.0

时间复杂度

$O(1)$

ACL 类别

@write@string@fast

将存储在键上的数字值增加 1。如果键不存在,在执行操作前先将其设置为 0。如果键包含一个错误类型的值,或者包含一个不能表示为整数的字符串,则返回一个错误。该操作仅限于 64 位有符号的整数。

注意:这是一个字符串操作,因为 Redis 没有专门的整数类型。存储在键中的字符串被解释为以 10 为基数的 64 位带符号整数以执行操作。

Redis 以其整数表示法存储整数,因此对于实际包含整数的字符串值,存储整数的字符串表示法没有开销。

返回值

返回一个整数,表示执行 DECR 操作后,key 对应的值。

模式:计数器

使用 Redis 原子增量操作可以做的最简单的事情就是计数器模式。其思想就是每次操作发生时向 Redis 发送 INCR 命令。例如,在 Web 应用程序中,我们可能想要知道某个用户每天访问了多少页面。

为此,Web 应用程序可以在用户每次浏览页面时,简单地递增一个键——通过将用户 ID 和表示当前日期的字符串连接来创建键名。

这个简单的模式可以以多种方式扩展,如:

  • 可以在每次页面浏览时同时使用 INCREXPIRE,以便在指定的秒数内仅对最近 N 次页面浏览进行计数。
  • 客户端可以使用 GETSET 来原子地获取当前计数器值并将其重置为零。
  • 使用其他原子增量/减量命令如 DECRINCRBY,可以根据用户执行的操作,处理可能增大或减小的值。例如,想象在线游戏中不同用户的分数。

模式:速率限制器

速率限制器模式是一种特殊的计数器,用于限制可以执行操作的速率。此模式的典型实现是限制对公共 API 请求数。

我们使用 INCR 提供了两种实现此模式的方法,其中我们假设要解决的问题是将 API 调用限制在每个 IP 地址每秒最多 10 个请求。

第一种实现

此模式的简单、直接的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
FUNCTION LIMIT_API_CALL(ip)
ts = CURRENT_UNIX_TIME()
keyname = ip+":"+ts
MULTI
INCR(keyname)
EXPIRE(keyname,10)
EXEC
current = RESPONSE_OF_INCR_WITHIN_MULTI
IF current > 10 THEN
ERROR "too many requests per second"
ELSE
PERFORM_API_CALL()
END

我们为每个 IP 地址和不同的秒数维护一个计数器。这些计数器总是递增的,并且设置了 10 秒的过期时间以便在过期时能被 Redis 自动删除。

请注意使用 MULTIEXEC 以确保在每个 API 调用中同时递增和设置过期时间。

该实现为每个 IP 地址和每个秒数维护一个键,其值是一个简单的计数器。每当客户端对 API 进行调用时,我们将执行以下操作:

  1. 获取当前 Unix 时间戳以生成键名。
  2. 使用 MULTI/EXEC 包装器执行原子 INCR(递增)和 EXPIRE(设置 10 秒过期时间)操作。
  3. 检查 INCR 操作的结果。如果大于 10,我们返回错误,否则继续执行 API 调用。
  4. 由于我们为键设置了 10 秒的生存时间,所以键在过期后会被 Redis 自动删除。

这种方法的主要优点是实现简单,并且由于使用了过期时间,不需要手动重置或删除计数器。Redis 会自动删除已过期的键。

第二种实现

另一种实现方式是使用单个计数器,但要在没有竞争条件的情况下正确执行,会稍微复杂一些。以下我们将探讨在不同情况下的实现。

1
2
3
4
5
6
7
8
9
10
11
FUNCTION LIMIT_API_CALL(ip):
current = GET(ip)
IF current != NULL AND current > 10 THEN
ERROR "too many requests per second"
ELSE
value = INCR(ip)
IF value == 1 THEN
EXPIRE(ip,1)
END
PERFORM_API_CALL()
END

该计数器的创建方式是它只能存活一秒,从当前一秒内执行的第一个请求开始。如果在同一秒内有超过 10 个请求,计数器的值将大于 10,否则它将过期并从 0 重新开始。

上述代码中存在竞争条件。如果由于某些原因,客户端执行了 INCR 命令,但没有执行 EXPIRE 命令,那么该键将被泄漏(即永不过期),从而导致当该键存储的 IP 地址累计调用超过 10 次后,就一直返回 too many requests per second 错误,变成了限制每个 IP 地址最多只能调用 10 次,而不是每秒限制调用 10 次。

不过这个问题也很好解决,只需将 EXPIRE 命令和 INCR 命令包装成一个 Lua 脚本,使用 EVAL 命令发送即可(仅从 Redis 2.6 版开始可用)。

1
2
3
4
5
local current
current = redis.call("incr",KEYS[1])
if current == 1 then
redis.call("expire",KEYS[1],1)
end

竞争条件(Race Condition)指多个线程或者进程在读写一个共享数据时结果依赖于它们执行的相对时间的情形。这些线程或进程有可能因为时间上推进的先后原因而出现问题。

有另一种不使用脚本来解决此问题的方法是使用 Redis 列表而不是计数器。该实现更加复杂,使用了更多的高级功能,但它的优点是可以记录当前正在执行 API 调用的客户端 IP 地址。当然,这个优点对你来说可能有用,也可能没用,取决于你的应用程序的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
FUNCTION LIMIT_API_CALL(ip)
current = LLEN(ip)
IF current > 10 THEN
ERROR "too many requests per second"
ELSE
IF EXISTS(ip) == FALSE
MULTI
RPUSH(ip,ip)
EXPIRE(ip,1)
EXEC
ELSE
RPUSHX(ip,ip)
END
PERFORM_API_CALL()
END

RPUSHX 命令仅在键已存在时才压入元素。

请注意,这里同样也存在竞争条件:一个客户端在执行 EXISTS(ip) 命令时返回了 false,但还没来得及执行 MULTI/EXEC 块中代码来创建键时,其他客户端可能先创建了该键。

但这不是一个大问题:一是这种情况比较罕见,二是此竞争只会导致错过了一次 API 调用,即原本是限制每个 IP 地址每秒最多 10 次调用,在这种情况下变成了每个 IP 地址每秒最多 9 次调用。

该实现使用 Redis 列表而不是简单的计数器键。每当客户端调用 API 时,我们将执行以下操作:

  1. 检查列表的长度。如果大于 10,我们返回错误。
  2. 否则,判断键是否存在,如果不存在,我们使用 MULTI/EXEC 创建列表并为其设置 1 秒的过期时间。
  3. 如果键已存在,我们只使用 RPUSHX 命令将客户端 IP 地址压入到列表中。RPUSHX 命令仅在键已存在时执行压入操作。
  4. 由于我们在首次压入时设置了 1 秒的生存时间,所以该列表将在下一秒过期。该方法的主要优点是它跟踪当前执行 API 调用的所有客户端 IP 地址,这可能对某些应用程序很有用。

示例 1

以下示例代码演示了 INCR 命令用于对字符串类型的整数值进行增减操作。每次调用 INCR,该键的值会增加 1。

1)使用 SET 命令将 counter 键的值设置为 1000;

2)使用 INCR 命令将 counter 键的值增加 1,现在值为 1001;

3)使用 GET 命令读取 counter 键的值,确认值为 1001。

1
2
3
4
5
6
7
redis> SET counter "1000"
OK
redis> INCR counter
(integer) 1001
redis> GET counter
"1001"
redis>

示例 2

以下示例演示了Redis 的 INCR 命令要求键的值必须是整数类型,才能正确执行加一操作。如果键的值是不能表示为整数类型,则会返回 ERR value is not an integer or out of range 错误。

1
2
3
4
5
redis> SET username "Johnson"
OK
redis> INCR username
(error) ERR value is not an integer or out of range
redis>

示例 3

以下示例演示了对 Redis 中最大的 64 位有符号整数值进行加一操作,由于加一操作后出现整数溢出,返回 ERR increment or decrement would overflow 错误:

1
2
3
4
5
redis> SET max_bigint "9223372036854775807"
OK
redis> INCR max_bigint
(error) ERR increment or decrement would overflow
redis>

64 位有符号整数的数值范围:[-9223372036854775808, 9223372036854775807]

(END)