
带新人带得多了你会发现一个特别有意思的现象很多人简历上写着熟练使用Java可一旦让他在本地写一段连接数据库的增删改查往往要在电脑前犹豫很久。不是能力不行而是没把基础链条真正理顺。Java连接数据库实现增删改查这个组合几乎是所有业务系统的起点虽然看着简单里面却串着JDBC、SQL、驱动、连接管理、异常处理、资源释放一大堆琐碎细节。当年我刚开始学的时候最苦恼的其实不是语法而是搞不明白错误到底出在哪一层。如果你也想从零起步把这套功能完整拿下来这篇文章就把整条链路拆开揉碎讲清楚从环境搭建开始建立一张用户表再写一个DAO类用PreparedStatement逐步完成新增、删除、修改、查询最后聊聊那些只有亲手踩过才记得住的坑。键盘就在手边跟着做一遍比看十篇教程都有效。1. 先搞清楚“增删改查”到底在做什么1.1 标题里的四个动作对应的核心逻辑“增删改查”这四个字落到系统里就是四种最基础的数据操作增加对应INSERT删除对应DELETE修改对应UPDATE查询对应SELECT。在业务里它们被统称为CRUD。很多新手以为这几个操作只要会写SQL就够了写完后却发现Java代码根本跑不通。原因在于CRUD的真正难点从来不在SQL本身而在于几个容易被忽略的衔接点Java怎么发起连接、用哪个对象执行SQL、参数怎么安全地传进去、返回的结果集怎么读以及用完的连接和流到底关没关干净。这每一个点串起来的其实就是JDBC的完整工作流。在后面的实现里我会用一个比较典型的“用户管理”场景来演示有一张user_info表里面存用户编号、用户名、密码、邮箱和创建时间。然后用Java写一个访问数据库的类把这张表的增删改查都做一遍。选择这个场景是因为它足够典型你以后写订单模块、写商品模块、写任何带列表页的后台功能底层骨架都是同一套。1.2 为什么第一个实战项目要绕开框架直接学JDBC现在很多新手一上来就学MyBatis甚至直接上Spring Boot加JPA问原因他们说现在没人用原生JDBC了。这话对一半。框架确实把数据库操作简化了但MyBatis也好JPA也罢底层都是把JDBC包装了一层。你如果连Statement和PreparedStatement的区别都不清楚连ResultSet都没手动遍历过那遇到MyBatis的#{}参数字符时你就不知道它到底帮你做了什么动态SQL拼接出问题的时候排查起来更是一头雾水。JDBC是Java连接数据库的官方标准接口负责把Java程序和数据库之间的通道建起来。先练好JDBC本质上是给自己打地基。地基稳了后面看框架源码才不会像是看天书。这篇博文不会让你一辈子写原生JDBC只是希望你在正式进入框架世界之前先把这层窗户纸捅破。1.3 适合什么人读怎么读效率最高如果你是刚学完Java语法还不清楚类和接口怎么组织这篇文章适合你如果你已经在用框架但遇到连接泄漏、SQL注入这些概念时总觉得含糊这篇也适合你。如果你只是想找一个能直接复制的代码模板那我把完整代码都放出来了照着抄也不丢人。我建议的读法是先花十分钟浏览全文知道大概有几块内容然后回到第二章动手把环境准备起来照着第四章的代码一行一行敲。别复制粘贴哪怕敲得慢也要敲。原因很简单CRUD的代码量不大但手一懒脑子就会自动默认自己会了等面试官让你现场写的时候往往就卡壳了。2. 动手前的准备环境、驱动和一张能承载增删改查的表2.1 开发环境三件套JDK、数据库、数据库驱动既然标题写了“零基础入门”那环境这关就不能跳过。先说JDK版本选8以上都可以我建议直接上17或者21这种长期支持版本老项目用8也不用慌后面代码基本兼容。数据库方面这篇以主流的MySQL为例你用MariaDB或者PostgreSQL也没问题只是驱动类名和连接字符串略有差异。然后是数据库驱动。Java本身并不知道怎么跟MySQL通信它通过一套JDBC接口去访问数据库而真正实现这套接口的就是数据库厂商发布的驱动JAR包。MySQL的驱动叫mysql-connector-jPostgreSQL的驱动叫postgresql。驱动是Java和数据库之间的翻译官没有它程序连localhost上的数据库都连不上。开发工具方面你用IDEA或者Eclipse都行社区版完全够用。新建一个普通的Java工程就可以先不用引入Spring那些复杂的依赖保持最简单这样出问题了容易定位。2.2 给工程引入驱动依赖的两种方式给项目加驱动根据你的工程类型有两种常见做法。如果你用Maven管理依赖那在pom.xml里加下面这一段就行dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version /dependency这里要特别留意一下artifactId。比较老的文章里写的是mysql-connector-java8.0之后官方把名字简化成了mysql-connector-j。如果照抄老文章的坐标经常会出现依赖拉不下来或者版本对不上的问题。驱动版本尽量和数据库大版本匹配比如MySQL 8.x配8.x驱动没毛病。如果你的工程没接Maven那就手工下载对应版本的驱动JAR包放到项目根目录下的lib文件夹里然后选中JAR文件右键选择“Add as Library”。这个操作其实就是把JAR包加入编译路径IDE会自动识别里面的驱动类。新手阶段用这种方式反而对原理更透明你能直观看到一个JAR文件是如何被工程引用的。2.3 设计一张用户表别忽视字段类型在写Java代码之前先把数据库的表建好。这里要强调建表不是随便写几个字段就完事字段类型设计直接影响后面Java代码怎么写。拿这张用户表举例CREATE TABLE user_info ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, email VARCHAR(100), created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字段的选择都是有原因的。id用BIGINT而不用INT是因为如果你的表将来数据量变大INT的最大值只有二十多亿看着很多但对某些体系来说并不算宽裕。username和password用VARCHAR长度给得够用就好。created_time设置DEFAULT CURRENT_TIMESTAMP意思是插入数据时如果不显式传值数据库就自动写当前时间这样Java代码里少处理一个字段。字符集必须用utf8mb4。很多老项目因为用了utf8导致存emoji或者生僻字时报错这问题排查起来特别隐蔽。表建好后最好再插入两条测试数据后面写查询代码时就能立刻看到效果。INSERT INTO user_info(username, password, email) VALUES (zhangsan, 123456, zhangsanexample.com), (lisi, abcdef, lisiexample.com);然后你还需要一个单独的数据库或者直接用现有的测试库。我后面写的连接字符串里会用到demo_db这个库名建库语句就是CREATE DATABASE demo_db DEFAULT CHARSET utf8mb4;非常简单。3. 连接管理写代码前必须搞懂的底层逻辑3.1 连接字符串和URL参数逐个解释JDBC连接数据库靠的是一个类似网址的字符串。拿MySQL举例子典型写法是这样的jdbc:mysql://localhost:3306/demo_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8allowPublicKeyRetrievaltrue这段信息虽然只是一串字符但新手最容易在这里翻车。简单拆一下开头的jdbc:mysql是协议名告诉驱动程序你要连的是MySQLlocalhost是数据库所在机器的地址本机就是localhost3306是MySQL默认的端口demo_db就是你要用的数据库名。后面跟的参数更像是调试选项。useSSLfalse表示本地开发不启用SSL证书校验因为本地根本没证书不关掉它部分版本会一直报握手失败。serverTimezoneAsia/Shanghai很关键MySQL 8的驱动对时区敏感不设置时区你执行查询时经常会抛出一个“Server time zone value”相关的异常。characterEncodingutf8解决的就是后面会碰到的中文乱码问题。allowPublicKeyRetrievaltrue是为解决MySQL 8使用caching_sha2_password认证时的一个公共密钥获取限制连接报错时验证一遍就知道它的用处了。3.2 把连接逻辑封装成一个工具类每个增删改查方法都需要连接数据库如果每个方法都重复写一遍DriverManager.getConnection代码会变得啰嗦而且改一次连接参数要动很多地方。所以行业惯例是做一个工具类统一管理连接的创建。package com.demo.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public final class JdbcUtil { private static final String URL jdbc:mysql://localhost:3306/demo_db ?useSSLfalse serverTimezoneAsia/Shanghai characterEncodingutf8 allowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD 你自己改; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(找不到JDBC驱动请检查依赖是否引入); } } private JdbcUtil() { } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }这里有个细节值得说一下。Class.forName(com.mysql.cj.jdbc.Driver)是驱动类加载的经典写法目的是把驱动类注册到DriverManager里。高版本的JDBC驱动其实支持SPI机制也就是驱动包自己会在META-INF里声明驱动类不写这行也能自动加载。但新手阶段保留这行没有坏处它能让你提前感知到“驱动加载”这一步的存在以后看到莫名其妙的ClassNotFoundException时思路会更清楚。真正获取连接用的是DriverManager.getConnection(URL, USER, PASSWORD)。这个方法是JDBC的标准入口它拿着你给的地址和账户密码去和数据库握手握手成功就返回一个Connection对象。注意这个对象不是普通的Java对象它背后是一条真实存在的数据库网络连接操作完必须关闭否则连接数会被耗尽。这个坑后面专门用一节说。3.3 生产环境必须使用连接池为什么既然提到连接很宝贵那就得说说生产环境和学习的区别。学习时你可以每次都DriverManager.getConnection用完就关。但生产环境面对的是大量并发请求数据库连接的建立费时费力频繁创建销毁对数据库服务器压力很大。业界通用的做法是使用连接池。连接池的原理就像共享单车预先准备一批现成的连接放在池子里谁要用就取一辆用完还回去而不是直接销毁。主流的连接池有HikariCP、Druid、Tomcat JDBC Pool等。如果是学习阶段你只需要知道有这一层东西存在等真正做项目时用Spring Boot默认的HikariCP就够了。4. 增删改查的实现从实体类到完整调用4.1 实体类和DAO分层提前养成好习惯虽然这是一个入门项目但从一开始就保持清晰的分层能避免以后项目变大时代码乱成一锅粥。我建议至少分成三层实体类放在entity包访问数据库的类放在dao包程序入口放在main包。实体类是数据库中一行数据的Java映射。user_info表对应一个User类package com.demo.entity; import java.time.LocalDateTime; public class User { private Long id; private String username; private String password; private String email; private LocalDateTime createdTime; public User() { } public User(Long id, String username, String password, String email, LocalDateTime createdTime) { this.id id; this.username username; this.password password; this.email email; this.createdTime createdTime; } // getter和setter方法IDE可以自动生成这里不逐一列出 }DAO是Data Access Object也叫数据访问对象。它的作用是把对数据库的各种操作封装起来让上层业务代码不直接接触JDBC那些烦琐接口。接口可以先定义好方便以后换实现package com.demo.dao; import com.demo.entity.User; import java.util.List; public interface UserDao { ListUser findAll(); User findById(Long id); int insert(User user); int update(User user); int deleteById(Long id); }接口定义好之后真正干活的UserDaoImpl类就要逐个实现这些方法了。下面几节挨个讲。4.2 查询操作读取ResultSet并映射成Java对象查询是所有操作里最能体现JDBC特性的一个因为涉及ResultSet结果集的读取。先看查询全部用户的方法package com.demo.dao.impl; import com.demo.dao.UserDao; import com.demo.entity.User; import com.demo.util.JdbcUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Timestamp; import java.util.ArrayList; import java.util.List; public class UserDaoImpl implements UserDao { Override public ListUser findAll() { String sql select id, username, password, email, created_time from user_info; ListUser users new ArrayList(); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setEmail(rs.getString(email)); Timestamp ts rs.getTimestamp(created_time); if (ts ! null) { user.setCreatedTime(ts.toLocalDateTime()); } users.add(user); } } catch (SQLException e) { throw new RuntimeException(查询用户列表失败, e); } return users; } }这里有几个新手容易懵的点。第一ResultSet并不是一次把全部数据都装进内存它维护着一个游标一开始游标指向第一行数据之前。每次调用rs.next()游标就往下一行移动一次如果当前没有更多数据就返回false。所以while循环所有行就是用游标一行行划过去。第二取字段值用getLong、getString之类的方法参数可以写列的序号也可以写列名我建议写列名代码可读性高不容易因为SELECT语句里字段顺序调整而出错。我注意到有人觉得DAO方法里抛出RuntimeException是非主流做法。其实这是刻意为之。数据库异常是典型的运行时异常如果在DAO层强行让上层去处理调用方代码会被try-catch淹没。把底层异常包成RuntimeException抛上去由全局统一异常处理来兜底是实际项目里更常见的姿势。再来看按ID查询单个用户。区别只是SQL多了一个where条件并且要给条件参数赋值Override public User findById(Long id) { String sql select id, username, password, email, created_time from user_info where id ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getLong(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setEmail(rs.getString(email)); Timestamp ts rs.getTimestamp(created_time); if (ts ! null) { user.setCreatedTime(ts.toLocalDateTime()); } return user; } } } catch (SQLException e) { throw new RuntimeException(查询用户失败, e); } return null; }SQL里的问号是占位符真正传入的值通过ps.setLong(1, id)设置。这个设计是PreparedStatement的精髓后面专门讲。4.3 新增操作PreparedStatement的参数绑定新增方法比查询多一层参数绑定的理解。看代码Override public int insert(User user) { String sql insert into user_info(username, password, email) values (?, ?, ?); try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(新增用户失败, e); } }先说一个原则执行INSERT、DELETE、UPDATE这些会改变数据的语句用executeUpdate()执行SELECT用executeQuery()。executeUpdate()的返回值是影响的行数比如插入成功就返回1你可以用这个数值判断操作是否生效。再说为什么必须坚持占位符加setXxx而不是用字符串拼接。假如你用这种写法String sql insert into user_info(username, password, email) values ( user.getUsername() , ...);一旦用户名里含有一个单引号SQL就会语法错乱。更可怕的是有心人传一段 or 11这种内容可能会直接改变SQL语义这就是臭名昭著的SQL注入漏洞。PreparedStatement的意义在于把SQL结构和参数值分离开先让数据库对SQL结构做预编译参数再按类型安全地绑定进去。你的输入只是数据永远不可能再变成SQL命令的一部分。如果插入的表有自增主键有时业务需要立刻拿到新生成的ID比如前端新增完用户就要跳转到详情页。这时可以在创建PreparedStatement时加一个参数PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);执行完executeUpdate()后再取生成的键值try (ResultSet keys ps.getGeneratedKeys()) { if (keys.next()) { user.setId(keys.getLong(1)); } }这个功能在框架调用链里非常常见但很多入门文章不提我顺手讲一下你用得上。4.4 修改与删除注意条件别漏掉修改用户逻辑上分两种全字段覆盖更新或者按需更新。全字段更新最简单直接写一条UPDATE语句把所有可变字段都SET一遍用id定位要改的行Override public int update(User user) { String sql update user_info set username ?, password ?, email ? where id ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getEmail()); ps.setLong(4, user.getId()); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(更新用户失败, e); } }这里最容易犯的错是漏掉where条件。一旦漏掉UPDATE会对整张表生效所有用户的密码都会被改成同一个值。所以凡是写UPDATE和DELETE第一件事就是确认where条件写没写、参数传没传。按需更新要复杂一些因为字段可能有为空的情况比如只改密码其他字段不传。这时如果还用上面那条SQL会把其他字段覆盖成空。处理办法是动态拼接SQL先判断哪些字段不为空再把对应碎片拼进去。这其实就是MyBatis动态SQL的雏形。学习阶段可以先把全字段更新写熟练然后再去想动态拼接的问题循序渐进。删除就直观多了Override public int deleteById(Long id) { String sql delete from user_info where id ?; try (Connection conn JdbcUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, id); return ps.executeUpdate(); } catch (SQLException e) { throw new RuntimeException(删除用户失败, e); } }我顺便给一句忠告真正的企业项目里物理删除一般要慎用。用户删了可能还会想恢复订单删了审计对不上账。所以很多系统会用逻辑删除也就是加一个is_deleted字段删除操作其实是执行UPDATE把is_deleted改成1查询时统一带条件过滤掉这些记录。入门时掌握物理删除没问题但脑子里要有这层意识。4.5 事务一次操作需要多条SQL时必须依赖它上面的增删改查都是单条SQL数据库自动提交不会出大问题。但真实业务往往是多条SQL组合。比如转账场景张三扣钱李四加钱这两步必须同时成功或同时失败。如果第一步执行成功而第二步抛异常钱就凭空消失了。JDBC里控制这个行为的开关是setAutoCommit。默认情况下每条SQL执行完就会被自动提交但你可以关闭自动提交手动控制提交时机Connection conn null; try { conn JdbcUtil.getConnection(); conn.setAutoCommit(false); try (PreparedStatement ps1 conn.prepareStatement(update account set money money - 500 where id 1)) { ps1.executeUpdate(); } try (PreparedStatement ps2 conn.prepareStatement(update account set money money 500 where id 2)) { ps2.executeUpdate(); } conn.commit(); } catch (Exception e) { try { if (conn ! null) { conn.rollback(); } } catch (SQLException ex) { ex.printStackTrace(); } throw new RuntimeException(转账失败, e); } finally { try { if (conn ! null) { conn.close(); } } catch (SQLException e) { e.printStackTrace(); } }注意同一个事务里的多条SQL必须使用同一个Connection对象因为事务是以连接为单位的。如果你在每条SQL里都重新getConnection()那就等于开了好几个独立事务根本没法一起提交或回滚。这是一个很容易忽略细节的领域。4.6 把增删改查串起来写一个控制台演示入口代码都写齐了最后需要一个入口把功能跑通也让新手直观看到CRUD的执行效果。写一个简单的Main类package com.demo; import com.demo.dao.UserDao; import com.demo.dao.impl.UserDaoImpl; import com.demo.entity.User; public class Main { public static void main(String[] args) { UserDao dao new UserDaoImpl(); // 1. 新增 User newUser new User(); newUser.setUsername(wangwu); newUser.setPassword(111222); newUser.setEmail(wangwuexample.com); int insertRows dao.insert(newUser); System.out.println(新增影响行数 insertRows); // 2. 查询全部 System.out.println(--- 当前用户列表 ---); for (User user : dao.findAll()) { System.out.println(user.getId() user.getUsername() user.getEmail()); } // 3. 修改 newUser.setPassword(888888); int updateRows dao.update(newUser); System.out.println(修改影响行数 updateRows); // 4. 按ID查询 User target dao.findById(newUser.getId()); System.out.println(按ID查询用户名 target.getUsername()); // 5. 删除 int deleteRows dao.deleteById(newUser.getId()); System.out.println(删除影响行数 deleteRows); } }这个主流程几乎可以照搬到任何入门项目里。新增一条记录查出来核对一下修改后再看影响行数最后删掉收尾。整个链路跑通你对JDBC的感观认知就建立起来了。5. 那些必须亲手踩过的坑排查方法实录5.1 高频故障速查表我把自己和带的新人遇到过的典型问题汇总成了表格。遇到报错时先对照这张表能省下大量搜索时间。现象主要原因处理方法ClassNotFoundException: com.mysql.cj.jdbc.Driver驱动JAR没有引入或者类名写错用Maven重新导入依赖检查类名5.x驱动用com.mysql.jdbc.Driver8.x驱动用com.mysql.cj.jdbc.DriverCommunications link failure数据库服务没有启动端口号不对或者地址写错确认数据库进程存在检查3306端口是否监听确认localhost不是远程地址Server time zone value XXX is unrecognizedMySQL 8驱动校验时区失败在URL末尾加serverTimezoneAsia/Shanghai中文数据存进去变成问号数据库表字符集、连接字符集不一致表使用utf8mb4URL加characterEncodingutf8连接串别丢了该参数Too many connections连接对象没关闭或连接池连接被耗尽检查所有Connection是否都在finally或try-with-resources中关闭No operations allowed after connection closed用了已关闭的Connection或者连接被连接池回收检查代码里是否手动调用过close别在异步线程里复用同一个连接5.2 最典型的隐蔽坑SQL注入和字符串拼接这个坑值得重复强调因为我见过太多新手在入门时就养成了坏习惯。用字符串拼接SQL虽然“看着简单”但它有两个问题。第一个是语法层面的引号问题第二个是安全层面的注入问题。举一个极端的例子登录时本来应该验证用户名和密码如果代码写成字符串拼接攻击者在用户名输入框里填admin --后面的密码条件就变成了注释的一部分登录逻辑直接被绕过。PreparedStatement的占位符机制可以完全杜绝这种问题因为传入的字符串永远只是参数值不会被解析成SQL结构。如果你接手了老项目在代码里看到Statement字符串拼接不要犹豫尽快替换成PreparedStatement。这不是风格偏好问题是底线问题。5.3 资源泄漏看不见的连接杀手Connection、PreparedStatement、ResultSet这三个对象都持有底层资源用完必须释放。很多人只记得关闭Connection以为连接关了其他也跟着关了这是误解。规范的写法就是这篇代码一直在用的try-with-resources它保证无论程序是正常结束还是抛异常括号里的资源都会自动关闭。特别注意一个场景你在一个事务里手动创建了Connection如果异常发生必须把回滚和关闭都处理好。我上面写事务代码时已经演示了标准写法catch块里rollbackfinally块里close。哪怕你事务代码里的SQL写得再漂亮连接不关高并发下一晚就能把连接池拖垮。写到这里我想起一个真实案例一次线上系统频繁卡死查遍应用日志发现每秒创建了上百个连接最后定位到一个没有关闭PreparedStatement的查询方法。问题不大影响巨大。类可以在这个方法被调用了几万次后发现连接数爆炸。这种教训懂的人自然懂。5.4 下一步怎么走从JDBC平滑过渡到框架原生JDBC写完后你会发现代码里有很多重复模板每次都要获取连接、创建PreparedStatement、遍历ResultSet、关闭资源。这些重复正是框架存在的理由。当有一天你开始觉得这些模板代码烦人那就是该接触Spring的JdbcTemplate、或者MyBatis的时候了。JdbcTemplate把连接获取、资源释放这些模板代码都内部处理了你只需要写SQL和参数映射。MyBatis则更进一步把SQL和Java方法解耦还提供了动态SQL能力。当你回头看框架代码时你能清晰说出PreparedStatement在哪里被创建、ResultSet在哪里被解析你对框架的理解就会比别人深一个层次。我个人的建议是先用这篇文章的代码跑通JDBC然后去读Spring JdbcTemplate的源码片段重点看它如何封装连接和ResultSet。这个阅读过程会比你想象的顺畅因为底层你已经完全清楚了。数据库操作这条路往后越走越宽但起点永远是这个看着不起眼的增删改查。把这一步踩实后面学什么框架都不慌。