设计分布式复位计数器
在某些系统中,实现**分布式重置计数器**可能是一项重大挑战。这在餐厅管理软件等应用中尤其常见,在这些应用中,每天的访客数量从 0 开始按顺序发出。计数器每天重置,并随着每次下订单而递增。
虽然有多种方法可以实现此功能,但有些解决方案过于复杂。在这篇文章中,我将概述一种更简单、更高效的解决方案,利用 **Redis** 来应对这一挑战。
什么是重置分布式计数器?
想象一下,当您去一家餐厅点餐时,每位顾客都会被分配一个唯一的编号。每天,计数器都会重置为 0,而每位新顾客都会获得下一个可用的编号,该编号会加 1。
现在想象一下有多家餐厅使用我们的服务并且他们需要实现此功能。
此功能定义了**重置分布式计数器**:
**Redis** 可用作内存数据存储,以高效管理计数器。Redis 的高速操作、对原子命令的支持以及**生存时间 (TTL)** 功能使其成为此用例的理想选择。
Redis 的优点:
考虑:
Redis 是一种内存存储,这意味着默认情况下不会持久保存其数据。为了降低数据丢失的风险(例如服务器重启),我们会定期将计数器值持久保存在数据库中。这可确保即使 Redis 发生故障,系统也能正常恢复。
实现细节
关键设计选择:
Redis 计数器服务
以下是计数器服务的实现:
const redis = require('redis'); const client = redis.createClient(); const BASE_TTL = 86400; // 24 hours in seconds const TTL_RANDOMIZATION = 10800; // 1-3 hours randomization // Generate a random TTL between 24 and 27 hours function getRandomTTL() { return BASE_TTL + Math.floor(Math.random() * TTL_RANDOMIZATION); } // Function to manage the resetting distributed counter async function setOrgCounterTTL(orgUUID) { const key = `orgCounter-${orgUUID}`; const ttl = getRandomTTL(); // Check if the counter exists const existingCounter = await client.get(key); if (existingCounter !== null) { // If the counter exists, increment it const newValue = await client.incr(key); return newValue; } else { // If the counter does not exist, initialize it with a TTL await client.set(key, 1, { EX: this.getRandomTTL(), NX: true }); return 1; } } module.exports = { setOrgCounterTTL };
订单创建流程
订单创建服务与基于Redis的计数器集成:
const { setOrgCounterTTL } = require('./distributedCounterService'); // Handle new order creation async function createOrder(orgUUID) { try { // Fetch the current counter value const counter = await setOrgCounterTTL(orgUUID); // Assign the counter to the new order const newOrder = { organization: orgUUID, numberForTheDay: counter, // Add other order details here... }; // Save the new order to the database await saveOrderToDatabase(newOrder); } catch (error) { console.error("Error creating order:", error); // Fallback to database in case Redis is unavailable const fallbackCounter = await fallbackToDatabaseCounter(orgUUID); console.warn(`Fallback counter value: ${fallbackCounter}`); } }
我们可以通过两种方法确保在执行增加计数器时不会发生竞争条件,第一种方法将使用 watch 和 multi 和 watch 命令。 watch 命令监视整个事务中密钥的任何更改,如果在事务期间增加或重置了密钥,则 watch 命令将返回 null,如果返回 null,我们将重试。 Redis 事务需要考虑的另一件事是,它们不支持跨多个节点群集的密钥。 为了解决这个问题,我们只需使用 Redis 哈希标签在特定插槽上对组织进行分区。
async setOrGetCounter(key: string) : Promise{ await this.client.watch(key); const multi = await this.client.multi(); await multi.get(key); await multi.incr(key); const [exits, curr] = await multi.exec(); if (exits === null) { await multi.set(key, 1, { EX: this.getRandomWithin24HoursTTL(), NX: true }); } if (curr === null) { return this.setOrGetCounter(key); // Optionally retry } if (exits) { console.log("existing counter", curr, exits); return parseInt(exits); } return 1; }}
第二种解决方案是在我们的 NodeJS 应用程序中使用 lua 脚本,请注意,这是理想的,Redis 是单线程的,这将作为一条命令执行:
async setOrGetCounter(key: string): Promise{ const luaScript = ` local current = redis.call("GET", KEYS[1]) if current then return redis.call("INCR", KEYS[1]) else redis.call("SET", KEYS[1], 1, "EX", ARGV[1], "NX") return 1 end `; const ttl = this.getRandomWithin24HoursTTL(); const result = await this.client.eval(luaScript, 1, key, ttl); return parseInt(result); }
其他注意事项
最终一致性
在分布式系统中,**最终一致性**是一项关键原则。虽然 Redis 可确保近乎实时的更新,但偶尔也会出现暂时不一致的情况(例如网络分区或 Redis 故障)。考虑到性能优势,这种权衡是可以接受的。
峰值流量和随机 TTL
对于多租户系统,峰值流量可能会给 Redis 带来压力,因为租户之间的同步是每天偶然发生的。添加 TTL 随机化可确保组织之间的计数器在略有不同的时间重置,从而均匀分布每日重置的负载。
允许用户手动重置计数器
每个班次在开始时可能需要手动重置计数器,因此请为他们提供一个端点,让他们只需在 UI 上单击即可执行此操作。
数据持久性
计数器直接附加到订单服务,每当有新订单时,我们都会从 redis 请求一个新的号码。
回退策略(优雅降级)
如果 Redis 不可用:
Redis 集群
如果密钥位于不同的插槽/节点上,Redis 不支持事务。我们可以通过使用哈希标签在特定插槽上划分组织来解决这个问题。
结论
使用 Redis 进行分布式计数器重置提供了一种可扩展的高性能解决方案,消除了传统基于数据库的方法的瓶颈。借助原子操作、TTL 随机化和回退机制,此实现既强大又高效,并且能够在保持可靠性的同时处理峰值流量。
艾哈迈德·拉坎