多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

菜谱数据库设计指南:从表结构到按食材查询的SQL技巧

菜谱数据库设计指南:从表结构到按食材查询的SQL技巧 简介《食谱菜谱大全》数据库资源包收录了多达4927条菜品信息字段涵盖菜名、做法、功效、烹饪时间、烹饪方式等适合烹饪爱好者、数据分析师及Web开发者用于饮食文化统计、健康食谱筛选或美食推荐系统开发。压缩包内共包含4个文件分别提供SQL、JSON、CSV、XLS四种不同的格式SQL便于关系型数据库查询和聚合分析JSON适合Web前后端数据交互CSV可快速导入Excel进行清洗整理XLS则方便制作图表、执行公式统计整套资源约10.35MB。已有4690人学习下载可据此探究不同菜系分布、对比烹饪效率、分析功效与食材关联既能快速查询最短烹饪时间或按功效筛选菜品也能为个性化菜谱推荐、在线教学平台或健康管理应用提供结构化数据基础是烹饪学习、研究与应用开发的实用数据素材。1. 为什么「食谱菜谱大全数据库」先要把菜谱拆开存如果你手上攒了上千条菜谱最先想到的存储方案多半是 Excel 或 Markdown一行一道菜食材、步骤、备注全塞在一格里。直到你想回答三个问题时这套方案就扛不住了冰箱里有番茄和鸡蛋能做什么菜哪些菜用了鸡肉、同时还是辣的一顿饭按人份放大到八人桌食材量怎么算食谱菜谱大全数据库要做的就是把这些原本躺在文本里的菜谱信息拆成一张张能互相 JOIN 的关系表让「按食材找菜」「按标签聚合」「按人份缩放」变成几条标准 SQL。这篇文章面向的是打算自建菜谱库的开发者、做菜单或推荐功能的后端以及想把菜谱沉淀成数据资产的个人。核心结论先说这道题的重点不在「怎么存菜谱」而在「怎么把菜谱拆得足够细还拆不坏」。2. 食谱数据库的表结构设计五张基表与字段取舍2.1 为什么不能把菜谱塞进一张大宽表常见的第一版设计是这样的recipes 表里放一个ingredients字段把「番茄 2 个鸡蛋 3 个盐 适量」整段写进 VARCHARsteps字段同理。录入时很痛快查询时就全成了黑匣子想筛「用了番茄的菜」只能LIKE %番茄%再被「西红柿」「圣女果」这些别名漏掉一大半想算一道菜的热量得先把字符串切开再做单位换算正则表达式写了一晚上第二天自己都看不懂。把菜谱拆成关系模型之后答案就简单了一道菜是 recipes 里的一行一种食材是 ingredients 里的一行它们之间的多对多关系落在 recipe_ingredients 里步骤按顺序放在 steps 里标签单独成表再挂关联。数据库擅长的等值查询、分组聚合、外键约束全部能派上用场。这是做这类库「最值得先想清楚」的一步后面的导入脚本、报表 SQL 都是顺着表结构长出来的。2.2 五张基表的建表 SQL 与字段参数说明库的底座我默认用 MySQL 8InnoDB 支持外键级联utf8mb4 对中文友好Ngram 全文检索能直接搜菜名。如果你的场景只是个人单机使用换成 SQLite 也完全跑得动但外键开关和全文索引语法要跟着调整。下面是建表语句先从菜谱主表开始-- 菜谱主表只放「一道菜」自己的属性不放食材明细 CREATE TABLE recipes ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(100) NOT NULL COMMENT 菜名如 西红柿炒鸡蛋, category VARCHAR(50) NOT NULL DEFAULT 家常菜 COMMENT 分类家常菜/汤/主食等, cuisine VARCHAR(30) DEFAULT NULL COMMENT 菜系川/粤/鲁可空, difficulty TINYINT NOT NULL DEFAULT 2 COMMENT 1简单 2普通 3挑战, cook_time_min SMALLINT UNSIGNED NOT NULL DEFAULT 15 COMMENT 预计耗时(分钟), servings TINYINT UNSIGNED NOT NULL DEFAULT 2 COMMENT 标准份量(人份), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_name_category (name, category) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;这里几个参数值得单独说。UNIQUE KEY uk_name_category是幂等导入的底线同一道菜即使在不同来源里出现只要菜名和分类一致就不会被重复插入。difficulty用 TINYINT 而不是 VARCHAR是为了能直接按难度排序。servings是后面所有份量换算的分母必须设成非空否则统计「一人份热量」时会除零。cuisine允许 NULL因为很多家常菜谈不上菜系强填一个反而失真。食材字典表解决的是「同一食材多个叫法」的问题-- 食材字典每个食材只保留一个标准名 CREATE TABLE ingredients ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT 标准名如 番茄, category VARCHAR(30) DEFAULT NULL COMMENT 蔬菜/肉类/调料等, cal_per_100g SMALLINT UNSIGNED DEFAULT NULL COMMENT 每100克千卡可空, PRIMARY KEY (id), UNIQUE KEY uk_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;UNIQUE KEY uk_name保证「番茄」只有一个 ID。如果你在菜谱里看到「西红柿」正确做法是在别名表里加一条映射而不是在 ingredients 表再插一行「西红柿」——否则按食材关联时这两道菜永远 JOIN 不到一起。cal_per_100g允许 NULL因为很多调料的营养数据查不到硬给 0 会把热量统计带偏。菜谱与食材的关联表是这类库的核心字段设计最容易出错-- 菜谱-食材关联表数量、单位、备注都放这里 CREATE TABLE recipe_ingredients ( recipe_id INT UNSIGNED NOT NULL, ingredient_id INT UNSIGNED NOT NULL, quantity DECIMAL(8,2) DEFAULT NULL COMMENT 数量NULL表示“适量”, unit VARCHAR(20) DEFAULT NULL COMMENT 单位克/毫升/个/瓣/勺等, note VARCHAR(100) DEFAULT NULL COMMENT 预处理说明去皮、切滚刀块, sort_no TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT 食材在配料表中的展示顺序, PRIMARY KEY (recipe_id, ingredient_id), KEY idx_ingredient (ingredient_id), CONSTRAINT fk_ri_recipe FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE, CONSTRAINT fk_ri_ingredient FOREIGN KEY (ingredient_id) REFERENCES ingredients(id) ON DELETE RESTRICT ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;复合主键(recipe_id, ingredient_id)从结构上杜绝了同一道菜重复挂同一食材。外键策略是我刻意选的菜谱删了关联食材记录跟着级联删除食材字典被引用时不允许直接删防止历史菜谱变成无主数据。quantity允许 NULL就是为了「盐适量」「糖少许」这类写法后面第 5 章会专门讲这个坑。unit保留字符串而不是外键因为「个」「瓣」「把」「撮」这类异形单位很难枚举干净强行建单位表反而增加录入成本。步骤表与标签表-- 步骤表步骤顺序由复合主键保证 CREATE TABLE steps ( recipe_id INT UNSIGNED NOT NULL, step_no SMALLINT UNSIGNED NOT NULL DEFAULT 1, description VARCHAR(500) NOT NULL, timer_min SMALLINT UNSIGNED DEFAULT NULL COMMENT 这步预计分钟NULL表示不专门计时, PRIMARY KEY (recipe_id, step_no), CONSTRAINT fk_steps_recipe FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 标签表辣/下饭/快手/空气炸锅这类多对多属性 CREATE TABLE tags ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, name VARCHAR(30) NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_tag_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE recipe_tags ( recipe_id INT UNSIGNED NOT NULL, tag_id INT UNSIGNED NOT NULL, PRIMARY KEY (recipe_id, tag_id), KEY idx_tag (tag_id), CONSTRAINT fk_rt_tag FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;steps用(recipe_id, step_no)复合主键天然保证每个步骤的顺序号唯一且连续。如果把它拆成独立自增 ID排序时必须额外维护step_no的稳定性得不偿失。标签拆成三张表tags、recipe_tags、recipes而不是在 recipes 表放一个tags字段是因为「下饭」和「快手」这类标签是典型的多对多关系一个标签对应大量菜谱一个菜谱也有多个标签。只放一个逗号分隔字段后面做「推荐相似菜谱」「按标签统计」全部要FIND_IN_SET性能差还容易统计错。2.3 数量与单位为什么要拆成两列很多从文本起步的菜谱库会把「300克」「2瓣」「半勺」原样存成一个字符串字段。录入舒服了查询就遭罪按人份缩放时字符串没法乘系数算热量时得先正则抽出数字再判断单位是克还是毫升还要处理「半勺」这种中文数字。我一般会把quantity存成 DECIMALunit另起一列存单位两个字段都允许 NULL。这样「番茄 2 个」就是 quantity2、unit个「盐 适量」就是 quantityNULL、unitNULLnote 写「适量」。统计时只需要把单位统一换算成克或毫升NULL 值直接跳过逻辑干净很多。提示处理「适量」时千万别在导入阶段把 NULL 强行转成 0否则后面热量统计会把一道菜算成零卡报表一出来全是反直觉数字。3. 从菜谱 JSON 到 MySQL导入脚本与增删改查3.1 准备一份结构化的菜谱 JSON 并用 Python 灌库手工往五张表里 INSERT 菜谱不现实常见的做法是先把菜谱整理成结构化的 JSON 文件再用脚本统一写入。JSON 的字段设计尽量和表结构对齐省去脚本里做映射的麻烦。下面是一个最小样例[ { name: 西红柿炒鸡蛋, category: 家常菜, cuisine: null, difficulty: 1, cook_time_min: 15, servings: 2, ingredients: [ {name: 番茄, quantity: 2, unit: 个, note: 切块}, {name: 鸡蛋, quantity: 3, unit: 个, note: 打散}, {name: 盐, quantity: null, unit: null, note: 适量} ], steps: [ {step_no: 1, description: 番茄切块鸡蛋打散, timer_min: 5} ], tags: [下饭, 快手] } ]导入脚本用 Python 写连接 MySQL 用 PyMySQL核心逻辑是「食材和标签先查后插菜谱和关联关系用 UPSERT 保证可重跑」import json import pymysql conn pymysql.connect( host127.0.0.1, port3306, userroot, password***, databasecookbook, charsetutf8mb4, autocommitFalse ) def get_or_create_ingredient(cur, name): cur.execute(SELECT id FROM ingredients WHERE name %s, (name,)) row cur.fetchone() if row: return row[0] cur.execute(INSERT INTO ingredients (name) VALUES (%s), (name,)) return cur.lastrowid def get_or_create_tag(cur, name): cur.execute(SELECT id FROM tags WHERE name %s, (name,)) row cur.fetchone() if row: return row[0] cur.execute(INSERT INTO tags (name) VALUES (%s), (name,)) return cur.lastrowid with conn.cursor() as cur: recipes json.load(open(recipes.json, encodingutf-8)) for r in recipes: # UPSERT 主表命中唯一键时只更新分类不重复插入 cur.execute( INSERT INTO recipes (name, category, cuisine, difficulty, cook_time_min, servings) VALUES (%s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE categoryVALUES(category), (r[name], r[category], r[cuisine], r[difficulty], r[cook_time_min], r[servings]) ) # 命中唯一键时 lastrowid 不可靠重新查一次 cur.execute(SELECT id FROM recipes WHERE name%s, (r[name],)) recipe_id cur.fetchone()[0] for ing in r[ingredients]: ing_id get_or_create_ingredient(cur, ing[name]) cur.execute( INSERT INTO recipe_ingredients (recipe_id, ingredient_id, quantity, unit, note) VALUES (%s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE quantityVALUES(quantity), unitVALUES(unit), noteVALUES(note), (recipe_id, ing_id, ing[quantity], ing[unit], ing[note]) ) # 步骤和标签按「全量覆盖」处理先删旧数据再插入 cur.execute(DELETE FROM steps WHERE recipe_id%s, (recipe_id,)) cur.execute(DELETE FROM recipe_tags WHERE recipe_id%s, (recipe_id,)) for s in r[steps]: cur.execute( INSERT INTO steps (recipe_id, step_no, description, timer_min) VALUES (%s, %s, %s, %s), (recipe_id, s[step_no], s[description], s[timer_min]) ) for t in r[tags]: tag_id get_or_create_tag(cur, t) cur.execute( INSERT IGNORE INTO recipe_tags (recipe_id, tag_id) VALUES (%s, %s), (recipe_id, tag_id) ) conn.commit()这段脚本有三个要点。第一连接参数里的charsetutf8mb4必须和建库字符集一致否则中文大概率变乱码这个坑后面还会单独讲。第二所有 SQL 都用%s占位符传参不要用 f-string 拼 SQL食材名里的引号、注释里的特殊字符一旦没转义轻则语法报错重则埋下 SQL 注入隐患。第三食材和标签的「先查后插」逻辑里name上的唯一索引是并发安全的关键没有唯一索引时两个请求同时查到「不存在」就会插入两条同名食材。如果菜谱量上万条建议把单连接改成连接池避免每条数据都走一次 TCP 握手一次性executemany批量插入也比逐条execute快很多。导入的耗时大头在食材字典的反复查询数据量上来后可以考虑一次性把全部食材 ID 加载到内存字典避免每次都在数据库里做一次 SELECT。3.2 增删改查四个高频 SQL 与参数化写法菜谱库上线后的日常操作绕不开增删改查这四个动作。插入一道新菜INSERT INTO recipes (name, category, cuisine, difficulty, cook_time_min, servings) VALUES (麻婆豆腐, 家常菜, 川, 2, 20, 2);这个语句本身没什么特别值得提醒的是接入了唯一索引uk_name_category后如果重复执行这条 SQLMySQL 会直接报Duplicate entry错误。应用层要提前捕获这个错误码按「已存在」处理而不是让它变成 500。按食材反查菜谱是这类库最常用的查询也最考验 JOIN 的写法SELECT r.id, r.name FROM recipes r JOIN recipe_ingredients ri ON ri.recipe_id r.id JOIN ingredients i ON i.id ri.ingredient_id WHERE i.name IN (番茄, 鸡蛋) GROUP BY r.id, r.name HAVING COUNT(DISTINCT i.id) 2;HAVING COUNT(DISTINCT i.id) 2的意思是这道菜必须同时包含番茄和鸡蛋两种食材而不是只含其中一种。如果把 换成 1结果会变成「番茄或鸡蛋任意一个就能做」语义完全不同。更新和删除UPDATE recipes SET cook_time_min 25 WHERE id 123; DELETE FROM recipes WHERE id 123;DELETE只需要删主表这一行外键的ON DELETE CASCADE会自动清掉 recipe_ingredients、steps、recipe_tags 里的关联数据不用应用层手动逐表清理。这也是建表时选 InnoDB 而不是 MyISAM 的原因之一后者不支持外键删一道菜要写三段 DELETE少一段就留垃圾数据。3.3 幂等重跑让导入脚本可以反复执行而不翻倍导入脚本最怕的不是跑不通而是跑通了两次。第一次灌进去 1000 道菜修复了几个 JSON 字段后重跑发现 recipes 表变成 2000 行。原因是主表插入用的是裸INSERT没有唯一约束同一道菜被当成新菜插入了。解决分两步。第一步是给recipes.name category建唯一索引这是「防重复」的根基。第二步是把裸 INSERT 改成INSERT ... ON DUPLICATE KEY UPDATE命中唯一键时执行更新而不是报错。这样脚本重跑多少遍主表数据都不会膨胀。子表 recipe_ingredients 同理用复合主键加上 UPSERT重复执行只覆盖数量、单位、备注不产生新行。步骤和标签则按「先 DELETE 再 INSERT」的方式重刷因为步骤顺序可能调整旧记录必须清掉才能保证step_no连续。4. 索引与全文检索让菜谱库查询不靠全表扫描4.1 索引取舍外键必建低区分度列别建菜谱库的数据量通常不会太大几万条菜谱 MySQL 完全扛得住但随着食材关联表和标签表膨胀常见的「按食材找菜」查询如果不走索引一样会把数据库拖慢。建索引的原则其实很朴素外键列一定建高频过滤列按区分度决定低区分度列建了是负担。recipe_ingredients 表的主键是(recipe_id, ingredient_id)按菜谱正向查食材时走的复合主键按食材反向查菜谱时走的是KEY idx_ingredient (ingredient_id)。这两条路径都是索引覆盖不用回表。recipes 表上的category和cuisine适合建普通索引因为「查某分类下的菜」是高频操作。difficulty这种只有三四个取值的列就别建了区分度太低优化器算一下发现全表扫描更快索引白占空间。验证索引是否生效最直接的手段是EXPLAIN:EXPLAIN SELECT r.id, r.name FROM recipes r JOIN recipe_ingredients ri ON ri.recipe_id r.id JOIN ingredients i ON i.id ri.ingredient_id WHERE i.name 番茄;看结果里的type列能到ref或range说明关联走索引了出现ALL就说明某张表在全表扫描。常见翻车现场是 ingredients 表没建uk_name唯一索引导致WHERE i.name 番茄变成全表扫描。执行计划里possible_keys为 NULL 时第一件事就是检查 WHERE 条件列有没有索引。4.2 中文菜名搜索LIKE 与 FULLTEXT 怎么选菜谱库的搜索框几乎都是中文。用LIKE %番茄%搜菜名数据量小的时候没问题上万条之后前缀模糊匹配用不上普通索引每查询一次就是一次全表扫描。MySQL 5.7 之后提供了中文全文检索的解法Ngram 分词插件。ALTER TABLE recipes ADD FULLTEXT INDEX ft_name (name) WITH PARSER ngram; ALTER TABLE recipes ADD FULLTEXT INDEX ft_name_category (name, category) WITH PARSER ngram;查询写法对应地换成MATCH ... AGAINSTSELECT id, name FROM recipes WHERE MATCH(name) AGAINST(番茄 IN NATURAL LANGUAGE MODE);Ngram 默认的分词长度是 2也就是把「西红柿炒鸡蛋」切成「西红 / 红柿 / 柿炒 / 炒鸡 / 鸡蛋」这样的二元组。想搜单个字可以把ngram_token_size调成 1但索引体积会显著膨胀误命中率也跟着上升一般不建议动。另外要注意全文索引对「番茄」和「西红柿」这组同义替换是无能为力的它只能帮你搜到字面上包含「番茄」的菜名真正的别名问题要靠食材字典解决搜索功能不是归一的后悔药。4.3 统计口径热量与人份怎么算才不算错菜谱库一旦要做营养统计单位换算就成了第一道坎。如果食材表的cal_per_100g是按「每 100 克千卡」登记的那只有单位是克或毫升的食材可以直接参与计算「2 个番茄」这种带个数的必须先把「个」换算成克才有意义。没有单位换算表之前我一般先把可直接计算的量算出来其他单位标记为未知SELECT r.name, SUM(CASE WHEN ri.unit IN (克, g, 毫升, ml) THEN ri.quantity * i.cal_per_100g / 100 ELSE 0 END) AS cal_known FROM recipes r JOIN recipe_ingredients ri ON ri.recipe_id r.id JOIN ingredients i ON i.id ri.ingredient_id GROUP BY r.id;cal_known只统计单位能直接对齐的食材ELSE 0并不会把「适量」的盐算成零卡因为后面的展示会单独说明「部分食材未计入热量」。比这更隐蔽的坑是servings一份菜谱的quantity是给「标准份量」写的如果这道菜标注是 2 人份那统计一人份热量时要除以 2忘了除报表数字直接翻倍。我的习惯是在所有统计 SQL 里显式写上r.servings而不是心里默认一个 1。5. 避坑食谱数据库最常见的五个翻车现场5.1 中文乱码「???」字符集三处没对齐现象脚本导入后SELECT查出来的菜名全是「???」或者问号夹杂乱码。原因建库时没指定utf8mb4或者连接串里漏了charsetutf8mb4再或者表字段本身是latin1。字符集要三处对齐库、表、连接。解决建库时写DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci已有表用ALTER TABLE recipes CONVERT TO CHARACTER SET utf8mb4;连接参数里补上charsetutf8mb4。很多时候「改了建表语句还是乱码」就是因为 Python 连接串里少了最后一项。5.2 搜「番茄」搜不到「西红柿」缺食材别名表现象菜谱里录入的是「西红柿炒鸡蛋」用户搜「番茄」这道菜就是不出来。原因食材没有归一化ingredients.name既存了「番茄」又存了「西红柿」两个标准名各自关联了一批菜谱数据被切碎了。解决在 ingredients 表之外加一张别名表ingredient_aliases (alias_name, ingredient_id)把「西红柿」映射到标准名「番茄」。导入阶段做一次名称替换查询阶段用别名表做一层展开。这是这类库的「后悔药」——越早做后面要改的历史数据越少。5.3 重跑导入脚本数据翻倍没有唯一约束现象第一次跑完 1000 道菜改了几个错别字再跑一遍变成 2000 道。原因recipes 表没有唯一索引INSERT全部当新数据写入。解决先给name category加唯一索引再把 INSERT 改成INSERT ... ON DUPLICATE KEY UPDATE。已经翻倍的数据用GROUP BY name, category HAVING COUNT(*) 1找出重复组保留最小 ID 那行其余 DELETE。这个清理 SQL 只能手动执行一次但根上的修复是索引加 UPERT不是靠清理。5.4 「盐适量」写不进去字段约束设计死了现象导入遇到「盐 适量」「糖 少许」时报Column quantity cannot be null或者强行塞 0 后统计报表里这道菜热量低得离谱。原因quantity和unit在建表时设了NOT NULL DEFAULT 0把「适量」这种语义硬编码成了数值。解决ALTER TABLE recipe_ingredients MODIFY quantity DECIMAL(8,2) NULL;允许 NULLnote 里保留「适量」原文。统计时用CASE WHEN quantity IS NULL THEN 0 ELSE ... END单独处理或者直接标记为「估算」。改表结构不是大动作但要在设计阶段就预留这个可能性。5.5 多人同时写库偶发死锁行的加锁顺序不一致现象两个管理员同时维护菜谱库报Deadlock found when trying to get lock; try restarting transaction。原因事务 A 先插 recipe 再插 ingredient事务 B 先插 ingredient 再插 recipe两个事务同时申请对方已锁的行InnoDB 只能回滚其中一个。解决所有事务里固定写库顺序——先处理食材和标签再处理菜谱主表最后写关联表事务尽量短批量导入做到几千条一提交不要攒一大坨才 commit能用INSERT ... ON DUPLICATE KEY UPDATE的地方就不用「先 SELECT 判断再 INSERT」减少锁窗口。死锁本身是 InnoDB 的常规操作检测到后重试事务即可真正要避免的是「每次死锁都出现在同一段代码」这种系统性缺陷。6. 进阶用「冰箱里有什么」反查菜谱的 SQL 技巧前几章把菜谱库的骨架搭完了最后分享一个我实际用最多的技巧把手头的食材清单输入查询让数据库告诉你「这些食材能做什么菜」。基础版是「包含其中任意几种」SELECT r.name FROM recipes r JOIN recipe_ingredients ri ON ri.recipe_id r.id JOIN ingredients i ON i.id ri.ingredient_id WHERE i.name IN (番茄, 鸡蛋, 豆腐) GROUP BY r.id, r.name HAVING COUNT(DISTINCT i.id) 2; 2表示这道菜至少用到你手头三种食材里的两种适合「不够再买点」的场景。如果你想精确匹配「只用番茄和鸡蛋就能做」要再加一个排除条件SELECT r.name FROM recipes r JOIN recipe_ingredients ri ON ri.recipe_id r.id WHERE r.id IN ( SELECT recipe_id FROM recipe_ingredients JOIN ingredients i ON i.id recipe_ingredients.ingredient_id WHERE i.name IN (番茄, 鸡蛋) GROUP BY recipe_id HAVING COUNT(DISTINCT i.id) 2 ) AND NOT EXISTS ( SELECT 1 FROM recipe_ingredients ri2 JOIN ingredients i2 ON i2.id ri2.ingredient_id WHERE ri2.recipe_id r.id AND i2.name NOT IN (番茄, 鸡蛋) );这个查询第一次看有点绕拆开就清楚了内层 IN 子句筛出「恰好包含番茄和鸡蛋」的菜NOT EXISTS再把「还用了其他食材」的菜剔掉。剩下的就是冰箱里现有食材能做、并且不用额外采购的菜。把这个查询包成一个视图配一个简单的输入页面菜谱库就从「查菜名的字典」升级成了「备餐决策工具」。这些年维护菜谱库我最大的教训是表结构里「多拆一张关联表」的代价远远小于「后来才发现少拆了一张」。食材别名、单位换算、标签关联这些看着是小题大做等菜谱涨到几千条再回填每一件都是体力活。如果你的菜谱数据已经上了规模从第 2 章的表结构开始照着重做一遍后面所有查询都会顺很多。希望帮到你。本文还有配套的精品资源点击获取
返回列表