הגשת קובץ מתוך בלוב של MySQL, ומדוע כנראה אין לעשות זאת

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

הטבלה

CREATE TABLE files (
    id        INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
    name      VARCHAR(255) NOT NULL,
    mime      VARCHAR(127) NOT NULL,
    size      INT UNSIGNED NOT NULL,
    modified  DATETIME NOT NULL,
    data      LONGBLOB NOT NULL
);

אחסנו את טיפוס ה-MIME והגודל לצד הבתים. לחשב אותם בזמן ההגשה אפשרי אבל בזבזני, והגודל נחוץ לכותרת לפני שהנתונים נקראים. LONGBLOB מחזיק עד ארבעה ג'יגה-בייט; BLOB לבדו נעצר ב-64 קילו-בייט, וזו ההפתעה הראשונה שכולם נתקלים בה.

הסקריפט

<?php
$id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
if ($id <= 0) {
    header('HTTP/1.1 404 Not Found');
    exit;
}

$db = new PDO('mysql:host=localhost;dbname=site;charset=utf8', 'user', 'password');
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

$stmt = $db->prepare('SELECT name, mime, size, modified, data FROM files WHERE id = ?');
$stmt->execute(array($id));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
    header('HTTP/1.1 404 Not Found');
    exit;
}

$etag     = '"' . md5($id . $row['modified'] . $row['size']) . '"';
$modified = gmdate('D, d M Y H:i:s', strtotime($row['modified'])) . ' GMT';

if ((isset($_SERVER['HTTP_IF_NONE_MATCH']) && $_SERVER['HTTP_IF_NONE_MATCH'] === $etag) ||
    (isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $_SERVER['HTTP_IF_MODIFIED_SINCE'] === $modified)) {
    header('HTTP/1.1 304 Not Modified');
    exit;
}

header('Content-Type: ' . $row['mime']);
header('Content-Length: ' . $row['size']);
header('Content-Disposition: inline; filename="' . addslashes($row['name']) . '"');
header('Last-Modified: ' . $modified);
header('ETag: ' . $etag);
header('Cache-Control: public, max-age=86400');
echo $row['data'];

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

המירו את המזהה למספר. (int) הופך כל דבר עוין במחרוזת השאילתה למספר. ההצהרה המוכנה הייתה מגנה עליכם בכל מקרה, אבל בקשה ל-id=abc צריכה להיות 404, לא שאילתה.

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

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

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

שימו לב גם למה שאינו בסקריפט: אין ob_start(), אין פלט לפני הכותרות, ואין תג סיום ?>, שהיה מסתכן בשליחת שורה חדשה אחרי הבתים.

מדוע מערכת הקבצים בדרך כלל טובה יותר

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

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

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

וכלי התפעול אינם מתאימים. אינכם יכולים לעשות ls לעמודת בלוב, rsync לה, או למסור ספרייה ל-CDN.

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

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