ที่ Slash, เราเป็นผู้ขับเคลื่อนการใช้จ่ายมูลค่าหลายหมื่นล้านดอลลาร์ทุกปี. ด้วยการเติบโตเกิน 1000% ในช่วง 12 เดือนที่ผ่านมา, เราอยู่ในระหว่างการแก้ปัญหาใหม่ ๆ อย่างต่อเนื่องในขนาดที่ใหญ่ขึ้น. เมื่อปริมาณข้อมูลเพิ่มขึ้นและระบบของเราซับซ้อนมากขึ้น, สมมติฐานที่เราเคยทำเกี่ยวกับความสัมพันธ์ของข้อมูลมักจะล้มเหลว. เราพบปัญหาจากหลายแหล่ง: การเติมกลับที่ไม่ดี, ข้อบกพร่องในโค้ดที่ใช้งานจริง, และข้อผิดพลาดเล็กน้อยระหว่างการย้ายข้อมูล เมื่อเวลาผ่านไป มันกลายเป็นเรื่องยากขึ้นเรื่อยๆ ที่จะตรวจสอบความถูกต้องของข้อมูลของเรา ซึ่งทำให้เราต้องสร้างสิ่งที่เราเรียกว่า Health Checks — ระบบที่ออกแบบมาเพื่อตรวจสอบความถูกต้องของข้อมูลอย่างต่อเนื่องในสภาพแวดล้อมการผลิต

การตรวจสอบสุขภาพเป็นเครื่องมือที่ช่วยให้เราตรวจสอบค่าคงที่ในฐานโค้ดของเรา เมื่อเราสร้างระบบ เราออกแบบระบบเสมอด้วยชุดของค่าคงที่ทั้งที่ชัดเจนและไม่ชัดเจน ซึ่งเป็นกฎหรือสมมติฐานที่เราคาดหวังให้คงอยู่ตลอดเวลา เพื่อให้เราสามารถสร้างระบบที่ซับซ้อนมากขึ้นบนพื้นฐานของระบบเหล่านั้นได้

ตัวอย่างเล็กน้อยของสิ่งที่คงที่: ใน บัตร ตารางในฐานข้อมูลของเรา ซึ่งแต่ละแถวแทนบัตรเครดิตหนึ่งใบ มี สถานะ คอลัมน์ที่หนึ่งในค่าสามารถเป็น "ปิด" และ เหตุผลการปิด คอลัมน์ที่สามารถเป็น NULLABLE ได้. ข้อไม่เปลี่ยนแปลงอย่างหนึ่งคือว่า สถานะ คอลัมน์จะ "ปิด" **หากและเฉพาะเมื่อ** เหตุผลการปิด ฟิลด์นี้ไม่ว่างเปล่า

ตัวอย่างข้างต้นไม่จำเป็นต้องเป็นเงื่อนไขที่เราจะเขียนการตรวจสอบสุขภาพเสมอไป แต่เป็นตัวอย่างของอินแวนต์ที่เราต้องมั่นใจว่ายังคงเป็นจริงในขณะที่ทำงานกับส่วนนี้ของโค้ดเบส

การออกแบบการตรวจสุขภาพเบื้องต้นของเรา

การตรวจสอบสุขภาพครั้งแรกของเราเป็นดังนี้:

/**
 * This health check ensures that for every declined AuthorizationSet, there are no transfer intents
 */
export const hcrForDeclinedAuthSets = createHealthCheckRoutineDefinition({
  programmaticName: 'chargeCard.authSets.declinedAuthSets',
  title: 'Charge card declined auth sets',
  description:
    'This health check ensures for every declined AuthorizationSet, there are no transfer intents',
  schedule: {
    type: 'interval',
    interval: 'minutes',
    frequency: 30,
  },
})
  .bulkCheckFn((deps: DefineDependencies<[HealthCheckModule.Service]>) => {
    return async (
      cursor:
        | HealthCheckRoutineCursor<{
            authorizationSetId: string;
          }>
        | undefined
    ) => {
	    // getErrorAccounts -> a query to Snowflake
      const errorAccounts = await getErrorAccounts(cursor, deps);

      return {
        data: errorAccounts.data.map((val) => ({
          key: val.authorizationSetId,
        })),
        nextCursor: errorAccounts.nextCursor,
      };
    };
  })
  .singleCheckFn(() => async (params) => {
    const singleCheckRes = await runQuery(
      sql`...`
    );

    return {
      success: singleCheckRes.length === 0,
    };
  });

เราออกแบบการตรวจสอบสุขภาพในตอนแรกให้ถูกกำหนดโดยการค้นหาด้วยคำสั่ง SQL. เราจะทำการค้นหาด้วยคำสั่ง SQL ต่อตารางเพื่อค้นหา 'ข้อผิดพลาดที่อาจเกิดขึ้น'. แต่เนื่องจากการรันคำสั่ง SQL กับตารางขนาดใหญ่มีค่าใช้จ่ายสูง เราจึงทำการตรวจสอบเหล่านี้กับรีพลิกาสำหรับการอ่านที่มีความสอดคล้องในที่สุด (ในกรณีของเราคือ Snowflake) สำหรับแต่ละผลลัพธ์ที่ได้จากการสอบถาม เราจะทำการตรวจสอบกับฐานข้อมูลหลักที่ใช้งานจริงของเราอีกครั้งเพื่อให้แน่ใจว่าไม่ใช่ผลบวกลวง การออกแบบนี้มีปัญหาอยู่บ้าง:

  1. เราจำเป็นต้องรักษาสองคำสั่งค้นหา คำสั่งค้นหาแรกจะทำงานกับรีพลิกาสำหรับการอ่านสำหรับทั้งตาราง ผู้ดูแลระบบจะต้องคิดและจัดการกับการแบ่งหน้าสำหรับคำสั่งค้นหาอย่างชัดเจนด้วย คำสั่งค้นหาที่สองจะทำงานกับระบบผลิตสำหรับผลลัพธ์แต่ละรายการที่คำสั่งค้นหาแรกจะคืนค่า
  2. การค้นหาข้อมูลจากตารางทั้งหมดจะกลายเป็นเรื่องซับซ้อนและยากต่อการอ่าน / ยากต่อการบำรุงรักษา เนื่องจากเราจะพยายามบันทึกตรรกะทางธุรกิจจำนวนมากไว้ในคำสั่ง SQL เพียงคำสั่งเดียว

การตรวจสอบสุขภาพรอบที่สอง

เราตัดสินใจ:

  1. การตรวจสอบสุขภาพควรดำเนินการกับฐานข้อมูลการผลิตหลักของเราเสมอ มิฉะนั้น เราอาจไม่พบปัญหาที่เกิดขึ้นจริงทุกประการ
  2. การทำการ "full table scan" ในคำสั่ง SQL เป็นสิ่งที่ควรหลีกเลี่ยงอย่างยิ่ง แต่การทำการ "full table scan" แบบควบคุมโดยการวนลูปผ่านแต่ละแถวและทำการตรวจสอบสุขภาพนั้นสามารถทำได้ — ตราบใดที่โหลดคงที่ สามารถคาดการณ์ได้ และไม่พุ่งสูงขึ้นอย่างฉับพลัน ทุกอย่างมักจะไม่มีปัญหา
  3. การทำซ้ำข้อมูลในตารางที่ใช้งานจริง (และแยกส่วนนั้นออกไปให้ผู้พัฒนา) ช่วยทำให้ประสบการณ์การใช้งานของเราง่ายขึ้นมาก:
    1. เราไม่จำเป็นต้องรักษาคำสั่งค้นหา sql ที่ซับซ้อนสองคำสั่งพร้อมการจัดหน้าอีกต่อไป เราเพียงแค่ต้องกำหนดตรรกะของแอปพลิเคชันสำหรับการตรวจสอบสถานะของรายการเดียวเท่านั้น
    2. การตรวจสอบสุขภาพทุกครั้งจะถูกกำหนดไว้สำหรับทั้งตาราง (ในอนาคต เราอาจเลือกที่จะขยายการตรวจสอบสุขภาพให้สามารถวนซ้ำเฉพาะตามคำจำกัดของดัชนีได้)

บทเรียนสำคัญที่เราได้เรียนรู้ร่วมกันในฐานะทีมตลอดระยะเวลาที่ผ่านมา คือระบบที่สามารถคาดการณ์ได้และสร้างภาระงานอย่างสม่ำเสมอถือเป็นระบบที่เหมาะสมที่สุด ปัญหาหลักที่มักเกิดขึ้นกับระบบผลิตจริงมักเกิดจากการเปลี่ยนแปลงที่ไม่คาดคิดและเกิดขึ้นอย่างฉับพลัน เช่น การเพิ่มขึ้นอย่างรวดเร็วของภาระงานฐานข้อมูล หรือการเปลี่ยนแปลงในตัววางแผนคำสั่งภายในของฐานข้อมูล

แง่มุมที่ขัดกับความเข้าใจทั่วไปเกี่ยวกับการให้ระบบทำงานภายใต้ภาระคงที่ โดยเฉพาะอย่างยิ่งกับฐานข้อมูลและคิว คือมันอาจดูเหมือนเป็นการสิ้นเปลือง ผมเคยเชื่อว่าการไม่สร้างภาระให้กับระบบเลยเป็นค่าเริ่มต้น และทำงานเฉพาะเมื่อจำเป็นในแต่ละวันไม่กี่ครั้งจะดีกว่า อย่างไรก็ตาม วิธีนี้อาจนำไปสู่ปริมาณงานที่พุ่งสูงเป็นช่วงๆ ซึ่งบางครั้งอาจทำให้ระบบของเราเสื่อมประสิทธิภาพโดยไม่คาดคิด ในความเป็นจริง เราพบว่าการมีโหลดคงที่เล็กน้อยที่ทำงานกับฐานข้อมูลอย่างต่อเนื่องเป็นระยะเวลานาน มักจะดีกว่า แม้โหลดคงที่นั้นจะเพิ่มการใช้งาน CPU เพียง 1-5% ตลอด 24 ชั่วโมงก็ตาม เมื่อสามารถคาดการณ์โหลดได้ จะทำให้การตรวจสอบอัตราการใช้งานโดยรวมเป็นเรื่องง่ายขึ้น ความสามารถในการคาดการณ์นี้ช่วยให้เราสามารถขยายระบบในแนวนอนได้อย่างมั่นใจ และวางแผนล่วงหน้าสำหรับปัญหาด้านประสิทธิภาพที่อาจเกิดขึ้นในอนาคต

มีบทความที่น่าสนใจเกี่ยวกับเรื่องนี้โดยทีมงานของ AWS: ความน่าเชื่อถือ การทำงานอย่างต่อเนื่อง และกาแฟแก้วอร่อย

เวอร์ชันที่สองของการตรวจสุขภาพตอนนี้มีลักษณะดังนี้:

export const savingsAccount = createHealthCheckGroup({
  over: SlashAccountGroup,
  name: 'savings_account_interest_entities',
  schedules: {
    full_scan: {
      type: 'full_scan',
      config: {},
      props: {
        numberOfItemsToPaginate: 2000,
        minimumIntervalSinceStartOfLastRun: 3 * 60 * 60 * 1000,
      },
    },
  },
  checks: {
    interest_limit_rule_should_exist: {
      async check(group, ctx) {
        if (group.groupType === 'savings') {
          const activeInterestLimits = await from(SlashAccountGroupLimitRule)
            .leftJoin(LimitRule, {
              on: (_) => _.lr.id.equals(_.slr.limitRuleId),
            })
            .where((_) =>
              _.slr.slashAccountGroupId
                .equals(group.id)
                .and(_.slr.isActive.equals(true))
                .and(_.lr.type.equals('interest'))
            );

          ctx.shouldHaveLengthOf(activeInterestLimits, 1, {
            message:
              'Savings accounts should have exactly one active interest limit',
          });
        }
      },
    },
    // other checks
    ...
  }
});

ด้วยการออกแบบนี้ ทำให้การเพิ่มการตรวจสอบสุขภาพใหม่บนเอนทิตีเดียวเป็นเรื่องง่ายมาก เนื่องจากแต่ละการตรวจสอบสุขภาพถูกกำหนดให้เป็นฟังก์ชันเดียวแบบอะซิงโครนัส เราสามารถทำการตรวจสอบสุขภาพเหล่านี้ซ้ำไปซ้ำมาบนตารางทั้งหมดได้หลายครั้งต่อวัน โดยไม่จำเป็นต้องแลกเปลี่ยนความถี่ เนื่องจากปริมาณงานคงที่ สำหรับการตรวจสอบสุขภาพข้างต้น แต่ละเอนทิตีจะทำการค้นหาอย่างรวดเร็วไปยังตาราง `LimitRule` การตรวจสอบเหล่านี้ทำงานอย่างต่อเนื่อง ทำให้เกิดภาระงานที่น้อยแต่คงที่ต่อฐานข้อมูลในทุกช่วงเวลา

ระบบนี้ทำงานอย่างไรภายใต้ระบบสามารถอธิบายให้เข้าใจง่ายได้ดังนี้:

  1. เราทำการตรวจสอบของเราบน Temporal เพื่อให้เราสามารถรับประกันการดำเนินการในที่สุดได้ เราใช้ผลิตภัณฑ์ "schedules" ของ Temporal เพื่อให้แน่ใจว่าเราเรียกใช้ cron ของเราทุกนาที
  2. ทุกนาที เราจะตรวจสอบเช็คทั้งหมดที่ควรกำหนดเวลาให้ดำเนินการในทันที และนำเช็คเหล่านั้นไปดำเนินการในคิวงาน เราจะควบคุมคิวงานให้เหมาะสมเพื่อไม่ให้มีการส่งข้อมูลไปยังฐานข้อมูลเกินกว่า RPS ที่กำหนดไว้
  3. แต่ละกิจกรรมที่ทำงานจะดำเนินการงาน "คงที่" บางประเภท ไม่มีความซับซ้อนของเวลาแบบ O(N) หรือความซับซ้อนอื่น ๆ ที่เพิ่มขึ้นตามจำนวนของเอนทิตีทั้งหมดในฐานข้อมูล สิ่งนี้ช่วยให้มั่นใจว่ากิจกรรมทำงานได้อย่างรวดเร็ว คาดการณ์ได้ง่าย และจะไม่ทำให้คิวงานติดขัด

อะไรต่อไป

การตรวจสอบสุขภาพในปัจจุบันเป็นพื้นฐานที่สำคัญในกระบวนการทดสอบของเรา มันเป็นรากฐานสำหรับการทดสอบการตรวจสอบการผลิตอย่างต่อเนื่องของเรา เมื่อเราเติบโตขึ้น การทดสอบทั่วไปก็เพิ่มขึ้นเพื่อรักษาความเสถียรและการทำงานของผลิตภัณฑ์ของเราให้คงที่ เราต้องลองหลายครั้งกว่าจะทำได้ถูกต้อง เราต้องการที่จะนำมาใช้ระบบการทดสอบบัญชีแยกประเภท / การตรวจสอบข้อมูลในระบบการผลิตมาเป็นเวลาสามปีแล้ว อย่างไรก็ตาม จนกระทั่งเมื่อหนึ่งปีครึ่งที่ผ่านมา เราจึงสามารถสร้างระบบขึ้นมาได้ ในภาพรวม บทเรียนที่สำคัญที่เราได้เรียนรู้คือ:

  1. ส่งอะไรก็ได้ ส่งอะไรก็ได้ และปรับปรุงมันอย่างต่อเนื่องตามเวลา เราไม่สามารถไปถึงการปรับปรุงครั้งที่สองของระบบตรวจสอบสุขภาพของเราได้หากเราไม่ได้ส่งครั้งแรก
  2. ทำให้เรียบง่าย — เราต้องการให้วิศวกรสร้างและดูแลการตรวจสอบสุขภาพได้ง่ายที่สุดเท่าที่จะเป็นไปได้ การใช้ตรรกะทางธุรกิจที่ซับซ้อนฝังอยู่ในคำสั่ง SQL หลายคำสั่งไม่เคยเป็นเรื่องสนุกในการทำงาน
  3. รันการตรวจสอบของเราโดยตรงบนระบบผลิต — นั่นคือแหล่งข้อมูลที่ถูกต้องของเรา และหากเราทำการตรวจสอบกับสิ่งอื่นใด มันจะซับซ้อนมากขึ้นโดยธรรมชาติ และมีโอกาสเกิดผลบวกหรือลบที่ผิดพลาดได้มากขึ้น

วันนี้ การตรวจสอบสุขภาพเป็นก้าวแรกที่เราได้ทำเพื่อทดสอบและตรวจสอบระบบของเราให้ดีขึ้นนอกเหนือจากรูปแบบที่ดั้งเดิมมากขึ้นเช่นการทดสอบและการสังเกตการณ์ เมื่อเราเติบโตต่อไป เราจะค่อยๆ สะท้อนและค้นหาวิธีใหม่ ๆ ที่จะทำให้ผลิตภัณฑ์ของเราเสถียร

Read more from us