Elixir Ecto 数据访问层:Schema、Query、迁移与变更集

深入 Elixir 生态的事实标准数据库工具 Ecto:掌握 Schema 定义与类型映射、Repo 与 Query DSL、关联与约束建模、Migration 迁移管理,以及 Changeset 变更集的校验与约束处理。

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 的对比

对比维度EctoRails 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 类型数据库类型说明
:stringvarchar(255)最大 255 字符
:texttext超长文本(用 field :body, :text 或在 migration 中 :text)
:integerinteger整数
:floatfloat浮点(精度不保证)
:decimalnumeric高精度小数,金融计算首选
:booleanboolean布尔
:date / :time / :utc_datetimedate / time / timestamp时间类型(utc_datetime 存 UTC)
:mapjsonb (PG) / json键值结构,无需建表
{:array, :string}text[]数组类型(PG 特有)
Ecto.Enumvarchar + 约束枚举字段(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_requiredvalidate_required([:title])必填
validate_lengthvalidate_length(:title, min: 3, max: 200)长度
validate_numbervalidate_number(:qty, greater_than: 0, less_than: 100)数值范围
validate_formatvalidate_format(:email, ~r/@/)正则
validate_confirmationvalidate_confirmation(:password)与 password_confirmation 一致
validate_subsetvalidate_subset(:roles, [:admin, :user])属于子集
validate_exclusionvalidate_exclusion(:name, ["admin"])排除
validate_change自定义函数式自定义校验
validate_inclusionvalidate_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 后端开发的核心能力。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「erlang」更多文章

  1. Elixir 测试工程:ExUnit 深入、属性测试与 Mock 策略
  2. Erlang 热代码升级与 Release:从 appup 到滚动升级
  3. Mnesia 分布式数据库:事务、容错副本与生产部署