[译] 混乱世界中的稳定:Postgres 如何使事务原子化

混乱世界中的稳定:Postgres 如何使事务原子化

原子性( “ACID” 特性)声明,对于一系列的数据库操做,要么全部操做一块儿提交,要么所有回滚;不容许中间状态存在。对于那些须要去适应混乱的现实世界的代码来讲,简直是天赐良物。前端

那些改变数据并继续恶化下去的故障将被取代,这些改变会被恢复。当你在处理着百万级请求的时候,可能会由于间歇性的问题致使链接的断断续续或者出现一些其它的突发状况,从而致使一些不便,但不会打乱你的数据。react

众所周知 Postgres 的实现中提供了强大的事务语义化。虽然我已经用了好几年,可是有些东西我历来没有真正理解。Postgres 有着稳定出色的工做表现,让我安心把它当成一个黑盒子 -- 惊人地好用,可是内部的机制倒是鲜为人知的。android

这篇文章是探索 Postgres 如何保持它的事务及原子性提交,和一些可让咱们深刻理解其内部机制的关键概念[1]ios

管理并发访问

假如你创建了一个简易数据库,这个数据库读写硬盘上的 CSV 文件。当只有一个客户端发起请求时,它会打开文件,读取信息并写入信息。一切运行很是完美,有一天,你决定强化你的数据库,给它加入更加复杂的新特性 - 多客户端支持!git

不幸地是,当两个客户端同时试图去操做数据的时候,新功能当即被出现的问题所困扰。当一个 CSV 文件正在被一个客户端读取,修改,和写入数据的时候,若是另外一个客户端也尝试去作一样的事情,这个时候就会发生冲突。github

客户端之间的资源争夺会致使数据丢失。这是并发访问出现的常见问题。能够经过引入并发控制来解决。曾经有过许多原始解决方案。例如咱们让来访者带上独占锁去读写文件,或者咱们能够强制让全部访问都须要经过流控制点,从而实现同一时间只能运行其一。可是这些方法不只运行缓慢,并且因为不能纵向扩展,从而使数据库不能彻底支持 ACID 特性。现代数据库有一个完美的解决办法,MVCC (多版本并行控制系统)。数据库

在 MVCC,语句在事务里面执行。它会建立一个新版本,而不会直接覆写数据。有需求的客户端仍然可使用原始的数据,但新的数据会被隐藏起来直到事务被提交。这样客户端之间就不存在直接争夺的状况,数据也再也不面临重写并且能够安全地被保存。编程

事务开始执行的时候,数据库会生成一个此刻数据库状态的快照。在数据库的每个事务都会以串行的顺序执行,经过一个全局锁来保证每次只有一个事务可以提交或者停止操做。快照完美体现了两个事务之间的数据库状态。后端

为了不被删除或隐藏的行数据不断地堆积,数据库最后将通过一个 vacuum 程序(或在某些状况下,带有歧义查询的 “microvacuums” 队列)来清理淘汰数据,可是只能在数据再也不被其它快照使用的时候才能进行。数组

让咱们看下 Postgres 如何使用 MVCC 管理并发的状况。

事务、元组和快照

这是 Postgres 用于实现事务的结构(来自 proc.c):

typedef struct PGXACT
{
    TransactionId xid;   /* 当前由程序执行的顶级事务 ID 
                          * 若是正在执行且 ID 被赋值;
                          * 不然是无效事务 ID */

    TransactionId xmin;  /* 正在运行最小的 XID,
                          *  除了 LAZY VACUUM:
                          * vacuum 不移除因 xid >= xmin
                          * 而被删除的元组 */

    ...
} PGXACT;复制代码

事务是以 xid(或 “xact” ID)来标记。这是 Postgres 的优化,当事务开始更改数据的时候,Postgres 会赋值一个 xid 给它。由于只有那个时候,其它程序才须要开始追踪它的改变。只读操做能够直接执行,不须要分配 xid

当这个事务开始运行的时候,xmin 立刻被设置为运行事务中 xid 最小的值。Vacuum 进程计算数据的最低边界,使它们保持 xmin 是全部事务中的最小值。

生命周期感知的元组

在 Postgres,行数据经常与元组有关。当 Postgres 使用像 B 树通用的查找结构去快速检索信息,索引并无存储一个元素的完整数据或其中任意的可见信息。相反,他们存储能够从物理存储器(也称为“堆”)检索特定行的 tid(元组 ID)。Postgres 经过 tid 做为起始点,对堆进行扫描直到找到一个能知足当前快照的可见元组。

这是 Postgres 实现的堆元组(不是索引元组),以及它头信息的结构( 来自 htup.h htup_details.h):

typedef struct HeapTupleData
{
    uint32          t_len;         /* *t_data 的长度 */
    ItemPointerData t_self;        /* SelfItemPointer */
    Oid             t_tableOid;    /* 惟一标识一个表 */
    HeapTupleHeader t_data;        /* -> 元组的头部及数据 */
} HeapTupleData;

/* 相关的 HeapTupleData */
struct HeapTupleHeaderData
{
    HeapTupleFields t_heap;

    ...
}

/* 相关的 HeapTupleHeaderData */
typedef struct HeapTupleFields
{
    TransactionId t_xmin;        /* 插入 xact ID */
    TransactionId t_xmax;        /* 删除或隐藏 xact ID */

    ...
} HeapTupleFields;复制代码

像事务同样,元组也会追踪它本身的 xmin,可是这只在特定的元组状况下,例如它被记录为第一个事务,其中元组变可见。(即建立它的那个)。它还追踪 xmax 做为最后的一个的事务,其中元组是可见的(即删除它的那个)[2]

可使用 xminxmax 来追踪堆元组的生存期。虽然 xminxmax 是内部概念,可是他们能够显示任何 Postgres 表上被隐藏的列。经过名字显示地选择它们:

# SELECT *, xmin, xmax FROM names;

 id |   name   | xmin  | xmax
----+----------+-------+-------
  1 | Hyperion | 27926 | 27928
  2 | Endymion | 27927 |     0复制代码

快照:xmin,xmax,和 xip

这是快照的实现结构 (来自 snapshot.h):

typedef struct SnapshotData
{
    /*
     * 如下字段仅用于 MVCC 快照,和在特定的快照。
     * (但 xmin 和 xmax 专门用于 HeapTupleSatisfiesDirty)
     * 
     *
     * 一个 MVCC 快照 永远不可能见到 XIDs >= xmax 的事务。
     * 除了那些列表中的 snapshot,它会看到时间长的 XIDs 的内容。
     * 对于大多数的元组,xmin 被存储起来是个优化的操做,这样避免去搜索 XID 数组。
     * 
     */
    TransactionId xmin;            /* id小于xmin的全部事务更改在当前快照中可见 */
    TransactionId xmax;            /* id大于xmax的全部事务更改在当前快照中可见 */

    /*
     * 对于普通的 MVCC 快照,它包含了程序中全部的 xact IDs
     * 除非在它是空的状况下被使用。
     * 对于历史 MVCC 的快照, 这就是恰好相反, 即它包含了在 xmin 和 xmax 中已提交的事务。
     * 
     *
     * 注意: 全部在 xip[] 的 ids 都知足 xmin <= xip[i] < xmax
     */
    TransactionId *xip; /* 全部正在运行的事务的id列表 */
    uint32        xcnt; /* 正在运行的事务的计数 */

    ...
}复制代码

快照的 xmin 计算方式和计算事务的相同(即在正在运行的事务中,xid 最低的事务),但用途却不同。xmin 是数据可见的最低边界。元组是被 xid < xmin 条件的事务所建立,对快照可见。

同时也有定义为 xmax 的变量,它被设置为最后一次提交事务的 xid + 1。xmax 是数据可见的上限;xid >= xmax 的事务对快照是不可见的。

最后,当快照被建立,它会定义一个 *xip 做为存储全部事务 xid 的数组。*xip 存在是由于即便 xmin 被设定为可见边界,可能有一些已经提交的事务的 xid 大于 xmin,但也存在 xmin 大于一些处于执行阶段的事务的 xid

咱们但愿任何 xid > xmin 的事务提交结果都是可见的,但事实上它们被隐藏了。快照建立的时候,*xip 存储的有效事务清单能够帮助咱们辨别各事务身份。

事务是对数据库进行操做,快照是为了抓捕数据库一瞬间的信息。

开启事务

当你执行 BEGIN 语句,尽管 Postgres 对于一些经常使用的操做会有相应优化,但它会尽量地推迟更多开销比较大的操做。举个例子,一个新的事务在开始修改数据以前,咱们不会给它分配 xid。这样作能够减小在其余地方追踪它的花费。

新的事务也不会当即使用快照。当事务运行第一个查询,exec_simple_query (postgres.c)才会将其入栈。甚至一个简单的 SELECT 1; 语句也会触发:

static void
exec_simple_query(const char *query_string)
{
    ...

    /*
     * 若是解析/计划须要,则设置一个快照
     */
    if (analyze_requires_snapshot(parsetree))
    {
        PushActiveSnapshot(GetTransactionSnapshot());
        snapshot_set = true;
    }

    ...
}复制代码

建立新快照是程序真正开始加载的起始点。这是 GetSnapshotData (procarray.c):

Snapshot
GetSnapshotData(Snapshot snapshot)
{
    /* xmax 老是等于 latestCompletedXid + 1 */
    xmax = ShmemVariableCache->latestCompletedXid;
    Assert(TransactionIdIsNormal(xmax));
    TransactionIdAdvance(xmax);

    ...

    snapshot->xmax = xmax;
}复制代码

这个函数作了不少初始化的工做,但像咱们谈到的,它最主要的工做就是设置快照的 xminxmax,和 *xip。其中最简单的就是设置 xmax,它能够从 Postmaster 管理的共享存储器中检索出来。每一个提交的事务都会通知 Postmaster,和 latestCompletedXid 将会被更新,若是 xid 高于当前 xid 的值(稍后将详细介绍)。

须要注意的是,最后的 xid 自增是由函数实行的。由于在 Postgres 里面,事务的 IDs 是被容许包装,因此并非单纯的自增那么简单。一个事务 ID 是被定义为一个无符号32位整数(来自 c.h):

typedef uint32 TransactionId;复制代码

尽管 xid 是看状况来分配的(上文提过,读取数据时是不须要它的),可是系统大量的吞吐量很容易就达到32位的边界,因此系统须要根据需求将 xid 序列进行“重置”。这是由一些预处理器处理的(在 transam.h):

#define InvalidTransactionId ((TransactionId) 0)
#define BootstrapTransactionId ((TransactionId) 1)
#define FrozenTransactionId ((TransactionId) 2)
#define FirstNormalTransactionId ((TransactionId) 3)

...

/* 提早一个事务ID变量, 直接操做 */
#define TransactionIdAdvance(dest) \
    do { \
        (dest)++; \
        if ((dest) < FirstNormalTransactionId) \
            (dest) = FirstNormalTransactionId; \
    } while(0)复制代码

最初的几个 ID 被保留做为特殊标识符,因此咱们通常跳过它,从 3 开始。

回到 GetSnapshotData 里,经过迭代全部正在执行的事务咱们能够获得 xminxip (回顾快照中它们的做用):

/*
 * 循环 procArray 查看 xid,xmin,和 subxids。  
 * 目的是获得全部 active xids,找到最低的 xmin,和试着去记录 subxids。
 * 
 */
for (index = 0; index < numProcs; index++)
{
    volatile PGXACT *pgxact = &allPgXact[pgprocno];
    TransactionId xid;
    xid = pgxact->xmin; /* fetch just once */

    /*
     * 若是事务中没有被赋值的 XID,咱们能够跳过;
     * 对于 sub-XIDs 也同理。若是 XID >= xmax,咱们也能够跳过它;
     * 这样的事务被认为(任何 sub-XIDs 都将 >= xmax)。
     * 
     */
    if (!TransactionIdIsNormal(xid)
        || !NormalTransactionIdPrecedes(xid, xmax))
        continue;

    if (NormalTransactionIdPrecedes(xid, xmin))
        xmin = xid;

    /* 添加 XID 到快照中。 */
    snapshot->xip[count++] = xid;

    ...
}

...

snapshot->xmin = xmin;复制代码

提交事务

事务经过 CommitTransaction (在 xact.c)被提交。函数很是复杂,下面代码是函数比较重要部分:

static void
CommitTransaction(void)
{
    ...

    /*
     * 咱们须要去 pg_xact 标记 XIDs 来表示已提交。做为
     * 已稳定提交的标记。
     */
    latestXid = RecordTransactionCommit();

    /*
     * 让其余知道没有其余事务在程序中。
     * 须要注意的是,这个操做必须在释放锁以前
     * 和记录事务提交以前完成。
     */
    ProcArrayEndTransaction(MyProc, latestXid);

    ...
}复制代码

持久性和 WAL

Postgres 是彻底围绕着持久性的概念设计的。这样即便像在外力摧毁或功率损耗的状况下,已提交的事务也保持原有的状态。像许多优秀的系统,Postgres 使用预写式日志( WAL,或 “xlog”)去实现稳定。全部的更改被记录进磁盘,甚至像宕机这种事情,Postgres 会搜寻 WAL,而后从新恢复没有写进数据文件的更改记录。

从上面 RecordTransactionCommit 的片断代码中,将事务的状态更改到 WAL:

static TransactionId
RecordTransactionCommit(void)
{
    bool markXidCommitted = TransactionIdIsValid(xid);

    /*
     * 若是目前咱们尚未指派 XID,那咱们就不能再指派,也不能
     * 写入提交记录
     */
    if (!markXidCommitted)
    {
        ...
    } else {
        XactLogCommitRecord(xactStopTimestamp,
                            nchildren, children, nrels, rels,
                            nmsgs, invalMessages,
                            RelcacheInitFileInval, forceSyncCommit,
                            MyXactFlags,
                            InvalidTransactionId /* plain commit */ );

        ....
    }

    if ((wrote_xlog && markXidCommitted &&
         synchronous_commit > SYNCHRONOUS_COMMIT_OFF) ||
        forceSyncCommit || nrels > 0)
    {
        XLogFlush(XactLastRecEnd);

        /*
         * 若是咱们写入一个有关提交的记录,那么可能更新 CLOG
         */
        if (markXidCommitted)
            TransactionIdCommitTree(xid, nchildren, children);
    }

    ...
}复制代码

commit log

伴随着 WAL,Postgres 也有一个commit log(或者叫 “clog” 和 “pg_xact”)。这个记录都保存事务提交痕迹,不管最后事务提交与否。上面的 TransactionIdCommitTree 实现了这个功能 - 首先会尝试把一系列的信息写入 WAL,而后 TransactionIdCommitTree 会在 commit log 中改成“已提交”。

虽然 commit log 也被称为“日志”,但实际上它是一个提交状态的位图,在共享内存和在磁盘上的进行拆分。
在现代编程中不多出现这么简约的例子,事务的状态能够仅使用二个字节来记录,咱们能每字节存储四个事务,或者每一个标准 8k 页面存储 32758。

来自 clog.hclog.c:

#define TRANSACTION_STATUS_IN_PROGRESS 0x00
#define TRANSACTION_STATUS_COMMITTED 0x01
#define TRANSACTION_STATUS_ABORTED 0x02
#define TRANSACTION_STATUS_SUB_COMMITTED 0x03

#define CLOG_BITS_PER_XACT 2
#define CLOG_XACTS_PER_BYTE 4
#define CLOG_XACTS_PER_PAGE (BLCKSZ * CLOG_XACTS_PER_BYTE)复制代码

优化的规模

稳定性当然重要,但性能表现也是一个 Postgres 哲学中的核心元素。如果事务从不赋值 xid,Postgres 就会跳过 WAL 和提交日志。如果事务被停止,咱们仍然会把它停止的状态写进 WAL 和 commit log,但不要急着立刻去刷新(同步),由于实际上即便系统崩溃了,咱们也不会丢失任何信息。在故障恢复期间,Postgres 会提示没有标记的事务,认为它们被停止了。

防护性编程

TransactionIdCommitTree (在 transam.c, 和 它的 实现 TransactionIdSetTreeStatusclog.c) 提交信息呈树状,由于用户接下来可能还有二次提交。我不会详细介绍二次提交,由于二次提交使 TransactionIdCommitTree 不能保证原子性,每个二次提交都单独提交,而父进程被记录为最后一次操做。当 Postgres 在宕机中恢复数据时,二次提交记录不被认为是提交的(即便它们已经一样被标记)直到父记录被读取和确认提交。

这再一次体现原子性;系统能够成功记录任何二次提交的记录,但在它写入父进程以前就崩溃了。

就像clog.c 所实现的:

/*
 * 将提交日志中的事务目录的最终状态记录到单个页面上全部目录上。
 * 原子只出如今这个页面。
 *
 * 其余的 API 与 TransactionIdSetTreeStatus() 相同。
 */
static void
TransactionIdSetPageStatus(TransactionId xid, int nsubxids,
                           TransactionId *subxids, XidStatus status,
                           XLogRecPtr lsn, int pageno)
{
    ...

    LWLockAcquire(CLogControlLock, LW_EXCLUSIVE);

    /*
     * 不管什么状况,都设置事务的 id。
     *
     * 若是咱们在写的时候在这个页面上更新超过一个 xid,
     * 咱们可能发现有些位转到了磁盘,有些则不会。
     * 若是咱们在更新页面的时候提交了一个破坏原子性的最高级 xid,
     * 那么在咱们标记最高级的提交以前咱们先提交 subxids。
     * 
     */
    if (TransactionIdIsValid(xid))
    {
        /* Subtransactions first, if needed ... */
        if (status == TRANSACTION_STATUS_COMMITTED)
        {
            for (i = 0; i < nsubxids; i++)
            {
                Assert(ClogCtl->shared->page_number[slotno] == TransactionIdToPage(subxids[i]));
                TransactionIdSetStatusBit(subxids[i],
                                          TRANSACTION_STATUS_SUB_COMMITTED,
                                          lsn, slotno);
            }
        }

        /* ... 而后是主事务 */
        TransactionIdSetStatusBit(xid, status, lsn, slotno);
    }

    ...

    LWLockRelease(CLogControlLock);
}复制代码

经过共用存储器来标记完成的事务

当事务被记录到提交日志,向系统其余部分进行提示是一种安全行为。这发生在上面的 CommitTransaction 的第二次调用(在 procarray.c):

void
ProcArrayEndTransaction(PGPROC *proc, TransactionId latestXid)
{
    /*
     * 当清除咱们的 XID时,咱们必须锁住 ProcArrayLock
     * 这样当别人设置快照的时候,运行的事务已全被清空了。
     * 看讨论
     * src/backend/access/transam/README.
     */
    if (LWLockConditionalAcquire(ProcArrayLock, LW_EXCLUSIVE))
    {
        ProcArrayEndTransactionInternal(proc, pgxact, latestXid);
        LWLockRelease(ProcArrayLock);
    }

    ...
}

static inline void
ProcArrayEndTransactionInternal(PGPROC *proc, PGXACT *pgxact,
                                TransactionId latestXid)
{
    ... 

    /* 也是在持锁的状况下提早全局 latestCompletedXid */
    if (TransactionIdPrecedes(ShmemVariableCache->latestCompletedXid,
                              latestXid))
        ShmemVariableCache->latestCompletedXid = latestXid;
}复制代码

你可能想知道什么是“proc array”。不像其余的服务进程,Postgres 没有使用线程,而是使用一个分岔模型的程序来操做并发机制。当它接受一个新链接,Postmaster 分开一个新服务器进程(postmaster.c)。使用 PGPROC 数据结构来表示服务器进程 (proc.h),和有效的程序的集合均可以在共用存储器追踪到,这就是“proc array”。

如今还记得咱们如何建立一个快照并把它的 xmax 设置为 latestCompletedXid + 1?经过把全局共用存储器中的 latestCompletedXid 赋值给刚提交的事务的 xid,咱们把它的结果对全部从这一刻开始,任何服务器进程的新快照均可见。

看如下获取锁和释放锁所调用的 LWLockConditionalAcquireLWLockRelease。大多数时候,Postgres 很是乐意让程序都并行工做,可是有一些地方须要得到锁来避免争夺,而这就是须要用到它们的时候。在文章的开头,咱们提到了在 Postgres 的事务是如何按顺序依次提交或停止的。ProcArrayEndTransaction 须要独占锁以便于当它更新 latestCompletedXid 的时候不被别的程序打扰。

响应客户端

在整个流程中,客户端在它的事务被确认以前会同步地等待。部分原子性是虚构数据库标记事务为提交,这不是不可能的。不少地方均可能发生故障,可是若是出现了故障,客户端会找出它而后去重试或解决问题。

检查可见性

咱们以前说过如何将可见的信息存储在堆元组。heapgettup (heapam.c) 是负责扫描堆,看看里面有没有符合快照可见性的标准:

static void
heapgettup(HeapScanDesc scan,
           ScanDirection dir,
           int nkeys,
           ScanKey key)
{
    ...

    /*
     * 预先扫描直到找到符合的元组
     * 
     */
    lpp = PageGetItemId(dp, lineoff);
    for (;;)
    {
        /*
         * if current tuple qualifies, return it.
         */
        valid = HeapTupleSatisfiesVisibility(tuple,
                                             snapshot,
                                             scan->rs_cbuf);

        if (valid)
        {
            return;
        }

        ++lpp;            /* 这个页面的itemId数组向前移动一个索引 */
        ++lineoff;
    }

    ...
}复制代码

HeapTupleSatisfiesVisibility 是一个预处理宏,它将会调用 “satisfies” 功能像 HeapTupleSatisfiesMVCC (tqual.c):

bool
HeapTupleSatisfiesMVCC(HeapTuple htup, Snapshot snapshot,
                       Buffer buffer)
{
    ...

    else if (TransactionIdDidCommit(HeapTupleHeaderGetRawXmin(tuple)))
        SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
                    HeapTupleHeaderGetRawXmin(tuple));

    ...

    /* xmax transaction committed */

    return false;
}复制代码

TransactionIdDidCommit (来自 transam.c):

bool /* true if given transaction committed */
TransactionIdDidCommit(TransactionId transactionId)
{
    XidStatus xidstatus;

    xidstatus = TransactionLogFetch(transactionId);

    /*
     *  若是该事务标记提交,那就提交
     */
    if (xidstatus == TRANSACTION_STATUS_COMMITTED)
        return true;

    ...
}复制代码

进一步探究 TransactionLogFetch 将揭示了它的工做原理。它从给出的事务 ID 计算提交日志中的位置,并经过它获取该事务中的提交状态。事务提交是否用于帮助肯定元组的可见性。

关键在于一致性,提交日志被认为是提交状态的标准(还有扩展性,可见性)[3]。不管 Postgres 是否在数小时前成功提交了事务,或服务器刚刚从崩溃的前几秒中恢复,一样的信息都会被返回。

提示位

在从数据可见检查返回以前,从上面的 HeapTupleSatisfiesMVCC 上再作一件事:

SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
            HeapTupleHeaderGetRawXmin(tuple));复制代码

核对提交日志去查看元组的 xminxmax 事务是否被提交是一个昂贵的操做。避免每次都要访问它,Postgres 会为被扫描的堆元组设置一个特别的提交状态标记(被称为“提示位”)。后续操做能够检查堆提示位并保存到提交日志。

盒子的黑墙

当我在数据库运行一个事务:

BEGIN;

SELECT * FROM users

INSERT INTO users (email) VALUES ('brandur@example.com')
RETURNING *;

COMMIT;复制代码

我不会中止思考其中发生什么。我获得一个强大的高级抽象(以 SQL 形式),我知道这样作是可靠的,如咱们所看到的,Postgres 在底层作好了全部繁杂的细节工做。好的软件就是一个黑盒子,而 Postgres 是特别黑的那种(尽管有可访问的内部的接口)。

感谢 Peter Geoghegan 耐心地回答了我全部业余问题,有关 Postgres 事务和快照,和给予我寻找相关源码的指引。

  • 1 提几句建议:Postgres 源码是很是庞大,因此我略写了一些细节,让读者更容易消化。因为 Postgres 还在持续开发中,引用的代码可能会过期。
  • 2 读者可能会注意到,xmin and xmax 对于跟踪元组的建立和删除是很是适合,可是它们还不足够去处理更新操做。为了达到目的,目前我不会谈论更新操做是如何实现的。
  • 3 注意,提交日志最终将会被截断,但只能在快照的 xmin 范围以外,因此在对 WAL 检查以前,须要先对可见性进行检查。

混乱世界中的稳定:Postgres 如何使事务变得原子化发表于旧金山在 2017 年 8 月 16 日。

在推特上能够找到我 @brandur
请在 Hacker News 上发表你的看法。
若是文章有错,请 pull request.


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索