1. 最小后端服务的数据库设计实战
作为一名经历过多个线上系统从零到一搭建的后端工程师,我深刻体会到:数据库设计的好坏直接决定了系统的生死。很多新手工程师往往把精力放在Controller的优雅性上,却忽视了最基础也最重要的数据层设计。今天我就用一个"最小可运行后端服务"为例,分享工程级的数据库与事务设计经验。
1.1 核心数据模型设计
一个最小但完整的后端服务数据模型应该包含三个关键部分:
- 核心业务表:承载系统最核心的实体
- 扩展/关联表:处理业务扩展属性
- 约束与索引设计:确保数据完整性和查询性能
以用户系统为例,典型的数据模型如下:
sql复制-- 用户核心表
CREATE TABLE user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(20) UNIQUE NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_phone (phone)
);
-- 账户表
CREATE TABLE account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
balance DECIMAL(12,2) NOT NULL DEFAULT 0,
status TINYINT NOT NULL DEFAULT 1,
UNIQUE KEY uk_user_id (user_id),
CONSTRAINT fk_account_user FOREIGN KEY (user_id) REFERENCES user(id)
);
-- 用户扩展信息表
CREATE TABLE user_profile (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
nickname VARCHAR(50),
avatar VARCHAR(255),
UNIQUE KEY uk_user_id (user_id),
CONSTRAINT fk_profile_user FOREIGN KEY (user_id) REFERENCES user(id)
);
这个结构体现了几个关键设计思想:
- 核心表(user)只存放最基础、最频繁访问的信息
- 账户(account)和资料(user_profile)作为扩展表,通过user_id关联
- 每个表都有明确的主键和必要的唯一约束
提示:在实际项目中,是否使用外键约束(FOREIGN KEY)需要根据团队规范决定。即使不使用数据库级外键,也必须在应用层保证引用完整性。
1.2 表设计的三大工程底线
1.2.1 明确的业务主键
每个表除了自增ID外,必须有业务意义的唯一标识:
- 用户表:手机号(phone)
- 订单表:订单编号(order_no)
- 商品表:商品编码(sku_code)
这些业务主键应该:
- 在业务生命周期内保持唯一
- 尽量不可变(避免修改)
- 建立唯一索引(UNIQUE KEY)
sql复制-- 好的业务主键示例
ALTER TABLE user ADD UNIQUE INDEX uk_phone (phone);
ALTER TABLE order ADD UNIQUE INDEX uk_order_no (order_no);
1.2.2 唯一性约束优先代码判断
很多新手会在代码中检查数据是否存在,这存在竞态条件。正确的做法是:
- 在数据库定义唯一约束
- 代码直接尝试插入
- 捕获唯一键冲突异常
java复制// 错误做法:先查询再插入
if (userRepository.findByPhone(phone) == null) {
userRepository.save(newUser); // 仍然可能重复
}
// 正确做法:依赖唯一约束
try {
userRepository.save(newUser);
} catch (DuplicateKeyException e) {
throw new BusinessException("手机号已注册");
}
1.2.3 关系必须建立索引
任何作为关联字段的列都必须建立索引:
- user_id
- order_id
- product_id
没有索引的关联查询会导致全表扫描,性能极差:
sql复制-- 错误设计:user_id无索引
SELECT * FROM account WHERE user_id = 123; -- 全表扫描
-- 正确设计:user_id有索引
ALTER TABLE account ADD INDEX idx_user_id (user_id);
SELECT * FROM account WHERE user_id = 123; -- 索引扫描
2. 事务设计的工程实践
2.1 事务的本质认知
事务不仅仅是"防止写一半",更重要的是定义业务一致性边界。它控制的是:
- 多表操作的原子性
- 并发操作的可见性
- 失败场景的回滚
- 隔离级别与锁机制
2.2 事务边界划分原则
事务应该放在Service层,而不是:
- Controller:太上层,业务含义不明确
- DAO:太底层,无法表达业务语义
- SQL:无法处理复杂业务逻辑
典型的事务划分示例:
java复制@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final AccountRepository accountRepository;
@Transactional
public UserDto createUser(CreateUserCommand cmd) {
// 校验手机号格式
if (!PhoneValidator.isValid(cmd.getPhone())) {
throw new IllegalArgumentException("手机号格式错误");
}
// 创建用户
User user = new User(cmd.getPhone(), cmd.getPassword());
userRepository.save(user);
// 初始化账户
Account account = Account.init(user.getId());
accountRepository.save(account);
return UserDto.from(user);
}
}
这个设计体现了几个关键点:
- 事务注解在Service方法上
- 包含完整的业务语义(创建用户+初始化账户)
- 校验逻辑在事务外(避免长事务)
2.3 三类必须处理的事务场景
2.3.1 原子性场景
确保多个操作要么全部成功,要么全部失败:
java复制@Transactional
public void placeOrder(OrderCommand cmd) {
// 扣减库存
inventoryService.reduce(cmd.getSku(), cmd.getQuantity());
// 创建订单
Order order = orderFactory.create(cmd);
orderRepository.save(order);
// 生成支付记录
paymentService.createPayment(order);
}
2.3.2 并发性场景
处理并发更新冲突,常见方案:
- 乐观锁(适合冲突少的场景)
java复制@Transactional
public void updateProduct(Product product) {
Product existing = productRepository.findById(product.getId());
if (existing.getVersion() != product.getVersion()) {
throw new OptimisticLockException("数据已被修改");
}
product.setVersion(product.getVersion() + 1);
productRepository.save(product);
}
- 悲观锁(适合冲突多的场景)
java复制@Transactional
public void deductBalance(Long userId, BigDecimal amount) {
Account account = accountRepository.findByUserIdForUpdate(userId);
if (account.getBalance().compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
account.setBalance(account.getBalance().subtract(amount));
accountRepository.save(account);
}
2.3.3 异常回滚场景
必须验证事务在异常时能否正确回滚:
java复制@Test
public void testCreateUserRollback() {
CreateUserCommand cmd = new CreateUserCommand("13800138000", "password");
assertThrows(Exception.class, () -> {
userService.createUser(cmd); // 模拟数据库异常
});
assertFalse(userRepository.existsByPhone("13800138000"));
assertFalse(accountRepository.existsByUserId(any()));
}
2.4 事务传播机制实战
理解不同传播行为的影响:
| 传播行为 | 说明 | 适用场景 |
|---|---|---|
| REQUIRED | 支持当前事务,不存在则新建 | 默认选择 |
| REQUIRES_NEW | 新建事务,挂起当前事务 | 独立业务操作(如日志记录) |
| NESTED | 嵌套事务,可部分回滚 | 复杂业务流程 |
| SUPPORTS | 支持当前事务,不存在则以非事务运行 | 查询方法 |
java复制@Transactional(propagation = Propagation.REQUIRES_NEW)
public void auditLog(Action action) {
// 审计日志需要独立事务
logRepository.save(new AuditLog(action));
}
3. 数据库与事务的深度关系
3.1 表结构决定事务复杂度
糟糕的表设计会导致事务复杂化:
- 过度拆分表:需要更多跨表事务
sql复制-- 不好的设计:用户信息分散在多表
UPDATE user SET name = ? WHERE id = ?;
UPDATE user_contact SET email = ? WHERE user_id = ?;
UPDATE user_setting SET notify = ? WHERE user_id = ?;
- 缺乏冗余设计:需要关联查询
sql复制-- 好的设计:适当冗余减少关联
UPDATE order SET
product_name = ?,
customer_name = ?
WHERE id = ?;
3.2 索引设计影响锁范围
不合理的索引会导致锁升级:
sql复制-- 没有索引的更新会锁全表
UPDATE account SET balance = balance - 100 WHERE user_id = 123;
-- 有索引的更新只锁特定行
ALTER TABLE account ADD INDEX idx_user_id (user_id);
UPDATE account SET balance = balance - 100 WHERE user_id = 123; -- 只锁user_id=123的行
3.3 事务自检清单
每个后端项目都应该验证:
- 多表更新是否在一个事务中
- 并发更新是否有锁机制
- 异常时数据是否回滚
- 事务传播行为是否符合预期
- 长事务是否被避免(事务内不要有RPC/IO操作)
4. 工程级实战建议
4.1 设计流程规范
- 先设计表:明确核心实体和关系
- 再设计事务:确定一致性边界
- 最后写代码:实现业务逻辑
4.2 性能优化技巧
-
避免长事务:
- 事务内不要有网络调用
- 复杂计算放在事务外
- 尽早进行参数校验
-
合理设置隔离级别:
- 默认用READ_COMMITTED
- 高并发场景考虑READ_UNCOMMITTED
- 财务系统用REPEATABLE_READ
-
批量操作优化:
java复制@Transactional
public void batchUpdate(List<Item> items) {
jdbcTemplate.batchUpdate(
"UPDATE item SET stock = ? WHERE id = ?",
items,
100, // batch size
(ps, item) -> {
ps.setInt(1, item.getStock());
ps.setLong(2, item.getId());
}
);
}
4.3 常见陷阱与解决方案
- 自调用失效:
java复制// 错误:自调用事务失效
public void process() {
this.updateData(); // 事务不生效
}
@Transactional
public void updateData() { ... }
// 正确:通过代理调用
@Autowired
private MyService self;
public void process() {
self.updateData(); // 通过代理调用
}
- 异常捕获不当:
java复制// 错误:捕获异常导致不回滚
@Transactional
public void update() {
try {
doUpdate();
} catch (Exception e) {
log.error("更新失败", e); // 事务不会回滚
}
}
// 正确:抛出运行时异常
@Transactional
public void update() {
try {
doUpdate();
} catch (Exception e) {
log.error("更新失败", e);
throw new RuntimeException(e); // 触发回滚
}
}
- 连接泄露:
java复制// 错误:未关闭连接
@Transactional
public void query() {
Connection conn = dataSource.getConnection();
// 查询操作...
// 忘记conn.close()
}
// 正确:使用try-with-resources
@Transactional
public void query() {
try (Connection conn = dataSource.getConnection()) {
// 查询操作...
}
}
经过多个生产项目的验证,我发现遵循这些数据库和事务设计原则,可以显著提高系统稳定性。特别是在高并发场景下,合理的表设计和事务边界划分,往往比单纯增加服务器配置更有效。