Skip to content

צינורות סקרים בסיכון נמוך: עמידות, אצוות וספירה אידמפוטנטית

מדריך SysSeder זה בוחן צינור עיבוד לסקרים רגילים בעלי סיכון נמוך.

פרץ תשובות הוא תרחיש הנדסי מועיל גם בלי לקשור אותו למועמד פוליטי או לאירוע חדשותי מומצא. המאמר אינו תכנון לבחירות שלטוניות, להצבעה חשאית או להבטחת זהות. הוא אינו טוען להגן על הצבעה דמוקרטית או מתאר חוויית צפייה אישית בתוצאות בחירות.

ההיקף הוא סקר מידע קטן, כגון בחירת נושא לתיעוד. צירוף תוצאות סקר אינו אלגוריתם הסכמה מבוזרת, ולממשל DAO או לתיאום דגלי תכונות יש דרישות נוספות. נשמר הדיון בתכנון Go,‏ Redis ו־PostgreSQL, אך השירות שנראה בר־הרצה ואינו בטוח מוחלף בהדגמה סינתטית, לא מקוונת ומוגבלת. אין גישה להצבעות, לחשבונות או לתשתית.

ארכיטקטורה של מנוע סקרים הניתן להרחבה

עדכונים בו־זמניים עלולים להתחרות על שורות עמוסות או למצות מאגר חיבורים. התוצאה תלויה בעומס, בסכמה, בקיבולת ובוויסות הקליטה; מסד יחסי אינו קורס בהכרח בסף התעבורה המומצא שבמקור. יש למדוד עומסים סינתטיים מייצגים ומורשים לפני טענות קיבולת.

הפרדת קליטה מעיבוד יכולה למתן פרצי עומס, אך מעבירה את גבול העמידות. הגדירו במדויק מתי תשובה מתקבלת לעיבוד, מתי היא נרשמת בעמידות וכיצד מזהים ניסיון חוזר. הנתיב התפעולי המושגי הוא:

  1. קליטה: אמתו והרשו מזהה ישות אטום, בדקו סקר ואפשרות, הגבילו בקשה והכניסו לתור בעמידות מזהה אירוע יציב. 202 Accepted פירושו קבלה לעיבוד, לא ספירה מוצלחת. ספקו מנגנון מצב מתועד; אל תחזירו ״vote_registered״ לפני הרישום.
  2. תור: לרשימות ולזרמים של Redis סמנטיקות מסירה שונות. שליפה פשוטה שמסירה הודעה עלולה לאבד אותה אם עובד נכשל לפני האחסון. קבוצות צרכנים ב־Streams עוקבות אחר מסירות ממתינות, אך מדיניות התמדה, שכפול, קיצוץ ושחזור עדיין קובעת התנהגות אובדן. אין כאן טענת מדידה לתפוקה או להשהיה.
  3. עובד: עבדו אצווה מוגבלת, שמרו מזהי אירועים ושחזרו מסירות ממתינות אחרי כשל. אשרו מסירה רק לאחר אישור עסקת המסד או התאמה של אירוע זהה שכבר קיים. ניסיונות חוזרים אינם רשאים להוסיף אותה תשובה פעמיים.
  4. אחסון: בצעו commit לרישום אירועים ללא כפילויות ולהשפעתם על הסיכום באותה עסקה. צרפו רק אירועים חדשים שהתקבלו. אצוות עשויות לצמצם תקורה, אך מוסיפות השהיה, לחץ זיכרון ועבודת התאוששות; אין הבטחה לשיפור בסדרי גודל.

ארכיטקטורה מושגית וגבול אישור המסירה

בקשה מאומתת → תור עמיד → אצווה מוגבלת אצל עובד
עובד → עסקה: רישום אירועים ייחודי וסיכום → commit מאושר
commit מאושר או אירוע זהה שהותאם → אישור מסירה לתור
מסירה ממתינה אחרי כשל → שחזור וניסיון חוזר אידמפוטנטי
זהו מתווה תכנון, לא פריסה שמומשה או נבדקה.

מודל קליטה לא מקוון ב־Go

חבילת net/http של Go יכולה לתמוך בשירות, אך אינה הבחירה המושלמת או המהירה ביותר מעצם טבעה. מימוש HTTP אמיתי צריך טיפול בשיטה ובסוג תוכן, הגבלת גוף, מועדים, אימות זהות, הרשאה, TLS והתנהגות בעומס יתר. כאן משתמשים בכוונה במחרוזות מקומיות בלבד, ללא מאזין או לקוח רשת.

שלושת קטעי Go הבאים יוצרים לפי הסדר תוכנית לימוד בתהליך יחיד. הם משתמשים רק בספרייה התקנית: ללא תלות go-redis/v9, אתחול Redis או חיבור למסד. מזהי הישויות הם ערכים סינתטיים שמספקת הדוגמה, ולעולם אינם מתקבלים מגוף JSON. התוכנית לא הודרה ולא הורצה כאן.

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "strings"
)

const maxBody = 4096
const maxBatch = 100
const maxLedger = 1000

type Event struct {
    ID, Poll, Choice, Principal string
}

type MemoryStore struct {
    events map[string]Event
    principals map[string]string
    totals map[string]int
}

func validID(s string) bool {
    if len(s) == 0 || len(s) > 64 { return false }
    for _, c := range s {
        if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' ||
             c >= '0' && c <= '9' || c == '-' || c == '_') { return false }
    }
    return true
}

המנתח דוחה גופים גדולים מדי, מפתחות JSON לא מוכרים או כפולים, JSON נוסף בסוף וערכים שאינם מחרוזות. הוא מקבל רק סקר סינתטי קבוע ושתי אפשרויותיו. זו המחשה לגבולות קלט, לא לאימות זהות. סיבוכיות אסימפטוטית של פעולת תור אינה מוכיחה השהיה או עמידות מקצה לקצה.

func parseFixture(raw, principal string) (Event, error) {
    var e Event
    if len(raw) > maxBody || !validID(principal) {
        return e, errors.New("invalid input boundary")
    }
    d := json.NewDecoder(strings.NewReader(raw))
    token, err := d.Token()
    if err != nil || token != json.Delim('{') {
        return e, errors.New("expected one object")
    }
    fields := make(map[string]string)
    for d.More() {
        token, err = d.Token()
        if err != nil { return e, err }
        key, ok := token.(string)
        if !ok { return e, errors.New("invalid key") }
        if key != "event_id" && key != "poll_id" && key != "candidate_id" {
            return e, errors.New("unknown key")
        }
        if _, exists := fields[key]; exists {
            return e, errors.New("duplicate key")
        }
        var value string
        if err := d.Decode(&value); err != nil { return e, err }
        fields[key] = value
    }
    token, err = d.Token()
    if err != nil || token != json.Delim('}') {
        return e, errors.New("invalid object end")
    }
    if _, err = d.Token(); err != io.EOF {
        return e, errors.New("trailing data")
    }
    e = Event{fields["event_id"], fields["poll_id"], fields["candidate_id"], principal}
    if !validID(e.ID) || e.Poll != "demo-poll" ||
       (e.Choice != "topic-a" && e.Choice != "topic-b") {
        return Event{}, errors.New("invalid synthetic event")
    }
    return e, nil
}

החלת אצווה לא מקוונת וטיפול בכשל

לעובד המקורי כמה נתיבי אובדן נפרדים: BRPOP מסירה הודעה לפני אחסון; מטענים פגומים נזרקים; flushToDB רק רושמת שגיאות בזמן שהקורא שלה מנקה את החוצץ; ו־commit שתוצאתו אינה ודאית עלול להיספר פעמיים בניסיון חוזר עיוור. הגדלת אצווה אינה מתקנת אף אחת מהבעיות.

קריאה חוסמת של שנייה בתוך לולאת המקור יכולה לעכב העברה שהובטחה בתוך 500 אלפיות שנייה. עובד תפעולי צריך טיפול עצמאי ומוגבל בקבלה ובטיימר, ביטול, השהיה מדורגת בניסיונות חוזרים ושחזור הודעות ממתינות. המודל הלא מקוון שלהלן מדגים במקום זאת החלת אצווה בשלמותה או בכלל לא וטיפול בכפילויות; הוא אינו מקבילי או עמיד ואינו מבטיח זמן.

func (s *MemoryStore) applyBatch(batch []Event) error {
    if len(batch) > maxBatch { return errors.New("batch too large") }
    // Copy first: a rejected batch leaves the original maps unchanged.
    next := MemoryStore{map[string]Event{}, map[string]string{}, map[string]int{}}
    for k, v := range s.events { next.events[k] = v }
    for k, v := range s.principals { next.principals[k] = v }
    for k, v := range s.totals { next.totals[k] = v }
    for _, e := range batch {
        if !validID(e.ID) || !validID(e.Principal) || e.Poll != "demo-poll" ||
           (e.Choice != "topic-a" && e.Choice != "topic-b") {
            return errors.New("invalid event")
        }
        if old, exists := next.events[e.ID]; exists {
            if old != e { return errors.New("event ID conflict") }
            continue // Identical retry: no additional tally effect.
        }
        key := e.Poll + "/" + e.Principal
        if _, exists := next.principals[key]; exists {
            return errors.New("principal already responded")
        }
        if len(next.events) >= maxLedger { return errors.New("ledger full") }
        next.events[e.ID] = e
        next.principals[key] = e.ID
        next.totals[e.Choice]++
    }
    *s = next
    return nil
}

func main() {
    a, err := parseFixture(`{"event_id":"event-a","poll_id":"demo-poll","candidate_id":"topic-a"}`, "synthetic-a")
    if err != nil { panic(err) }
    b, err := parseFixture(`{"event_id":"event-b","poll_id":"demo-poll","candidate_id":"topic-b"}`, "synthetic-b")
    if err != nil { panic(err) }
    store := MemoryStore{map[string]Event{}, map[string]string{}, map[string]int{}}
    pending := []Event{a, b, a} // Includes an identical retry.
    if err := store.applyBatch(pending); err != nil {
        // Retain pending for explicit reconciliation; do not silently clear it.
        fmt.Println("האצווה נדחתה:", err)
        return
    }
    pending = nil
    fmt.Println("סכומים סינתטיים:", store.totals["topic-a"], store.totals["topic-b"])
    // By inspection the intended totals are 1 and 1, not an observed run.
}

SQL: השפעות אטומיות על הסיכום דורשות רישום אירועים

השתמשו בסקר ובאפשרויות סינתטיים, לא במזהה בחירות אמיתי. שמרו את זהות כל אירוע עד האחסון; צירוף שמעלים את המזהים לפני הסרת כפילויות מאבד מידע הדרוש להבחנה בין ניסיונות חוזרים לתשובות חדשות.

הפקודה INSERT ... ON CONFLICT של PostgreSQL מספקת תוצאת הוספה או עדכון אטומית עבור פעולת ההתנגשות בשורה. הוספת ספירה מצורפת אינה אידמפוטנטית: שידור חוזר מוסיף אותה שוב. השתמשו באילוצי ייחוד ברישום ועדכנו סכומים רק משורות חדשות שנוספו, באותה עסקה. כך לא נוצרת עסקה אטומית חוצת Redis ו־PostgreSQL.

ה־SQL הבא הוא סכמה נפרדת להמחשה, שאינה משמשת את תוכנית Go הלא מקוונת ולא הורצה. היא מייצגת אפשרויות מותרות, מזהי ישויות אטומים ותשובה אחת לכל ישות בכל סקר. זו אינה הוכחת זהות או הצבעה חשאית; עדיין נדרשים כללי שמירה וגישה תפעוליים.

-- Illustration only: no database is created or changed here.
CREATE TABLE poll_choices (
    poll_id varchar(64) NOT NULL,
    candidate_id varchar(64) NOT NULL,
    PRIMARY KEY (poll_id, candidate_id)
);
CREATE TABLE poll_events (
    event_id varchar(64) PRIMARY KEY,
    poll_id varchar(64) NOT NULL,
    candidate_id varchar(64) NOT NULL,
    principal_id varchar(64) NOT NULL,
    UNIQUE (poll_id, principal_id),
    FOREIGN KEY (poll_id, candidate_id)
        REFERENCES poll_choices (poll_id, candidate_id)
);
CREATE TABLE poll_results (
    poll_id varchar(64) NOT NULL,
    candidate_id varchar(64) NOT NULL,
    vote_count bigint NOT NULL DEFAULT 0 CHECK (vote_count >= 0),
    PRIMARY KEY (poll_id, candidate_id),
    FOREIGN KEY (poll_id, candidate_id)
        REFERENCES poll_choices (poll_id, candidate_id)
);

החליפו את flushToDB המקורית ב־Go בתבנית העסקה הפרמטרית הבאה. קשרו אירוע, סקר ואפשרות מאומתים ומזהה ישות שהופק בצד השרת ל־$1–$4. המשפט מדגים אירוע אחד; אצווה יכולה לקבץ רק שורות חדשות שהוחזרו. אם לא נוספה שורה, קראו את הרשומה הקיימת במשפט נוסף והשוו את המטען המלא: ניסיון חוזר זהה, מזהה מתנגש וישות שכבר השיבה הם תוצאות שונות. התבנית מניחה בידוד Read Committed ורשומות רישום בלתי משתנות הנשמרות לאורך חלון הניסיון החוזר. אם ההתאמה אינה מוכיחה אירוע זהה שבוצע לו commit, אל תאשרו מסירה: בצעו rollback והשתמשו במדיניות שחזור וניסיון חוזר מוגבלת. טפלו במפורש בשגיאות מסד ובכשלים עקב סדר ביצוע עסקאות.

-- Parameterized illustration, not executed.
BEGIN ISOLATION LEVEL READ COMMITTED;
WITH accepted AS (
    INSERT INTO poll_events (event_id, poll_id, candidate_id, principal_id)
    VALUES ($1, $2, $3, $4)
    ON CONFLICT DO NOTHING
    RETURNING poll_id, candidate_id
)
INSERT INTO poll_results (poll_id, candidate_id, vote_count)
SELECT poll_id, candidate_id, 1 FROM accepted
ON CONFLICT (poll_id, candidate_id)
DO UPDATE SET vote_count = poll_results.vote_count + EXCLUDED.vote_count
RETURNING poll_id, candidate_id, vote_count;

-- If zero rows were returned, a subsequent parameterized SELECT compares
-- event_id and (poll_id, principal_id) records with the complete request.
-- Read Committed gives the subsequent statement a fresh snapshot.
-- If no identical committed event can be established, do not acknowledge;
-- roll back and reconcile/retry under a bounded policy. Ledger retention
-- must not delete or rewrite events needed for this retry window.
-- Do not report an altered payload as a successful retry.
-- The caller chooses COMMIT only for a verified accepted/identical outcome;
-- otherwise ROLLBACK. Check every statement and commit result.

שיקולי אבטחה ומניעת שימוש לרעה

גם סקר רגיל צריך מודל שימוש לרעה. הגבילו בקשות ותורים, אכפו זכאות לסקר בצד השרת וצמצמו מזהים נשמרים. אמצעים אלה מצמצמים סיכונים מסוימים; הם אינם מוכיחים שלמות בחירות או אנונימיות.

1. הגבלת קצב וויסות קליטה

בחרו הגבלות קצב לפי קיבולת וסיכוני שימוש לרעה, לא לפי כלל שרירותי של שלוש בעשר שניות. כתובת IP משותפת יכולה לייצג משתמשים תקינים רבים, וללקוח אחד יכולות להיות כמה כתובות. השתמשו בהקשר הפעלה או ישות מורשה כאשר מתאים, הימנעו משמירת IP מיותרת והגבילו עומק תור. 429 Too Many Requests יכולה לכלול Retry-After; הגבלות אינן הוכחה לאדם אחד לכל תשובה.

2. מפתחות אידמפוטנטיות ומניעת הצבעה כפולה

אילוץ ייחוד על סקר ומזהה ישות אטום ומאומת אוכף מדיניות לגבי אותה ישות, לא לגבי אדם ייחודי. לעולם אל תתנו אמון ב־user_id ב־JSON כראיית זהות. קשרו ניסיונות חוזרים למזהי אירועים יציבים והשוו מטענים; שמרו על אטומיות הרישום והסיכום. אין לאחסן בדוגמת הלימוד שמות, דוא״ל, כתובות IP או העדפות פוליטיות, ואין לתאר רישום שניתן לקישור כאנונימי.

3. אימות חתימות

חתימה או MAC מאמתים החזקה במפתח במסגרת פרוטוקול מוגדר, לא אדם מצביע או טריות בקשה. HMAC היא MAC עם סוד משותף, לא חתימה במפתח ציבורי. אל תטמיעו סוד חתימה משותף בקוד דפדפן. תכנון לגיטימי בין מכונות דורש גם ניהול מפתחות, תיחום מדויק של ההודעה החתומה, מניעת שידור חוזר, TLS והרשאה; פעולות אלה מחוץ להיקף המאמר.

סיכום

הפרדה, אצוות ופעולות מסד אטומיות הן כלים מועילים, לא הבטחות להתרחבות ללא קושי. שאלות הביקורת המרכזיות הן היכן נתונים נעשים עמידים, כיצד מטופלים אירועים כפולים ומתנגשים ומה קורה בכל גבול כשל.

לא הוכחה תוצאת קיבולת לתכנון זה. תהליך יחיד בזיכרון אינו שירות בזמינות גבוהה או מאגר עמיד. הצעה תפעולית מחייבת בדיקות עומס וכשל מורשות, ניטור ואימות התאוששות. שילוב Redis ו־PostgreSQL אינו מספק אוטומטית מסירה פעם אחת בדיוק.

לדיון, השתמשו רק במקרים סינתטיים ללא מידע רגיש: כשל לפני commit, אובדן תשובת commit, מסירה חוזרת, מזהה אירוע מתנגש ורוויה בתור. איזו תוצאה צריך כל מקרה להפיק, וכיצד תאמתו אותה בלי לאסוף תשובות אמיתיות?

שמרו את ההדגמה לא מקוונת וסינתטית; אל תסבו אותה להצבעה בעלת השלכות משמעותיות.

SysSeder — סקירה לימודית של ארכיטקטורה

ביקורת כשל והתאוששות

לעולם אל תנקו אצווה שלא בוצע לה commit רק מפני ששגיאה נרשמה ביומן. אם תוצאת commit אינה ודאית, התאימו מזהי אירועים יציבים לפני אישור מסירה; ניסיונות חוזרים חייבים לשמור אותם. רשומות זרם ממתינות דורשות שחזור ומדיניות בידוד למטענים שגויים. אל תקצצו היסטוריה ממתינה שנחוצה לשחזור. הגבילו המתנה לקבלה, גודל אצווה, עומק תור וניסיונות חוזרים; נטרו פיגור ודחו עומס יתר במפורש. הגדרות התמדה קובעות את חלון האובדן של מידע שכבר אושר. אלה דרישות ביקורת, לא מימוש שנבדק.

מקורות ראשוניים

Post a Comment

Previous Post Next Post