news 2026/10/5 3:29:23

PostgreSQL SQL转储全解析:备份、恢复与故障排查实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL SQL转储全解析:备份、恢复与故障排查实战

干运维和开发这么多年,我对“备份”这两个字的敬畏,是真实事故堆出来的。数据库没有备份,就像把一整年的项目代码放在没有快照的笔记本里,看着能用,失去的时候连后悔的余地都没有。在PostgreSQL的备份和恢复这个主题里,SQL转储(逻辑备份)是我用得最多,也最常被同行拿来讨论的手段。它简单、透明、可读,适合中小型数据库和迁移场景,但用不好也会翻车。这篇文章就围绕SQL转储,把备份、恢复、选型、排错完整拆开揉碎。

1. SQL转储到底在备份什么:先想清楚再动手

很多同学第一次做PostgreSQL备份,上来就跑pg_dump命令,看到文件生成就以为备份完成了。这个习惯没问题,但对SQL转储的理解不能停留在“命令行跑通”这个层面。SQL转储和字面意思一样,是把数据库里的对象定义和数据内容“转”成一个SQL文本文件,也就是把建表语句、插入语句、序列、约束、函数、视图、触发器这些全部写出来。恢复时数据库把这些SQL重新执行一遍,等价于把那个时刻的数据库重新造出来。

1.1 逻辑备份的本质:把数据库“打印”成SQL

我经常跟团队里的小伙伴类比:物理备份是“复印硬盘”,SQL转储是“默写课文”。复印速度快,还原也完整;默写课文很灵活,谁读都能重新搭出来,但过程相对慢、量也大。pg_dump 默认会把数据批次导出成 COPY 语句,比逐行 INSERT 快得多;如果指定--inserts或--column-inserts,才会变成标准的 INSERT 语句,那是为了兼容性或某些特殊场景,日常备份没必要这么做。

SQL转储还有一个容易被忽略的特点:它保存的是数据库的逻辑状态,不是物理文件块。这意味着在恢复时,目标数据库可以是完全不同的系统架构、不同的磁盘布局,甚至不同的大版本。只要目标端能解析这些SQL,数据就能回去。所以跨机房迁移、从老版本升到新版本、把开发库复制成测试库,这些都是SQL转储的主场。

它的恢复粒度也很细。物理备份通常以整个实例或时间点为维度,SQL转储却可以只选某一个schema、某几张表、甚至只用--data-only导出数据。这个“挑着备份”的能力,在误删一张小表、要单独把一张业务表恢复到某天状态时,价值非常大。很多生产事故的兜底,最后就是靠这种最小粒度的逻辑备份救回来的。

1.2 SQL转储和物理备份各自的定位

我见过不少把SQL转储当万能方案的,也见过一口咬定“逻辑备份不能用于生产”的。这两种观点都过于绝对。我自己的判断标准很简单:先看数据量,再看恢复目标。

对比维度SQL转储物理备份(基础备份+WAL归档)
备份对象数据库对象定义和数据文件系统层面的数据文件和日志
恢复粒度单表、单schema、全库一般恢复整个实例,支持PITR
跨大版本通常可行,是升级常用路径一般限于兼容版本
备份速度CPU密集,大型库偏慢磁盘IO密集,通常更快
占用空间可压缩,常常更小体量约等于数据目录,较大
适用规模中小型库、迁移、局部恢复大库、高可用、RPO要求高场景

需要注意的是,这里说的SQL转储不含--format=directory这类并行扩展,但本质仍然是逻辑转储。真正的大库,比如单表过TB、整体几个TB以上,SQL转储不是不能跑,而是恢复时间会让人咬牙切齿。这时候就应该用物理备份配合WAL归档做持续备份,SQL转储反而适合做“最后一层保险”,比如每天导一份出库,放在异地,用于应对极端情况下的恢复。合理搭配,比二选一更靠谱。

2. 工具与格式选型:别只会一条命令打天下

PostgreSQL 的 SQL 转储主要由两个工具承担:pg_dump处理单个数据库,pg_dumpall处理整个集群中的全局对象和所有数据库。很多人只认识第一个,觉得能把一个库导出来就算完成任务。但真到恢复的时候,才发现角色、表空间这些关键信息根本没备份,白白浪费时间。工具选型做对了,后面恢复才会顺手。

2.1 pg_dump的四种输出格式怎么选

pg_dump支持四种输出格式:plain、custom、directory、tar。它们的共同点是内容都来自同一个数据源,区别在于文件组织方式、恢复工具和灵活性。

  • plain:纯SQL文本,可以用psql直接执行。好处是肉眼可读,适合迁移脚本、检查审计,坏处是不能用pg_restore选择性地恢复某个对象,遇到大库时恢复过程也不方便断点续传。
  • custom:自定义二进制格式,文件默认以.backup或.dump结尾,必须用pg_restore恢复。支持压缩、支持选择对象、支持并行恢复,是我最常用的格式。
  • directory:目录格式,备份时会生成一个目录,里面包含一个toc.dat和多个数据文件,配合pg_restore可以做真正的并行转储与并行恢复,适合中等以上规模数据库。
  • tar:本质上和custom类似,也配套pg_restore,但不支持并行恢复,而且.tar文件容易和其他打包工具混淆,我基本不用。

如果让我给刚接触的人一个建议:日常单库备份,用-Fc(custom)十拿九稳;数据量大、机器核数也多,用-Fd(directory)配合-j参数;需要完全可读的SQL脚本,再回到plain。

2.2 被很多人忽略的pg_dumpall

pg_dumpall的态度有点类似家庭保险里的“门锁”——它管的是全局对象,而不是某个业务数据库。全局对象包括角色、表空间、数据库级别的配置,甚至是一些跨库的公共对象。如果只做pg_dump,这些信息一点都不带,换一台干净实例恢复业务库时,最常出现的报错就是“角色不存在”、“表空间不存在”。

我养成的习惯是:全备从高到低。第一步先执行pg_dumpall --roles-only导出角色,如果实例里有自定义表空间,再加--tablespaces-only;第二步逐个pg_dump业务库。恢复时先恢复全局,再恢复单个库。这样虽然多了一步,但能让整条链路真正可用。需要注意的是,pg_dumpall需要超级用户权限,否则会跳过部分对象,权限不够的用户执行完,看起来成功,实际缺失严重。

2.3 常用参数一次讲清

只要你用过一段时间pg_dump,肯定会在不同场景之间切换参数。我把高频参数列一下,基本覆盖90%的日常操作:

参数作用我为什么常用
-F指定输出格式取值 c、d、p、t;默认是plain,必须显式写
-j并行度配合custom、directory格式,有效提升备份速度
-Z压缩级别0到9,配合-Fc效果明显,默认是单数压缩
-n/-N指定/排除schema分模块备份、跳过归档表时很有用
-t/-T指定/排除表单表恢复场景的利器
--schema-only只导结构做基线结构审查、迁移评估时用
--data-only只导数据数据迁移、修复时用,但要特别小心约束顺序
--no-owner不导出对象所有者信息跨实例恢复时防止角色问题
--no-privileges不导出权限授权目标库按自己的权限体系管理时使用
--clean/--if-exists恢复前清理对象配合pg_restore实现“可重复恢复”
--no-tablespaces不导出表空间目标端没有同名表空间时救急

不要把这些参数当命令手册背,理解它们背后的语义更重要。比如--no-owner不是“备份文件里没有owner”,而是恢复时不执行赋值 owner 的命令。备份阶段它影响的是 SQL 里的ALTER ... OWNER TO语句是否生成。我通常用--no-owner --no-privileges做跨环境重建,因为开发环境和管理权限常常对不上;生产迁移则会带 owner,保留原样更安全。

3. 从备份到恢复:完整跑一遍才算会

光会看参数列表没有感觉,真正上手跑一遍,才会理解哪些命令是“顺手就来的肌肉记忆”,哪些环节是“平时没踩坑就不知道的雷”。这一节我把日常实战里的完整流程拆开讲,包括备份命令选型、恢复路径、自动化脚本。

3.1 我常用的备份命令现场

假设有个业务库appdb,主机是pg-primary.example.com,我平时会这样备份:

pg_dump -h pg-primary.example.com -U backup_user \ -F c -Z 9 -j 4 \ -f /backup/appdb_$(date +%F_%H%M).backup \ appdb

这个命令的意思很直白:-F c输出custom格式,-Z 9把压缩拉满,-j 4用四个并行度导出。我默认用-j 4,是想在多核环境下把备份时间压下来,同时不把数据库CPU完全打满。如果服务器只有两核,我会降到-j 2,这个度需要按机器配置来调,没有固定值。

如果你要用directory格式,命令长这样:

mkdir -p /backup/appdb_dir pg_dump -h pg-primary.example.com -U backup_user \ -F d -j 8 \ -f /backup/appdb_dir \ appdb

注意 directory 格式的-f参数指向的是一个目录,不是文件。恢复时pg_restore直接指定目录即可。还有一个小技巧:pg_dump对单个数据库进行并行备份时,会在目标库上开启多个会话,如果有历史遗留的锁冲突,就可能互相等待。真实跑之前,我会先用--list或者只导少量数据测一下链路,避免主库直接卡住。

3.2 恢复:plain格式和custom格式是两条路

plain格式的恢复最简单,直接交给psql执行:

createdb -h pg-target -U postgres appdb_restored psql -h pg-target -U postgres -d appdb_restored \ -f /backup/appdb.sql

但 plain 格式有个潜在问题:如果备份文件里有CREATE DATABASE语句,你执行时可能连数据库都不需要提前建,直接psql -f到 postgres 库也行。不过这取决于备份时是否用了--create。所以我建议统一理解为:恢复的目标是“一个需要灌注数据的数据库”,先建库再灌数据,是更不容易出错的做法。

custom和directory格式的恢复,主力是pg_restore。我常用的方式:

createdb -h pg-target -U postgres appdb_restored pg_restore -h pg-target -U postgres \ -d appdb_restored \ --clean --if-exists \ -j 4 \ /backup/appdb.backup

--clean表示如果目标库里已有同名对象,先把它删掉;--if-exists让删除语句带上IF EXISTS,避免因对象不存在而报错。这样重复执行恢复也不会被脏数据坑。很多自动化场景里的“幂等恢复”,就是靠这两个参数实现的。

pg_restore还有个实用功能,就是先列出转储文件内容:

pg_restore -l /backup/appdb.backup | head -50

能看到对象清单和顺序。当你只需要恢复某张表时,可以用:

pg_restore -d appdb_restored \ --table=public.users \ /backup/appdb.backup

不过要小心:只恢复一张表时,如果它依赖的序列、约束没一起恢复,数据可能能进,但后续自增会错乱。需要把关联对象一并处理。

3.3 自动化备份脚本:给手工作业加上保险

手工敲命令只适合演练和临时需求,生产环境必须脚本化。我经常给客户搭一套基础备份脚本,主流程不复杂,但会把“保留策略”和“日志”一起考虑:

#!/bin/bash set -euo pipefail BACKUP_DIR=/backup/postgres LOG_FILE=/var/log/pg_backup.log DB_NAME=appdb RETENTION_DAYS=7 mkdir -p "$BACKUP_DIR" BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_$(date +%Y%m%d_%H%M%S).backup" echo "$(date +%F_%T) 开始备份 $DB_NAME" >> "$LOG_FILE" pg_dump -h 127.0.0.1 -U backup_user \ -F c -Z 9 -j 4 \ -f "$BACKUP_FILE" "$DB_NAME" if [ $? -eq 0 ]; then echo "$(date +%F_%T) 备份成功: $BACKUP_FILE" >> "$LOG_FILE" else echo "$(date +%F_%T) 备份失败: $BACKUP_FILE" >> "$LOG_FILE" exit 1 fi find "$BACKUP_DIR" -name "${DB_NAME}_*.backup" -mtime +$RETENTION_DAYS -delete echo "$(date +%F_%T) 清理超过 ${RETENTION_DAYS} 天的旧备份" >> "$LOG_FILE"

脚本里的backup_user我建议用一个独立的低权限账号,专门负责备份。这个账号至少要能连接目标库、读取所有表的权限,但不需要超级用户。用独立账号的好处是,就算备份连接串泄露,风险也被限制住,不会被拿来乱改数据。

定时执行用 cron 就够:

0 2 * * * /opt/scripts/backup_appdb.sh

凌晨两点一般业务压力低,适合做全量备份。如果你对RPO要求高,可以白天再做增量方面的策略,但那是物理备份和WAL归档的范畴,SQL转储本身更适合作为每日全量快照。

4. 恢复过程最容易翻车的五个细节

备份只是前半件,真正验证备份价值的永远是恢复。恢复时踩过的坑,比备份时的坑多得多。我把最有代表性的五类问题列出来,每一个都能让人从“哎呀”变为“彻底崩溃”。

4.1 角色权限对不上

最经典的报错是:role "biz_user" does not exist。原因很常见:你在备份机上好好地导出了appdb,但恢复目标实例里根本没有biz_user这个角色,或者两个库里的角色ID、属性完全不同。数据量再大,这一步过不去,后面全是白搭。

解决办法有两个角度。一种是在恢复前把角色导过去:

pg_dumpall --roles-only > /backup/roles.sql

然后先在目标实例执行这个文件,把全局角色建好,再恢复业务库。另一种是恢复时通过--no-owner放弃所有者和权限设置,让对象统一归到执行恢复的账号名下。前者适合生产迁移,后者适合快速搭开发环境。我的习惯是优先第一种,因为业务的权限模型往往是有意义的,绕过权限很可能在应用层露出问题。

4.2 表空间不存在

表空间的问题比角色更隐蔽。备份文件里如果记录着TABLESPACE ts_data,恢复目标机器上却没有这个表空间,报错会直接中止。遇到这种情况也别慌,先看备份文件里到底有没有涉及表空间:

pg_restore -l /backup/appdb.backup | grep -i tablespace

如果确实有,两条路:提前在目标实例创建同名表空间,或者在备份时使用--no-tablespaces把表空间信息剔除。我一般建议统一规范表空间名,让所有环境保持一致,这样备份恢复都不需要额外参数。临时恢复场景下,剔除表空间信息能快速过关,但要明白这是为了速度做的妥协,不是长期方案。

4.3 扩展和特殊对象的依赖问题

数据库里装了postgis、pg_trgm、uuid-ossp这类扩展后,备份文件会包含CREATE EXTENSION语句。恢复时如果目标实例没装对应扩展包,或者版本不匹配,执行到这一步就会失败。很尴尬的是,错误提示可能只在日志中尾部,前面的表都建好了,回滚却要手动清理。

我的排查套路是先查目标实例装了哪些扩展,再和备份比对:

SELECT name, default_version, installed_version FROM pg_available_extensions WHERE name IN ('postgis', 'uuid-ossp', 'pg_trgm');

如果目标实例缺扩展,先以管理员身份创建,再执行恢复。还需要留意外部数据包装器(FDW)和外部表,它们的定义依赖底层扩展,恢复顺序一样由pg_restore负责,但如果目标端没有对应的外部对象目标,恢复会报“server does not exist”。

4.4 编码和地区问题

编码错乱是我在中文环境里见得最多的恢复问题之一。备份文件可能是UTF8,目标数据库却是SQL_ASCII或者LATIN1,恢复时要么报错,要么数据变成乱码。最稳妥的做法是集群初始化时就统一用UTF8,并且保持locale一致。如果已经出现历史库编码不一致,尽量在恢复时显式指定客户端编码:

PGCLIENTENCODING=UTF8 createdb -h pg-target -U postgres -E UTF8 appdb_restored PGCLIENTENCODING=UTF8 psql -h pg-target -U postgres -d appdb_restored -f /backup/appdb.sql

这里切记不要临时用--encoding去创建数据库后,再硬灌不同编码的数据,容易出现不可逆转的乱码。真遇到乱码,宁可回去查源库编码,也不要盲目转换。

4.5 并行恢复不只是快慢问题

pg_restore -j 8确实能让恢复变快,但它也带来新的依赖矛盾。比如,数据表之间的外键依赖,pg_restore会自动排序,并行度高时,若多个工作线程同时处理同一条外键链,可能出现死锁或锁等待。大部分情况下pg_restore能自己处理好,但恢复速度特别慢时,先降到-j 1试一次。并行是锦上添花,恢复稳定优先。

另外,如果恢复目标库里还残留着旧表或旧数据,--clean并不能保证清理掉所有依赖对象。比如物化视图、触发器、外部表这些有外部依赖的对象,可能清理顺序不对。我在演练时吃过这个亏,之后都会在恢复前用DROP SCHEMA public CASCADE; CREATE SCHEMA public;或者直接重建空库来保证环境干净。

5. 常见问题与排查技巧实录

这一节讲讲我实操中遇到的真实问题和排查路径。每一条背后都有具体现场,不是泛泛而谈。

5.1 备份进行到一半卡住

你在跑pg_dump时发现进度长时间不动,第一反应不是“备份坏了”,而是“有会话在等锁”。pg_dump为了拿到一致快照,会在备份开始时开启事务并请求ACCESS SHARE锁。如果业务库里有长事务持锁,或者正在执行ALTER TABLE、VACUUM FULL这类操作,pg_dump只能排队等。

排查方法很简单,开一个终端连到源库:

SELECT pid, state, wait_event_type, wait_event, now() - query_start AS duration, query FROM pg_stat_activity WHERE state IS NOT NULL ORDER BY duration DESC;

看到wait_event_type是Lock的会话,基本可以锁定问题。处理方式也要谨慎:不要盲目pg_terminate_backend,先确认那个长事务是不是业务必须。备份窗口尽量避开业务高峰和DDL窗口,也可以通过连接参数给备份会话设置短一点的lock_timeout,让备份快速失败而不是等同。

5.2 恢复速度提不上去

恢复慢常常不是因为数据库不行,而是因为恢复策略没选对。首先,plain格式的转储只能用psql顺序执行,再多大并发也打不上去。想要并行恢复,一开始备份就该选custom或directory格式。其次,恢复目标实例的参数也可能成了瓶颈,我恢复大库时会临时调高maintenance_work_mem、max_parallel_maintenance_workers、max_wal_size,恢复完成后把参数改回生产值。

另外,恢复过程的日志一定开着。默认pg_restore把错误打到标准错误,如果静默执行,失败点会被覆盖。加--verbose或者重定向日志,可以在出问题时快速定位到具体对象。还有一点,恢复期间可以临时关掉autovacuum减少干扰,等恢复完成后再打开并手动ANALYZE。

5.3 转储文件比预期大

如果你发现 custom 格式转储文件巨大,但数据库本身量不大,先确认是不是有bytea、大对象、或者压缩率很低的 JSONB 数据。pg_dump的压缩对某些类型并不有效,尤其大对象(BLOB)在部分场景下默认不会压缩。此时用-Z 0反而能省CPU,文件大小不变。如果文件大是因为内含历史垃圾数据,那问题出在设计层面,别指望备份能帮你瘦身。

5.4 恢复后序列和主键错乱

这是我最想提醒大家的一个细节。数据库从备份恢复后,数据看起来都在,但当你往主键表插入新记录时,突然报“duplicate key value”或“sequence does not exist”。原因多半是恢复文件里的序列值没有正确恢复到当前状态,或者你用--data-only导出数据时漏掉了序列关联。修复方法:

SELECT setval('users_id_seq', max(id)) FROM users;

但更推荐在恢复完成后做一次系统检查,看看每个序列的last_value是否大于等于表中对应列的最大值:

SELECT c.oid::regclass AS table_name, s.relname AS sequence_name FROM pg_class c JOIN pg_depend d ON d.refobjid = c.oid JOIN pg_class s ON s.oid = d.objid WHERE c.relkind = 'r' AND d.deptype = 'a';

这条SQL可以把表名和依赖序列关联起来,再逐个对比。在自动化脚本里加一个“恢复后自增校验”环节,能避免很多后续开发报错。

5.5 备份输出有WARNING但退出码为0,要不要管

很多人看到pg_dump退出码是0就松口气,却忽略了屏幕上的 WARNING。有次我在一个老库上备份,日志里有“WARNING: skipping ... because of ...”,当时没在意,结果恢复出来后才发现,某张分区子表的统计信息和新版本不兼容。这些 WARNING 往往代表对象没有被完整导出,比如你不拥有某个表、某个扩展无法访问、某些大对象被跳过。

最稳妥的做法是,备份脚本里除了检查退出码,还把标准错误和标准输出同时记录,并定时扫描“WARNING”“ERROR”“SKIP”这些关键字。不要因为退出码正常就当作万事大吉。尤其跨大版本恢复时,这类提示会更加频繁,提前发现,总比恢复失败后再回看日志要省事。

6. 备份策略的底层认知:恢复演练比备份本身更重要

讲了这么多命令和排查,最后想分享一个理念层面的体会。备份的价值不是“我有备份文件”,而是“我能恢复出来”。我见过太多团队,备份策略齐全,脚本也一直在跑,但等到真正出事故,才发现备份账号权限不对、目标实例缺扩展、备份文件已经损坏半个月了。这些问题的共同点,就是把“启动备份”误当成了“保障安全”。

正确的做法是给备份策略闭环。第一,备份脚本必须监控,检查退出码、文件大小、日志关键信息。第二,定期做恢复演练,不用每次都恢复全库,可以选一个临时实例恢复最近一次备份,然后执行简单的数据校验:

SELECT count(*) FROM public.users; SELECT count(*) FROM public.orders;

第三,把“恢复手册”当作备份的一部分。手册里写清备份文件位置、恢复命令、常见报错处理、联系人。实际上很多企业出问题时,最缺的不是数据库技术,而是冷静下来能照着做的流程。

从我个人的实践来看,SQL转储这种逻辑备份手段,天然适合做“最后一层保险”。它不像物理备份那样依赖同机房、同版本,也不像流复制那样需要架构配合,它就是一个普通文件,只要有人、有 SQL 执行环境,数据就有机会重生。养成“备份后测一次恢复、恢复后做一次校验”的习惯,比囤一堆备份文件有价值得多。真正到了凌晨三点被电话叫醒的那一刻,你会感谢自己曾经花那几分钟做过演练。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 3:29:06

AI编程工具插件加载失败排查:plugin.json与TypeScript SDK实战

1. 从“plugins”这个标题说起:它到底在指什么“plugins”这个词单独拎出来,信息量其实非常低。它可以是浏览器插件、编辑器插件、构建工具插件、CLI 插件体系,也可以是某个具体平台(比如 Cursor、Codex CLI、各类 AI 编程工具&am…

作者头像 李华
网站建设 2026/10/5 3:29:02

插件体系深度解析:从加载激活到故障排查的工程实践

1. 从“plugins”这个词说起:它到底在解决什么问题“plugins”这个词,放在今天的开发语境里,早就不是浏览器装个广告拦截器那么简单了。你打开任何一个现代编辑器、CLI 工具、构建系统,甚至一个笔记软件,背后几乎都有一…

作者头像 李华
网站建设 2026/10/5 3:28:37

MCU资源受限下RTOS重发机制设计与避坑指南

1. 这不是“重传协议”,而是MCU资源受限场景下的生存策略你有没有遇到过这样的情况:用GD32F103跑RT-Thread,串口发一条指令给外围模块,对方没回ACK,你立刻重发——结果第二次发出去的瞬间,第一次的ACK突然跳…

作者头像 李华
网站建设 2026/10/5 3:27:38

插件开发实战:plugin.json、TypeScript SDK 与 CLI 加载机制详解

1. 从“plugins”这个标题说起:它到底指什么“plugins”这个词,放在不同语境里,含义差别很大。做前端的人第一反应可能是构建工具里的插件体系,做编辑器的人想到的是 IDE 扩展,做 CLI 工具的人想到的是命令行插件加载机…

作者头像 李华
网站建设 2026/10/5 3:25:27

MIT-BEVFusion代码精读:Fuser与Decoder的架构与实现

先聊个背景。BEV感知这几年的迭代速度非常快,从LSS到BEVFormer再到BEVFusion,核心思路都绕不开一件事:怎么把不同传感器的特征放到同一个鸟瞰图坐标系里,然后在这个坐标系上出检测、分割、车道线等结果。MIT-BEVFusion在BEVFusion…

作者头像 李华
网站建设 2026/10/5 3:25:27

MySQL入门实战:用四大名著英雄表掌握增删改查

如果你的数据库课程刚好进行到第二次作业,题目是“用MySQL创建四大名著英雄表并完成增删改查”,那这一篇应该能帮你少走很多弯路。我在带实训课的时候批过大量同题作业,发现多数同学都能把SQL敲出来,但问到为什么这样建表、为什么…

作者头像 李华