Ecto 是 Elixir 生态的事实标准数据库工具,由 Phoenix 框架团队维护。它不是一个传统的 ORM——Ecto 刻意将「数据结构(Schema)」「数据访问(Repo)」「查询(Query)」与「变更追踪(Changeset)」拆分为独立关注点,避免 ActiveRecord 式的隐式魔法。这种设计让数据库访问层既类型安全又可测试:Schema 描述形状,Query 在编译期检查字段,Changeset 在数据进入 Repo 前完成校验与约束。本文将系统深入 Ecto 的四大组件,覆盖从建表迁移到生产查询优化的完整链路。
一、Ecto 架构与核心组件
1.1 四个职责分离的组件
应用程序
│
┌────▼────┐
│ Changeset │ ← 输入校验、类型转换、约束检查(不进库)
└────┬────┘
┌────▼────┐
│ Schema │ ← 数据形状:字段、类型、关联(纯数据映射)
└────┬────┘
┌────▼────┐
│ Query │ ← 类型安全的查询 DSL,编译期检查
└────┬────┘
┌────▼────┐
│ Repo │ ← 数据访问边界:连接、事务、执行
└────┬────┘
PostgreSQL / MySQL / SQLite ...
1.2 与 ORM 的对比
| 对比维度 | Ecto | Rails ActiveRecord / Hibernate |
|---|---|---|
| 职责边界 | Schema / Query / Changeset / Repo 分离 | 全在一类模型里 |
| 类型转换 | 显式,Changeset 中完成 | 隐式自动转换 |
| 变更追踪 | Ecto.Changeset 记录所有字段变更 | dirty tracking |
| 查询安全 | Ecto.Query 宏,编译期防注入 | 部分 ORM 需手写 SQL |
| 关联加载 | preload 显式声明 | 懒加载隐式 |
二、Repo 与连接管理
2.1 配置 Repo
# config/config.exs
config :my_app, MyApp.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
database: "my_app_dev",
pool_size: 10,
log: false
# config/runtime.exs(生产环境用环境变量)
config :my_app, MyApp.Repo,
url: System.get_env("DATABASE_URL"),
pool_size: String.to_integer(System.get_env("POOL_SIZE", "10"))
# lib/my_app/repo.ex
defmodule MyApp.Repo do
use Ecto.Repo,
otp_app: :my_app,
adapter: Ecto.Adapters.Postgres
end
2.2 Repo 核心 API
# 单记录操作
user = Repo.get(User, 42)
user = Repo.get!(User, 42) # 不存在抛异常
user = Repo.get_by(User, email: "a@ex.com")
user = Repo.one!(from u in User, where: u.id == 42)
# 写入
{:ok, user} = Repo.insert(%User{name: "Alice"})
{:ok, user} = Repo.update(user)
{:ok, _} = Repo.delete(user)
{:ok, user} = Repo.insert_or_update(changeset)
# 批量
Repo.insert_all(User, [%{name: "A"}, %{name: "B"}], on_conflict: :nothing)
Repo.delete_all(User)
# 聚合
Repo.aggregate(User, :count, :id)
# 事务
Repo.transaction(fn ->
Repo.insert!(changeset1)
Repo.insert!(changeset2)
end)
三、Schema 定义
3.1 基础 Schema
defmodule MyApp.Blog.Post do
use Ecto.Schema
import Ecto.Changeset
schema "posts" do
field :title, :string
field :body, :string
field :published_at, :utc_datetime
field :view_count, :integer, default: 0
field :rating, :float, default: 0.0
field :meta, :map, default: %{}
field :tags, {:array, :string}, default: []
field :status, Ecto.Enum, values: [:draft, :published, :archived], default: :draft
belongs_to :author, MyApp.Accounts.User
has_many :comments, MyApp.Blog.Comment
has_one :seo_meta, MyApp.Blog.SeoMeta
timestamps() # inserted_at, updated_at
end
end
3.2 类型映射表
| Ecto 类型 | 数据库类型 | 说明 |
|---|---|---|
:string | varchar(255) | 最大 255 字符 |
:text | text | 超长文本(用 field :body, :text 或在 migration 中 :text) |
:integer | integer | 整数 |
:float | float | 浮点(精度不保证) |
:decimal | numeric | 高精度小数,金融计算首选 |
:boolean | boolean | 布尔 |
:date / :time / :utc_datetime | date / time / timestamp | 时间类型(utc_datetime 存 UTC) |
:map | jsonb (PG) / json | 键值结构,无需建表 |
{:array, :string} | text[] | 数组类型(PG 特有) |
Ecto.Enum | varchar + 约束 | 枚举字段(OTP 21+/Ecto 3.5+) |
{:binary, size: 16} | bytea | 二进制,如 UUID |
3.3 虚拟字段与计算字段
schema "products" do
field :price_cents, :integer
field :currency, :string
# 虚拟字段:不落库,仅用于表单与计算
field :display_price, :string, virtual: true
field :confirm, :boolean, virtual: true
end
def changeset(product, attrs) do
product
|> cast(attrs, [:price_cents, :currency, :confirm])
|> validate_required([:price_cents, :currency])
|> validate_confirmation(:price_cents) # price_cents_confirmation 一致
|> put_calc(:display_price)
end
defp put_calc(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{price_cents: c, currency: cur}} ->
put_change(changeset, :display_price, "#{c / 100} #{cur}")
_ ->
changeset
end
end
四、Query 查询
4.1 基础查询
import Ecto.Query
# 全表 + 排序 + 限制
posts = Repo.all(
from p in Post,
where: p.published_at != nil,
order_by: [desc: p.published_at],
limit: 20
)
# 管道风格
query =
Post
|> where([p], p.view_count > 1000)
|> order_by([p], desc: p.view_count)
|> limit(10)
Repo.all(query)
# 动态条件(参数插值,防注入)
title = "Ecto"
Repo.all(from p in Post, where: p.title == ^title)
4.2 where 的丰富表达
from p in Post,
where: p.title like "%Ecto%",
where: p.published_at > ^Date.shift(Date.utc_today(), day: -30),
where: p.view_count in [100, 200, 300],
where: p.status == :published or p.status == :archived,
where: fragment("lower(?) LIKE ?", p.title, "%ecto%"), # 原生 SQL 片段
select: %{title: p.title, views: p.view_count}
4.3 聚合与分组
# 按状态统计
Repo.all(
from p in Post,
group_by: p.status,
select: {p.status, count(p.id)}
)
# having 过滤
Repo.all(
from p in Post,
group_by: p.status,
having: count(p.id) > 10,
select: {p.status, count(p.id)}
)
# 窗口函数(PG 支持)
Repo.all(
from p in Post,
select: %{title: p.title, row_num: row_number() |> over(partition_by: p.status, order_by: p.view_count)}
)
4.4 关联与 preload
# join 查询(Ecto 自动按外键匹配)
query =
from p in Post,
join: c in assoc(p, :comments),
where: c.approved == true,
distinct: true,
select: p
# 预加载(避免 N+1)
posts = Repo.all(from p in Post, preload: [:author, :comments])
# 嵌套预加载 + 过滤
posts = Repo.all(
from p in Post,
preload: [author: :profile, comments: {c in Comment, where: c.approved == true}]
)
# 批量预加载
posts = Repo.all(Post)
Repo.preload(posts, :comments)
4.5 子查询与 CTE
# 子查询:热门作者下的最新文章
popular_authors =
from u in User,
join: p in assoc(u, :posts),
group_by: u.id,
having: count(p.id) > 10,
select: u.id
query =
from p in Post,
where: p.author_id in subquery(popular_authors),
select: p
# CTE(with_cte,PG 支持)
cte_query =
from p in Post,
where: p.published_at != nil,
order_by: [desc: p.published_at],
limit: 10
from p in Post,
with_cte: "latest_posts", as: cte_query,
join: lp in "latest_posts", on: lp.id == p.id,
select: p
五、Migration 迁移
5.1 创建迁移
# 生成迁移文件
mix ecto.gen.migration create_posts
# 生成带结构迁移(跟随 schema)
mix ecto.gen.migration add_view_count_to_posts
# 执行
mix ecto.migrate
# 回滚一步
mix ecto.rollback
# 回滚到指定版本
mix ecto.rollback --to 20240926120000
5.2 典型迁移
# priv/repo/migrations/20260926010000_create_posts.exs
defmodule MyApp.Repo.Migrations.CreatePosts do
use Ecto.Migration
def change do
create table(:posts) do
add :title, :string, null: false, size: 200
add :body, :text
add :status, :string, default: "draft", null: false
add :view_count, :integer, default: 0, null: false
add :author_id, references(:users, on_delete: :delete_all), null: false
timestamps()
end
create index(:posts, [:author_id])
create index(:posts, [:status, :view_count])
# 唯一约束(用于幂等)
create unique_index(:posts, [:title])
end
end
5.3 约束与索引
| 约束 | 迁移写法 | 说明 |
|---|---|---|
| 非空 | null: false | 列级约束 |
| 外键 | references(:users, on_delete: :delete_all) | :delete_all / :nilify_all / :restrict |
| 唯一 | create unique_index(:posts, [:title]) | 唯一索引 + 约束 |
| 检查 | create constraint(:posts, :positive_views, check: "view_count >= 0") | 业务约束 |
| 联合唯一 | create unique_index(:likes, [:post_id, :user_id]) | 防止重复点赞 |
5.4 可逆与回滚
def change do
# change/0 自动推导 up/down
create table(:posts) do
add :title, :string
end
create index(:posts, [:title])
end
# 不可逆操作需分开 up/down
def up do
execute "CREATE EXTENSION pg_trgm"
end
def down do
execute "DROP EXTENSION pg_trgm"
end
# 数据迁移(谨慎)
def up do
execute "UPDATE posts SET status = 'published' WHERE published_at IS NOT NULL"
end
5.5 生产迁移最佳实践
- 先备份再迁移:
pg_dump或快照; - 零停机迁移:先加列(
add :status, ...)→ 双写 → 回填 → 再删列,避免锁表; - 避免在高峰期执行
alter table:PG 加默认值会重写表; - 用
mix ecto.migrate在 CI 中跑在部署之前,而非部署之后。
六、Changeset 变更集
6.1 变更集模型
Changeset 是「数据 + 变更 + 校验 + 约束」的不可变载体:
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body, :status]) # 白名单字段 + 类型转换
|> validate_required([:title])
|> validate_length(:title, min: 3, max: 200)
|> validate_inclusion(:status, [:draft, :published, :archived])
|> validate_number(:view_count, greater_than_or_equal_to: 0)
|> validate_format(:title, ~r/^[A-Za-z0-9\s]+$/, message: "仅允许字母数字")
|> unique_constraint(:title) # 依赖数据库唯一索引
|> assoc_constraint(:author) # 外键必须存在
|> check_constraint(:status, name: :status_check)
end
6.2 校验函数大全
| 校验 | 用法 | 说明 |
|---|---|---|
validate_required | validate_required([:title]) | 必填 |
validate_length | validate_length(:title, min: 3, max: 200) | 长度 |
validate_number | validate_number(:qty, greater_than: 0, less_than: 100) | 数值范围 |
validate_format | validate_format(:email, ~r/@/) | 正则 |
validate_confirmation | validate_confirmation(:password) | 与 password_confirmation 一致 |
validate_subset | validate_subset(:roles, [:admin, :user]) | 属于子集 |
validate_exclusion | validate_exclusion(:name, ["admin"]) | 排除 |
validate_change | 自定义 | 函数式自定义校验 |
validate_inclusion | validate_inclusion(:status, [:a, :b]) | 枚举包含 |
6.3 自定义校验
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> validate_required([:title])
|> validate_body_not_duplicate()
end
defp validate_body_not_duplicate(changeset) do
case changeset do
%Ecto.Changeset{changes: %{body: body}, valid?: true} ->
case MyApp.Blog.find_duplicate(body) do
nil -> changeset
_post -> add_error(changeset, :body, "已存在相同内容")
end
_ -> changeset
end
end
6.4 约束与数据库一致性
validate_* 是应用层校验(可绕过),*_constraint 则依赖数据库约束兜底:
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :author_id])
|> validate_required([:title, :author_id])
|> unique_constraint(:title, message: "标题已存在")
|> assoc_constraint(:author)
end
# 约束冲突时,Ecto 捕获数据库错误转为 changeset error:
# - unique_constraint → :unique 约束错误
# - assoc_constraint → :foreign_key 约束错误
# - check_constraint → :check 约束错误
# - exclusion_constraint → 排除约束(如 PG 排他索引)
为什么需要数据库约束:应用层校验在多进程并发写时存在竞态窗口,两个请求同时通过校验但只有一个能成功插入。唯一索引是幂等与并发的最后防线。
6.5 关联的变更集处理
# 嵌套关联:创建 Post 时同时创建 Comments
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :body])
|> cast_assoc(:comments) # 需要 schema 中 has_many :comments
|> validate_required([:title])
end
# 显式操作 has_many
def put_comments(changeset, comments) do
put_assoc(changeset, :comments, comments)
end
# 单独更新关联(不触碰父记录)
comment_changeset =
Comment.changeset(comment, attrs)
|> Repo.update()
6.6 Changeset 的链式用法
# 从 map 构建
changeset = Ecto.Changeset.cast(%Post{}, attrs, [:title])
# 合并 / 附加
changeset
|> Ecto.Changeset.put_change(:slug, slugify(changeset.changes.title))
|> Ecto.Changeset.put_new(:view_count, 0) # 仅在未设值时写入
|> Ecto.Changeset.force_change(:status, :draft)
# 变更集状态检查
changeset.valid?
changeset.errors # [{field, {msg, opts}}]
changeset.changes # 只有被变更的字段
Ecto.Changeset.traverse_errors(changeset, &format/2) # 格式化错误
七、生产最佳实践与总结
- Repo 作为唯一数据访问边界:业务模块(Context)调用 Repo,Controller 不直接碰 Repo;
- 查询走 Query DSL 而非裸 SQL:
fragment只用于必要场景,参数一律^插值防注入; - preload 杜绝 N+1:列表页一次性预加载关联;复杂过滤用 join 而非多次查询;
- Changeset 白名单化:
cast只允许明确字段,杜绝 mass assignment 漏洞; - 校验分层:应用层
validate_*做 UX,数据库*_constraint做并发兜底; - 迁移幂等与可回滚:每个迁移可 up/down,生产执行前在 staging 演练;
- 事务边界清晰:只把真正需要原子性的操作包进
Repo.transaction,避免长事务占连接; - 多数据库 / 多 Repo:
use Ecto.Repo, adapter: ...可定义多个 Repo(如读写分离、Sharding)。
Ecto 把「数据库访问」从 Rails 式魔法中解放出来,让每一层都显式可控。配合 Phoenix 的 LiveView 与 Channel(见 https://plumephp.com/elixir-intro-phoenix/),Ecto 支撑起了完整的实时应用数据栈。理解 Schema 的形状、Query 的表达力与 Changeset 的约束链,是 Elixir 后端开发的核心能力。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。