C# Sinh Id theo cách của instagram
White

Lê Đào Táo viết ngày 11/12/2019

Ngày trước mình có đọc 1 bài về việc sinh id cho database từ link https://kipalog.com/posts/Instagram-da-sinh-ra-ID-trong-database-cua-ho-nhu-the-nao
Hôm nay mình làm 1 bài phân tích chi tiết thêm 1 chút và các cải tiến tương ứng.

Vấn đề

Thay vì phải implement lại 1 bộ generator thì chúng ta sẽ sử dụng thư viện IdGen. Thư viện này khá dễ dùng và đã định nghĩa sẵn 3 đoạn cho chúng ta sử dụng.

  • Timestamp
  • Generator-id
  • Sequence

alt
Với mỗi instance của ứng dụng (process) chúng ta sẽ tạo ra 1 instance IdGenerator bằng Singleton pattern và dùng xuyên suốt ứng dụng. Với 1 ứng dụng mà chỉ có 1 instance duy nhất thì chúng ta chỉ cần fix 1 generator-id là xong, không có gì đặc biệt cả. Nhưng nếu ứng dụng được chạy trên nhiều instance thì làm sao chúng ta xác định được instance nào đang chạy generator-id nào, và làm sao để đảm bảo chúng không bị trùng nhau? Việc generator-id trùng nhau khá nguy hiểm vì sẽ có xác suất nhỏ là 2 instance cùng tương tác với 1 loại tài nguyên thì chúng có thể sinh trùng id và chúng ta sẽ bị mất dữ liệu một cách ngẫu nhiên.

Giải pháp

Chúng ta cần triển khai thêm 1 khái niệm heart-beat và coi database là nơi lưu trữ và xác nhận các node đang hoạt động để đảm bảo từng instance sẽ nhận id và không trùng nhau. Bảng đăng ký (ApplicationNode) cho heartbeat đơn giản cần 3 trường:

  • Id: sử dụng cho generator-id
  • UpdatedTime: ghi nhận thời gian cập nhật cuối cùng của các node
  • InstanceId: sử dụng guid để định danh cho các node Mỗi khi ứng dụng khởi chạy(startup), ứng dụng sẽ kiểm tra trong bảng ApplicationNode các node nào gần đây đang hoạt động để loại trừ id của các node đó. Sau đó lấy ra 1 node trong list các id chưa hoạt động và dùng id này để khởi tạo cho IdGenerator
using IdGen;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Data.SqlClient;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Timers;

namespace Common
{
    public class Sid
    {
        private static int currentNode { get; set; }
        private static Guid instanceId;
        private static IdGenerator generator = null;
        public static void Setup()
        {
            var epoch = new DateTime(2019, 12, 1, 0, 0, 0, DateTimeKind.Utc);
            var mc = new MaskConfig(50, 1, 12);
            generator = new IdGenerator(0, epoch, mc);
        }
        public static void Setup(string nameOfConnectionString, DatabaseOptions options)
        {
            var epoch = new DateTime(2019, 12, 1, 0, 0, 0, DateTimeKind.Utc);
            var mc = new MaskConfig(45, 6, 12);
            if (options == null) options = new DatabaseOptions();
            instanceId = Guid.NewGuid();
            using (SqlConnection conn = new SqlConnection(nameOfConnectionString))
            {
                conn.Open();
                SqlCommand command = new SqlCommand(@"
                    IF NOT EXISTS (
                        SELECT  schema_name
                        FROM    information_schema.schemata
                        WHERE   schema_name = 'Sid' ) 
                    BEGIN
                        EXEC sp_executesql N'CREATE SCHEMA Sid'
                    END", conn);
                command.ExecuteNonQuery();
                command = new SqlCommand(@"
                    IF NOT EXISTS (SELECT * 
                                FROM INFORMATION_SCHEMA.TABLES 
                                WHERE TABLE_SCHEMA = 'Sid' 
                                AND  TABLE_NAME = 'ApplicationNode')
                    BEGIN
                        CREATE TABLE [Sid].[ApplicationNode] (
                            [Id] [INT] NOT NULL PRIMARY KEY,
                            [UpdatedTime] [DATETIME] NOT NULL,
                            [InstanceId] [UNIQUEIDENTIFIER] NOT NULL,
                        );
                    END", conn);
                command.ExecuteNonQuery();
                string now = DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss");
                List<int> inactiveNodes = ListInactiveNodes(conn, options);
                currentNode = inactiveNodes.FirstOrDefault();

                command = new SqlCommand($@"
                    IF EXISTS (SELECT * FROM [Sid].[ApplicationNode] WHERE [Id] = {currentNode})
                    BEGIN
                        UPDATE [Sid].[ApplicationNode]
                        SET [UpdatedTime] = '{now}', [InstanceId]='{instanceId}'
                        WHERE [Id] = {currentNode};
                    END
                    ELSE
                    BEGIN
                        INSERT INTO [Sid].[ApplicationNode] ([Id],[UpdatedTime], [InstanceId])
                        VALUES ({currentNode},'{now}','{instanceId}');
                    END", conn);
                generator = new IdGenerator(currentNode, epoch,mc);
                command.ExecuteNonQuery();
                conn.Close();
            }
            System.Timers.Timer aTimer = new System.Timers.Timer();
            aTimer.Elapsed += new ElapsedEventHandler((object source, ElapsedEventArgs e) =>
            {
                using (SqlConnection conn = new SqlConnection(nameOfConnectionString))
                {
                    conn.Open();
                    List<int> inactiveNodes = ListInactiveNodes(conn, options);
                    int newInactiveNode = inactiveNodes.FirstOrDefault();
                    string expired = DateTime.Now.AddSeconds(-options.PulseInterval * options.RetryTimes).ToString("yyyy-MM-dd hh:mm:ss");
                    string now = DateTime.Now.ToString("yyyy-MM-dd hh:mm:ss");
                    SqlCommand command;

                    command = new SqlCommand($@"
                    SELECT COUNT(1) FROM [Sid].[ApplicationNode] 
                    WHERE [Id] = {currentNode} AND [InstanceId] = '{instanceId}' AND [UpdatedTime] > '{expired}'", conn);
                    SqlDataReader reader = command.ExecuteReader();
                    while (reader.Read())
                    {
                        int count = reader.GetInt32(0);
                        if (count == 0)
                        {
                            currentNode = newInactiveNode;
                            command = new SqlCommand($@"
                                IF EXISTS (SELECT * FROM [Sid].[ApplicationNode] WHERE [Id] = {currentNode})
                                BEGIN
                                    UPDATE [Sid].[ApplicationNode]
                                    SET [UpdatedTime] = '{now}', [InstanceId] = '{instanceId}'
                                    WHERE [Id] = {currentNode}
                                END
                                ELSE
                                BEGIN
                                    INSERT INTO [Sid].[ApplicationNode] ([Id],[UpdatedTime], [InstanceId] 
                                        VALUES ({currentNode},'{now}','{instanceId}');
                                END
                                ", conn);

                            command.ExecuteNonQuery();
                            generator = new IdGenerator(currentNode, epoch, mc);
                        }
                        else
                        {
                            command = new SqlCommand($@"
                            UPDATE [Sid].[ApplicationNode]
                                SET [UpdatedTime] = '{now}'
                                WHERE [Id] = {currentNode} AND [InstanceId] = '{instanceId}';", conn);
                            command.ExecuteNonQuery();
                        }
                    }
                    command = new SqlCommand($@"
                    UPDATE [Sid].[ApplicationNode]
                        SET [UpdatedTime] = '{now}'
                        WHERE [Id] = {currentNode} AND [InstanceId] = '{instanceId}';", conn);
                    command.ExecuteNonQuery();
                    conn.Close();
                }
            });
            aTimer.Interval = options.PulseInterval * 1000;
            aTimer.Enabled = true;
            aTimer.Start();
        }

        private static List<int> ListInactiveNodes(SqlConnection conn, DatabaseOptions options)
        {
            string expired = DateTime.Now.AddSeconds(-options.PulseInterval * options.RetryTimes).ToString("yyyy-MM-dd hh:mm:ss");
            SqlCommand command = new SqlCommand($@"
                    SELECT [Id] FROM [Sid].[ApplicationNode] WHERE [UpdatedTime] > '{expired}'", conn);
            List<int> nodes = Enumerable.Range(0, options.TotalNodes).ToList();
            List<int> activeNodes = new List<int>();
            using (SqlDataReader reader = command.ExecuteReader())
            {
                if (reader.HasRows)
                {
                    while (reader.Read())
                    {
                        activeNodes.Add(reader.GetInt32(0));
                    }
                }
            }
            List<int> inactiveNodes = nodes.Except(activeNodes).ToList();
            if (inactiveNodes.Count == 0)
                throw new Exception("Bạn không thể tạo thêm node để sử dụng.");
            return inactiveNodes;
        }

        public static long NewSid()
        {
            return generator.CreateId();
        }
    }

    public class DatabaseOptions
    {
        private int _pulseInterval;
        /// <summary>
        /// calculate by second
        /// </summary>
        public int PulseInterval
        {
            get
            {
                if (_pulseInterval < 1)
                    return 1;
                return _pulseInterval;
            }
            set
            {
                _pulseInterval = value;
            }
        }

        private int _totalNodes;
        /// <summary>
        /// Max 64 nodes
        /// </summary>
        public int TotalNodes
        {
            get
            {
                if (_totalNodes < 1)
                    return 1;
                return _totalNodes;
            }
            set
            {
                _totalNodes = value;
            }
        }
        public int _retryTimes;
        public int RetryTimes
        {
            get
            {
                if (_retryTimes < 1)
                    return 1;
                return _retryTimes;
            }
            set
            {
                _retryTimes = value;
            }
        }
    }
}

Sid.Setup(Configuration.GetConnectionString("DataContext"), new DatabaseOptions
            {
                PulseInterval = 60,
                TotalNodes = 16,
                RetryTimes = 5,
            });

Mình tạm thư viện sinh id mới là Sid và chúng ta có 2 method sử dụng là:

  • Setup: khởi tạo các tham số PulseInterval(khoảng thời gian cập nhật với database), TotalNodes(số lượng node tối đa có thể khởi tạo), RetryTimes( RetryTimes* PulseInterval = thời gian tối đa 1 id được coi là đang hoạt động kể từ lần cập nhật cuối cùng)
  • NewSid : lấy 1 id mới từ thread bất kì của instance.

Ưu điểm

  • Dễ triển khai
  • Giảm tỉ lệ bị trùng generator-id

Nhược điểm

  • Bắt buộc phải có database hoặc 1 extend storage để làm nơi lưu trữ và kiểm tra.
  • các instance phải lần lượt bật lên, không thể cùng lúc bật 10 cái instance được vì sẽ xảy ra tình trạng trùng generator-id. Trong tình huống các instance được bật lên cùng lúc, thư viện sẽ tự điều chỉnh lại các generator-id mỗi nhịp heartbeat

Kết luận

Bài viết này vẫn còn mang tính chất thử nghiệm. Hi vọng các bạn có thể bổ sung ý kiến để mình hoàn thiện nó.

Bình luận


White
{{ comment.user.name }}
Bỏ hay Hay
{{comment.like_count}}
Male avatar
{{ comment_error }}
Hủy
   

Hiển thị thử

Chỉnh sửa

White

Lê Đào Táo

1 bài viết.
0 người follow
Kipalog
{{userFollowed ? 'Following' : 'Follow'}}
{{like_count}}

kipalog

{{ comment_count }}

bình luận

{{liked ? "Đã kipalog" : "Kipalog"}}


White
{{userFollowed ? 'Following' : 'Follow'}}
1 bài viết.
0 người follow

 Đầu mục bài viết

Vẫn còn nữa! x

Kipalog vẫn còn rất nhiều bài viết hay và chủ đề thú vị chờ bạn khám phá!