设计分布式复位计数器

在某些系统中,实现**分布式重置计数器**可能是一项重大挑战。这在餐厅管理软件等应用中尤其常见,在这些应用中,每天的访客数量从 0 开始按顺序发出。计数器每天重置,并随着每次下订单而递增。

虽然有多种方法可以实现此功能,但有些解决方案过于复杂。在这篇文章中,我将概述一种更简单、更高效的解决方案,利用 **Redis** 来应对这一挑战。

什么是重置分布式计数器?

想象一下,当您去一家餐厅点餐时,每位顾客都会被分配一个唯一的编号。每天,计数器都会重置为 0,而每位新顾客都会获得下一个可用的编号,该编号会加 1。

现在想象一下有多家餐厅使用我们的服务并且他们需要实现此功能。

此功能定义了**重置分布式计数器**:

  • 重置:计数器每天开始时从 0 重新开始。
  • 分布式:在 SaaS 解决方案中,计数器跨多个节点运行。
  • **Redis** 可用作内存数据存储,以高效管理计数器。Redis 的高速操作、对原子命令的支持以及**生存时间 (TTL)** 功能使其成为此用例的理想选择。

    Redis 的优点:

  • 高吞吐量:Redis 完全在内存中运行,提供快速的读/写性能。
  • 原子操作:Redis 命令(例如 INCR 和 SET)是原子的“但是在巨大的流量下,我们可能需要。
  • 自动重置:TTL 确保计数器每 24 小时自动重置,无需外部调度。
  • 资源效率:计数器存储在内存中,减少对数据库查询的依赖。
  • 考虑:

    Redis 是一种内存存储,这意味着默认情况下不会持久保存其数据。为了降低数据丢失的风险(例如服务器重启),我们会定期将计数器值持久保存在数据库中。这可确保即使 Redis 发生故障,系统也能正常恢复。

    实现细节

    关键设计选择:

  • 计数器粒度:每个组织(例如餐厅)都有自己的计数器,通过 orgCounter-{orgUUID} 等键来标识。
  • 随机化的 TTL:为了处理峰值负载(例如午夜的高流量),TTL 在 24 到 27 小时之间随机分配,以在组织之间错开重置。
  • 回退机制:如果 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 不支持事务。我们可以通过使用哈希标签在特定插槽上划分组织来解决这个问题。

    结论

    使用 Redis 进行分布式计数器重置提供了一种可扩展的高性能解决方案,消除了传统基于数据库的方法的瓶颈。借助原子操作、TTL 随机化和回退机制,此实现既强大又高效,并且能够在保持可靠性的同时处理峰值流量。

    艾哈迈德·拉坎