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

หากคอลเล็กชันมีเอกสารที่มีค่าดัชนีเรียงตามลำดับ 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 รายการต่อวินาทีที่อนุญาตสำหรับคอลเล็กชันที่มีเอกสารที่มีฟิลด์การประทับเวลาที่จัดทำดัชนี หากต้องการเพิ่มปริมาณงานการเขียน คุณต้องมีค่า shard 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. ในคอนโซล Firebase ให้ไปที่แท็บฐานข้อมูลและพื้นที่เก็บข้อมูล > Firestore > ดัชนีผสม

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

  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. ในคอนโซล Firebase ให้ไปที่แท็บฐานข้อมูลและพื้นที่เก็บข้อมูล > Firestore > ดัชนีฟิลด์เดียว

    ไปที่ดัชนีฟิลด์เดียว

  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 มีพื้นที่เพียงพอ ระหว่างรายการดัชนีเพื่อแยกรายการไปยังแท็บเล็ตหลายรายการ

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