การประทับเวลาแบบชาร์ด

หากคอลเล็กชันมีเอกสารที่มีค่าดัชนีต่อเนื่องกัน Cloud Firestore จะจำกัดอัตราการเขียนไว้ที่ 500 รายการต่อวินาที หน้านี้อธิบายวิธีแบ่งพาร์ติชันช่องเอกสารเพื่อข้ามขีดจำกัดนี้ ก่อนอื่น เรามาดูกันก่อนว่า "ช่องดัชนีต่อเนื่องกัน" หมายถึงอะไรและขีดจำกัดนี้มีผลเมื่อใด

ช่องดัชนีต่อเนื่องกัน

"ช่องดัชนีต่อเนื่องกัน" หมายถึงคอลเล็กชันเอกสารที่มีช่องดัชนีที่เพิ่มขึ้นหรือลดลงอย่างเดียว ในหลายๆ กรณี ช่องนี้หมายถึงช่อง timestamp แต่ค่าในช่องที่เพิ่มขึ้นหรือลดลงอย่างเดียวก็สามารถทริกเกอร์ขีดจำกัดการเขียน 500 รายการต่อวินาทีได้

ตัวอย่างเช่น ขีดจำกัดนี้จะมีผลกับคอลเล็กชันเอกสาร user ที่มี ช่องดัชนี userid หากแอปกำหนดค่า userid ดังนี้

  • 1281, 1282, 1283, 1284, 1285, ...

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

  • 100000, 100001, 100002, 100003, ...
  • 0, 1, 2, 3, ...

การแบ่งพาร์ติชันช่องการประทับเวลา

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

  1. เพิ่มช่อง shard ข้างช่อง timestamp ใช้ค่าที่ไม่ซ้ำกัน 1..n สำหรับช่อง shard ซึ่งจะเพิ่มขีดจำกัดการเขียน สำหรับคอลเล็กชันเป็น 500*n แต่คุณต้องรวมการค้นหา n รายการ
  2. อัปเดตตรรกะการเขียนเพื่อกำหนดค่า shard ให้กับเอกสารแต่ละรายการ แบบสุ่ม
  3. อัปเดตการค้นหาเพื่อรวมชุดผลลัพธ์ที่แบ่งพาร์ติชัน
  4. ปิดใช้ดัชนีช่องเดียวสำหรับทั้งช่อง shard และช่อง timestamp ลบดัชนีผสมที่มีอยู่ซึ่งมีช่อง timestamp
  5. สร้างดัชนีผสมใหม่เพื่อรองรับการค้นหาที่อัปเดต ลำดับของช่องในดัชนีมีความสำคัญ และช่อง shard ต้องมาก่อนช่อง timestamp ดัชนีที่มีช่อง timestamp ต้องมีช่อง shard ด้วย

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

ตัวอย่างต่อไปนี้แสดงวิธีแบ่งพาร์ติชันช่อง timestamp และวิธีค้นหาชุดผลลัพธ์ที่แบ่งพาร์ติชัน

โมเดลข้อมูลและการค้นหาตัวอย่าง

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

Node.js
async function insertData() {
  const instruments = [
    {
      symbol: 'AAA',
      price: {
        currency: 'USD',
        micros: 34790000
      },
      exchange: 'EXCHG1',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.010Z'))
    },
    {
      symbol: 'BBB',
      price: {
        currency: 'JPY',
        micros: 64272000000
      },
      exchange: 'EXCHG2',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.101Z'))
    },
    {
      symbol: 'Index1 ETF',
      price: {
        currency: 'USD',
        micros: 473000000
      },
      exchange: 'EXCHG1',
      instrumentType: 'etf',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.001Z'))
    }
  ];

  const batch = fs.batch();
  for (const inst of instruments) {
    const ref = fs.collection('instruments').doc();
    batch.set(ref, inst);
  }

  await batch.commit();
}

แอปนี้จะเรียกใช้การค้นหาต่อไปนี้และจัดเรียงตามช่อง timestamp

Node.js
function createQuery(fieldName, fieldOperator, fieldValue, limit = 5) {
  return fs.collection('instruments')
      .where(fieldName, fieldOperator, fieldValue)
      .orderBy('timestamp', 'desc')
      .limit(limit)
      .get();
}

function queryCommonStock() {
  return createQuery('instrumentType', '==', 'commonstock');
}

function queryExchange1Instruments() {
  return createQuery('exchange', '==', 'EXCHG1');
}

function queryUSDInstruments() {
  return createQuery('price.currency', '==', 'USD');
}
insertData()
    .then(() => {
      const commonStock = queryCommonStock()
          .then(
              (docs) => {
                console.log('--- queryCommonStock: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const exchange1Instruments = queryExchange1Instruments()
          .then(
              (docs) => {
                console.log('--- queryExchange1Instruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const usdInstruments = queryUSDInstruments()
          .then(
              (docs) => {
                console.log('--- queryUSDInstruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      return Promise.all([commonStock, exchange1Instruments, usdInstruments]);
    });

หลังจากทำการวิจัยแล้ว คุณพบว่าแอปจะได้รับการอัปเดตเครื่องมือทางการเงินระหว่าง 1,000 ถึง 1,500 รายการต่อวินาที ซึ่งเกินกว่า 500 รายการต่อวินาทีที่อนุญาตสำหรับคอลเล็กชันที่มีเอกสารที่มีช่องการประทับเวลาที่จัดทำดัชนี หากต้องการเพิ่มอัตราการส่งข้อมูลการเขียน คุณต้องมีค่าชาร์ด 3 ค่า ได้แก่ MAX_INSTRUMENT_UPDATES/500 = 3 ตัวอย่างนี้ใช้ค่า shard x, y และ z คุณยังใช้ตัวเลขหรืออักขระอื่นๆ สำหรับค่า shard ได้ด้วย

การเพิ่มช่อง shard

เพิ่มช่อง shard ลงในเอกสาร ตั้งค่าช่อง shard เป็นค่า x, y หรือ z ซึ่งจะเพิ่มขีดจำกัดการเขียนในคอลเล็กชันเป็น 1,500 รายการต่อวินาที

Node.js
// Define our 'K' shard values
const shards = ['x', 'y', 'z'];
// Define a function to help 'chunk' our shards for use in queries.
// When using the 'in' query filter there is a max number of values that can be
// included in the value. If our number of shards is higher than that limit
// break down the shards into the fewest possible number of chunks.
function shardChunks() {
  const chunks = [];
  let start = 0;
  while (start < shards.length) {
    const elements = Math.min(MAX_IN_VALUES, shards.length - start);
    const end = start + elements;
    chunks.push(shards.slice(start, end));
    start = end;
  }
  return chunks;
}

// Add a convenience function to select a random shard
function randomShard() {
  return shards[Math.floor(Math.random() * Math.floor(shards.length))];
}
async function insertData() {
  const instruments = [
    {
      shard: randomShard(),  // add the new shard field to the document
      symbol: 'AAA',
      price: {
        currency: 'USD',
        micros: 34790000
      },
      exchange: 'EXCHG1',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.010Z'))
    },
    {
      shard: randomShard(),  // add the new shard field to the document
      symbol: 'BBB',
      price: {
        currency: 'JPY',
        micros: 64272000000
      },
      exchange: 'EXCHG2',
      instrumentType: 'commonstock',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.101Z'))
    },
    {
      shard: randomShard(),  // add the new shard field to the document
      symbol: 'Index1 ETF',
      price: {
        currency: 'USD',
        micros: 473000000
      },
      exchange: 'EXCHG1',
      instrumentType: 'etf',
      timestamp: Timestamp.fromMillis(
          Date.parse('2019-01-01T13:45:23.001Z'))
    }
  ];

  const batch = fs.batch();
  for (const inst of instruments) {
    const ref = fs.collection('instruments').doc();
    batch.set(ref, inst);
  }

  await batch.commit();
}

การค้นหาการประทับเวลาที่แบ่งพาร์ติชัน

การเพิ่มช่อง shard กำหนดให้คุณต้องอัปเดตการค้นหาเพื่อรวมผลลัพธ์ที่แบ่งพาร์ติชัน

Node.js
function createQuery(fieldName, fieldOperator, fieldValue, limit = 5) {
  // For each shard value, map it to a new query which adds an additional
  // where clause specifying the shard value.
  return Promise.all(shardChunks().map(shardChunk => {
        return fs.collection('instruments')
            .where('shard', 'in', shardChunk)  // new shard condition
            .where(fieldName, fieldOperator, fieldValue)
            .orderBy('timestamp', 'desc')
            .limit(limit)
            .get();
      }))
      // Now that we have a promise of multiple possible query results, we need
      // to merge the results from all of the queries into a single result set.
      .then((snapshots) => {
        // Create a new container for 'all' results
        const docs = [];
        snapshots.forEach((querySnapshot) => {
          querySnapshot.forEach((doc) => {
            // append each document to the new all container
            docs.push(doc);
          });
        });
        if (snapshots.length === 1) {
          // if only a single query was returned skip manual sorting as it is
          // taken care of by the backend.
          return docs;
        } else {
          // When multiple query results are returned we need to sort the
          // results after they have been concatenated.
          // 
          // since we're wanting the `limit` newest values, sort the array
          // descending and take the first `limit` values. By returning negated
          // values we can easily get a descending value.
          docs.sort((a, b) => {
            const aT = a.data().timestamp;
            const bT = b.data().timestamp;
            const secondsDiff = aT.seconds - bT.seconds;
            if (secondsDiff === 0) {
              return -(aT.nanoseconds - bT.nanoseconds);
            } else {
              return -secondsDiff;
            }
          });
          return docs.slice(0, limit);
        }
      });
}

function queryCommonStock() {
  return createQuery('instrumentType', '==', 'commonstock');
}

function queryExchange1Instruments() {
  return createQuery('exchange', '==', 'EXCHG1');
}

function queryUSDInstruments() {
  return createQuery('price.currency', '==', 'USD');
}
insertData()
    .then(() => {
      const commonStock = queryCommonStock()
          .then(
              (docs) => {
                console.log('--- queryCommonStock: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const exchange1Instruments = queryExchange1Instruments()
          .then(
              (docs) => {
                console.log('--- queryExchange1Instruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      const usdInstruments = queryUSDInstruments()
          .then(
              (docs) => {
                console.log('--- queryUSDInstruments: ');
                docs.forEach((doc) => {
                  console.log(`doc = ${util.inspect(doc.data(), {depth: 4})}`);
                });
              }
          );
      return Promise.all([commonStock, exchange1Instruments, usdInstruments]);
    });

อัปเดตคำจำกัดความของดัชนี

หากต้องการนำข้อจำกัด 500 รายการต่อวินาทีออก ให้ลบดัชนีช่องเดียวและดัชนีผสมที่มีอยู่ซึ่งใช้ช่อง timestamp

ลบคำจำกัดความของดัชนีผสม

คอนโซล Firebase

  1. เปิดหน้า Cloud Firestore ดัชนีผสม ในคอนโซล Firebase

    ไปที่ดัชนีผสม

  2. สำหรับดัชนีแต่ละรายการที่มีช่อง timestamp ให้คลิกปุ่ม แล้วคลิก ลบ

คอนโซล GCP

  1. ในคอนโซล Google Cloud ให้ไปที่หน้าฐานข้อมูล

    ไปที่ฐานข้อมูล

  2. เลือกฐานข้อมูลที่ต้องการจากรายการฐานข้อมูล

  3. ในเมนูการนำทาง ให้คลิกดัชนี แล้วคลิกแท็บผสม

  4. ใช้ช่องตัวกรอง เพื่อค้นหาคำจำกัดความของดัชนีที่มีช่อง timestamp

  5. สำหรับดัชนีแต่ละรายการ ให้คลิกปุ่ม แล้วคลิก ลบ

Firebase CLI

  1. หากยังไม่ได้ตั้งค่า Firebase CLI ให้ทำตามวิธีการเหล่านี้เพื่อติดตั้ง CLI และเรียกใช้คำสั่งfirebase init ในระหว่างคำสั่ง init ให้เลือก Firestore: Deploy rules and create indexes for Firestore
  2. ในระหว่างการตั้งค่า Firebase CLI จะดาวน์โหลดคำจำกัดความของดัชนีที่มีอยู่ลงในไฟล์ที่ชื่อ firestore.indexes.json โดยค่าเริ่มต้น
  3. นำคำจำกัดความของดัชนีที่มีช่อง timestamp ออก เช่น

    {
    "indexes": [
      // Delete composite index definition that contain the timestamp field
      {
        "collectionGroup": "instruments",
        "queryScope": "COLLECTION",
        "fields": [
          {
            "fieldPath": "exchange",
            "order": "ASCENDING"
          },
          {
            "fieldPath": "timestamp",
            "order": "DESCENDING"
          }
        ]
      },
      {
        "collectionGroup": "instruments",
        "queryScope": "COLLECTION",
        "fields": [
          {
            "fieldPath": "instrumentType",
            "order": "ASCENDING"
          },
          {
            "fieldPath": "timestamp",
            "order": "DESCENDING"
          }
        ]
      },
      {
        "collectionGroup": "instruments",
        "queryScope": "COLLECTION",
        "fields": [
          {
            "fieldPath": "price.currency",
            "order": "ASCENDING"
          },
          {
            "fieldPath": "timestamp",
            "order": "DESCENDING"
          }
        ]
      },
     ]
    }
    
  4. ติดตั้งใช้งานคำจำกัดความของดัชนีที่อัปเดตแล้ว

    firebase deploy --only firestore:indexes
    

อัปเดตคำจำกัดความของดัชนีช่องเดียว

คอนโซล Firebase

  1. เปิดหน้า Cloud Firestoreดัชนีช่องเดียว ใน คอนโซล Firebase

    ไปที่ดัชนีช่องเดียว

  2. คลิก เพิ่มการยกเว้น

  3. ป้อน instruments ในส่วน รหัสคอลเล็กชัน ป้อน timestamp ในส่วน เส้นทางของช่อง

  4. ในส่วน ขอบเขตการค้นหา ให้เลือกทั้ง คอลเล็กชัน และ กลุ่มคอลเล็กชัน

  5. คลิกถัดไป

  6. สลับการตั้งค่าดัชนีทั้งหมดเป็นปิดใช้ คลิกบันทึก

  7. ทำซ้ำขั้นตอนเดียวกันสำหรับช่อง shard

คอนโซล GCP

  1. ในคอนโซล Google Cloud ให้ไปที่หน้าฐานข้อมูล

    ไปที่ฐานข้อมูล

  2. เลือกฐานข้อมูลที่ต้องการจากรายการฐานข้อมูล

  3. ในเมนูการนำทาง ให้คลิกดัชนี แล้วคลิกแท็บช่องเดียว

  4. คลิกแท็บช่องเดียว

  5. คลิก เพิ่มการยกเว้น

  6. ป้อน instruments ในส่วน รหัสคอลเล็กชัน ป้อน timestamp ในส่วน เส้นทางของช่อง

  7. ในส่วน ขอบเขตการค้นหา ให้เลือกทั้ง คอลเล็กชัน และ กลุ่มคอลเล็กชัน

  8. คลิกถัดไป

  9. สลับการตั้งค่าดัชนีทั้งหมดเป็นปิดใช้ คลิกบันทึก

  10. ทำซ้ำขั้นตอนเดียวกันสำหรับช่อง shard

Firebase CLI

  1. เพิ่มโค้ดต่อไปนี้ลงในส่วน fieldOverrides ของไฟล์คำจำกัดความของดัชนี

    {
     "fieldOverrides": [
       // Disable single-field indexing for the timestamp field
       {
         "collectionGroup": "instruments",
         "fieldPath": "timestamp",
         "indexes": []
       },
     ]
    }
    
  2. ติดตั้งใช้งานคำจำกัดความของดัชนีที่อัปเดตแล้ว

    firebase deploy --only firestore:indexes
    

สร้างดัชนีผสมใหม่

หลังจากนำดัชนีก่อนหน้าทั้งหมดที่มี timestamp ออกแล้ว ให้กำหนดดัชนีใหม่ที่แอปต้องการ ดัชนีที่มีช่อง timestamp ต้องมีช่อง shard ด้วย ตัวอย่างเช่น หากต้องการรองรับการค้นหาข้างต้น ให้เพิ่มดัชนีต่อไปนี้

คอลเล็กชัน ช่องที่จัดทำดัชนี ขอบเขตการค้นหา
instruments shard, price.currency, timestamp คอลเล็กชัน
instruments shard, exchange, timestamp คอลเล็กชัน
instruments shard, instrumentType, timestamp คอลเล็กชัน

ข้อความแสดงข้อผิดพลาด

คุณสร้างดัชนีเหล่านี้ได้โดยเรียกใช้การค้นหาที่อัปเดต

การค้นหาแต่ละรายการจะแสดงข้อความแสดงข้อผิดพลาดพร้อมลิงก์เพื่อสร้างดัชนีที่จำเป็นในคอนโซล Firebase

Firebase CLI

  1. เพิ่มดัชนีต่อไปนี้ลงในไฟล์คำจำกัดความของดัชนี

     {
       "indexes": [
       // New indexes for sharded timestamps
         {
           "collectionGroup": "instruments",
           "queryScope": "COLLECTION",
           "fields": [
             {
               "fieldPath": "shard",
               "order": "DESCENDING"
             },
             {
               "fieldPath": "exchange",
               "order": "ASCENDING"
             },
             {
               "fieldPath": "timestamp",
               "order": "DESCENDING"
             }
           ]
         },
         {
           "collectionGroup": "instruments",
           "queryScope": "COLLECTION",
           "fields": [
             {
               "fieldPath": "shard",
               "order": "DESCENDING"
             },
             {
               "fieldPath": "instrumentType",
               "order": "ASCENDING"
             },
             {
               "fieldPath": "timestamp",
               "order": "DESCENDING"
             }
           ]
         },
         {
           "collectionGroup": "instruments",
           "queryScope": "COLLECTION",
           "fields": [
             {
               "fieldPath": "shard",
               "order": "DESCENDING"
             },
             {
               "fieldPath": "price.currency",
               "order": "ASCENDING"
             },
             {
               "fieldPath": "timestamp",
               "order": "DESCENDING"
             }
           ]
         },
       ]
     }
    
  2. ติดตั้งใช้งานคำจำกัดความของดัชนีที่อัปเดตแล้ว

    firebase deploy --only firestore:indexes
    

ทำความเข้าใจขีดจำกัดการเขียนสำหรับช่องที่มีการจัดทำดัชนีตามลำดับ

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

Cloud Firestore จะวางรายการดัชนีที่ใกล้เคียงกันตามลำดับพจนานุกรมไว้ใน แท็บเล็ตเดียวกัน หากค่าดัชนีในแท็บเล็ตอยู่ใกล้กันมากเกินไป เช่น สำหรับ ช่องการประทับเวลา Cloud Firestore จะแยก แท็บเล็ตออกเป็นแท็บเล็ตขนาดเล็กลงได้อย่างไม่มีประสิทธิภาพ ซึ่งจะสร้างฮอตสปอตที่แท็บเล็ตเดียวรับการเข้าชมมากเกินไป และการอ่านและการเขียนไปยังฮอตสปอตจะช้าลง

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

ขั้นตอนถัดไป