数据库评审清单
数据库评审关注数据模型、存储选型、查询性能、数据一致性、迁移和运维。数据库改动成本高、风险大,评审阶段把好关能避免大量后期返工。
要点
- 数据库评审关注数据模型、存储选型、查询性能、一致性、迁移
- 重点检查:表结构设计、索引覆盖、查询性能、并发控制、备份恢复
- 数据库改动成本高,评审阶段发现问题比上线后优化划算
- Drizzle ORM 提供类型安全的 schema 定义和迁移工具
1. 适用时机
- 新增数据表或集合
- 修改现有表结构(加字段、改类型、改索引)
- 引入新的数据存储(从 MySQL 迁到 PostgreSQL、引入 MongoDB、Elasticsearch 等)
- 数据迁移(ETL、数据清洗、历史数据归档)
- 查询性能优化(加索引、重构查询)
2. 评审前准备
- 数据模型图:表/集合关系、字段、约束
- 数据量估算:当前数据量、增长速率、未来 6-12 个月预期
- 查询模式:主要查询有哪些?QPS 多少?是否有复杂查询?
- 一致性要求:哪些场景需要强一致?哪些可以最终一致?
- 迁移方案:如何从现有结构迁移到新结构?
- 性能基线:当前查询延迟、慢查询列表
3. 核心检查项
3.1 数据模型
- 表名、字段名命名清晰,符合团队约定(snake_case 等)
- 字段类型正确(字符串长度、数字精度、时间精度)
- 主键设计合理(自增、UUID、业务主键?)
- 外键和约束关系清楚
- 索引设计合理(覆盖主要查询,避免过多索引)
- 没有冗余字段(或冗余有明确理由 + 维护策略)
Drizzle ORM 示例:Schema 定义
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core'
import { relations } from 'drizzle-orm'
// 用户表
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // UUID
name: text('name').notNull(),
email: text('email').notNull().unique(),
age: integer('age'),
role: text('role', { enum: ['user', 'admin'] }).default('user').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull()
})
// 订单表
export const orders = sqliteTable('orders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull().references(() => users.id),
status: text('status', { enum: ['pending', 'paid', 'shipped', 'completed'] }).notNull(),
totalAmount: real('total_amount').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
})
// 订单商品表
export const orderItems = sqliteTable('order_items', {
id: text('id').primaryKey(),
orderId: text('order_id').notNull().references(() => orders.id),
productId: text('product_id').notNull(),
quantity: integer('quantity').notNull(),
price: real('price').notNull()
})
// 关系定义
export const usersRelations = relations(users, ({ many }) => ({
orders: many(orders)
}))
export const ordersRelations = relations(orders, ({ one, many }) => ({
user: one(users, {
fields: [orders.userId],
references: [users.id]
}),
items: many(orderItems)
}))索引设计
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'
export const orders = sqliteTable('orders', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
status: text('status').notNull(),
createdAt: integer('created_at').notNull()
}, (table) => ({
// 单字段索引
userIdIdx: index('user_id_idx').on(table.userId),
// 复合索引:查询用户的订单,按时间倒序
userCreatedIdx: index('user_created_idx').on(table.userId, table.createdAt),
// 状态索引:查询待处理订单
statusIdx: index('status_idx').on(table.status)
}))3.2 存储选型
- 关系型 vs 文档型 vs KV vs 时序 vs 向量 —— 选型理由充分
- 数据特性与存储引擎匹配(事务、查询模式、扩展性)
- 团队有能力运维这个存储(不是「因为流行」)
- 成本和容量规划清晰
存储选型决策表
| 场景 | 推荐存储 | 理由 |
|---|---|---|
| 订单、用户、支付 | 关系型(D1/PostgreSQL) | 事务、一致性、复杂查询 |
| 配置、缓存 | KV(Cloudflare KV/Redis) | 快速读写、简单结构 |
| 日志、事件 | 时序数据库(InfluxDB) | 时间序列查询、聚合 |
| 文档、内容 | 文档型(MongoDB) | 灵活 schema、嵌套结构 |
| 向量搜索 | 向量数据库(Pinecone) | 相似度搜索 |
3.3 查询性能
- 主要查询都有 EXPLAIN 分析
- 没有全表扫描(除非表很小或确实需要)
- 索引覆盖主要查询路径
- 没有 N+1 查询(批量加载或 JOIN)
- 大数据量查询有分页(不能一次返回 10 万条)
- 复杂查询考虑物化视图或缓存
Drizzle ORM 示例:查询优化
import { drizzle } from 'drizzle-orm/d1'
import { eq, and, desc, inArray } from 'drizzle-orm'
const db = drizzle(c.env.DB)
// 好的查询:使用索引
const userOrders = await db
.select()
.from(orders)
.where(eq(orders.userId, userId))
.orderBy(desc(orders.createdAt))
.limit(20)
.offset((page - 1) * 20)
// 坏的查询:全表扫描(没有索引)
const allOrders = await db.select().from(orders) // 不要这样!
// 避免 N+1:使用 IN 查询
const userIds = orders.map(o => o.userId)
const users = await db
.select()
.from(users)
.where(inArray(users.id, userIds))
// 而不是循环查询
for (const order of orders) {
const user = await db.select().from(users).where(eq(users.id, order.userId)) // N+1!
}
// 批量插入
const items = [
{ orderId: '1', productId: 'p1', quantity: 2, price: 100 },
{ orderId: '1', productId: 'p2', quantity: 1, price: 200 }
]
await db.insert(orderItems).values(items) // 批量插入,而不是循环单条插入EXPLAIN 分析
-- D1 支持 EXPLAIN
EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE user_id = 'xxx' ORDER BY created_at DESC LIMIT 20;
-- 输出示例:
-- 0|0|0|SEARCH orders USING INDEX user_created_idx (user_id=?)
-- 说明:使用了索引,没有全表扫描3.4 数据一致性
- 事务边界清楚(哪些操作必须在同一事务)
- 隔离级别选择合理(读已提交 vs 可序列化)
- 并发写入处理(乐观锁、悲观锁、CAS)
- 跨服务数据一致性方案(Saga、最终一致、事件溯源)
- 数据校验在应用层和数据库层都有
Drizzle ORM 示例:事务与并发控制
import { db } from './db'
import { orders, inventory } from './schema'
import { eq, and, sql } from 'drizzle-orm'
// 事务:扣减库存 + 创建订单
async function createOrder(userId: string, items: OrderItem[]) {
return await db.transaction(async (tx) => {
// 检查库存
for (const item of items) {
const stock = await tx
.select()
.from(inventory)
.where(eq(inventory.productId, item.productId))
.get()
if (!stock || stock.quantity < item.quantity) {
throw new Error('库存不足')
}
// 扣减库存
await tx
.update(inventory)
.set({ quantity: sql`${inventory.quantity} - ${item.quantity}` })
.where(eq(inventory.productId, item.productId))
}
// 创建订单
const orderId = crypto.randomUUID()
await tx.insert(orders).values({
id: orderId,
userId,
status: 'pending',
totalAmount: items.reduce((sum, item) => sum + item.price * item.quantity, 0)
})
return orderId
})
}
// 乐观锁:防止并发更新冲突
async function updateOrderStatus(orderId: string, newStatus: string, expectedVersion: number) {
const result = await db
.update(orders)
.set({
status: newStatus,
version: expectedVersion + 1,
updatedAt: new Date()
})
.where(
and(
eq(orders.id, orderId),
eq(orders.version, expectedVersion)
)
)
.returning()
if (result.length === 0) {
throw new Error('订单已被修改,请重试')
}
return result[0]
}3.5 数据迁移
- 迁移脚本可重复执行(幂等)
- 大表迁移分批执行(避免锁表)
- 有回滚方案(迁移失败能退回)
- 迁移期间服务可用性考虑(在线迁移 vs 停机迁移)
- 数据校验(迁移后对比源和目标的记录数、关键指标)
Drizzle ORM 示例:迁移脚本
// drizzle/migrations/0001_add_status_to_orders.ts
import { sql } from 'drizzle-orm'
import { migrate } from 'drizzle-orm/d1/migrator'
export async function up(db: D1Database) {
// 添加字段,带默认值
await db.exec(`
ALTER TABLE orders
ADD COLUMN status TEXT NOT NULL DEFAULT 'pending'
`)
// 添加索引
await db.exec(`
CREATE INDEX idx_orders_status ON orders(status)
`)
}
export async function down(db: D1Database) {
// 回滚:删除索引和字段
await db.exec(`DROP INDEX IF EXISTS idx_orders_status`)
await db.exec(`ALTER TABLE orders DROP COLUMN status`)
}
// 大表迁移:分批执行
async function migrateLargeTable(db: D1Database) {
const batchSize = 1000
let offset = 0
while (true) {
const batch = await db
.select()
.from(oldTable)
.limit(batchSize)
.offset(offset)
.all()
if (batch.results.length === 0) break
await db.insert(newTable).values(batch.results)
offset += batchSize
// 避免长时间占用
await new Promise(resolve => setTimeout(resolve, 100))
}
}3.6 备份与恢复
- 备份策略明确(全量 + 增量、频率、保留时长)
- 恢复演练定期执行(不是「备份了但从没试过恢复」)
- RPO(恢复点目标)和 RTO(恢复时间目标)明确
- 跨地域备份(灾难恢复)
3.7 安全与权限
- 敏感字段加密存储(密码、身份证号、银行卡号)
- 数据库账号权限最小化(应用账号、只读账号、管理账号分开)
- 审计日志记录关键操作(谁在什么时候删了什么)
- 数据脱敏(开发/测试环境不用真实数据)
- 合规要求满足(GDPR、等保、行业规范)
敏感数据加密示例
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
// 加密敏感字段
function encrypt(value: string, key: string): string {
const iv = randomBytes(16)
const cipher = createCipheriv('aes-256-cbc', Buffer.from(key), iv)
let encrypted = cipher.update(value, 'utf8', 'hex')
encrypted += cipher.final('hex')
return `${iv.toString('hex')}:${encrypted}`
}
// 解密
function decrypt(encrypted: string, key: string): string {
const [ivHex, data] = encrypted.split(':')
const iv = Buffer.from(ivHex, 'hex')
const decipher = createDecipheriv('aes-256-cbc', Buffer.from(key), iv)
let decrypted = decipher.update(data, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
// 使用
const encryptedEmail = encrypt('[email protected]', process.env.ENCRYPTION_KEY!)
await db.insert(users).values({
id: crypto.randomUUID(),
email: encryptedEmail // 存储加密后的值
})3.8 连接与资源
- 连接池配置合理(最大连接数、超时、空闲回收)
- 慢查询监控和告警
- 连接泄漏防护(用 try-finally 或 ORM 自动管理)
- 读写分离(读多写少场景)
- 分库分表策略(数据量超过单机容量时)
3.9 归档与清理
- 历史数据归档策略(多久前的数据移到冷存储)
- 软删除 vs 硬删除(是否需要保留历史)
- 数据清理任务(定时删除过期数据)
- 存储空间监控和预警
软删除示例
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
import { isNull } from 'drizzle-orm'
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
deletedAt: integer('deleted_at', { mode: 'timestamp' }) // 软删除标记
})
// 查询未删除的用户
const activeUsers = await db
.select()
.from(users)
.where(isNull(users.deletedAt))
// 软删除
await db
.update(users)
.set({ deletedAt: new Date() })
.where(eq(users.id, userId))
// 定时清理:删除 30 天前的软删除数据
async function cleanupDeletedUsers() {
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
await db
.delete(users)
.where(
and(
isNull(users.deletedAt),
lt(users.deletedAt, thirtyDaysAgo)
)
)
}4. 评审会上容易漏的点
- 数据量估算:问「一年后这张表有多少行?查询还能这么快吗?」
- 索引代价:问「加了这个索引,写入性能会降多少?」
- 并发场景:问「两个人同时修改同一条记录会怎样?」
- 迁移回滚:问「迁移失败了,怎么回到原来的状态?」
- 敏感数据:问「哪些字段是敏感的?怎么加密?谁能看?」
5. 通过标准
- 数据模型清晰、规范、无明显冗余
- 查询性能满足预期(有 EXPLAIN 证据)
- 一致性方案明确,并发场景已考虑
- 迁移方案完整,有回滚路径
- 备份与恢复策略明确
- 安全措施到位
- 监控与告警覆盖
6. 反模式
- 大宽表:一张表 50+ 字段,什么都有
- 万能字段:用 JSON 存所有可变数据,失去数据库的约束能力
- 无索引查询:大数据量表没有合适索引,全靠全表扫描
- 过度索引:一张表 20 个索引,写入性能崩塌
- 跨表事务:一个事务横跨 5 张表,锁竞争严重
- 无归档策略:表越来越大,查询越来越慢
7. 维护建议
数据库评审清单需要随技术栈和业务规模更新:
- 引入新的存储引擎(如向量数据库、时序数据库)时,补充对应检查项
- 出现数据相关事故后,把根因对应的检查项加进来
- 数据规模增长后,调整查询性能和索引相关的要求
- 每半年复审一次,删除过时的检查项