当前位置: 首页 > article >正文

Postgres 如何使事务原子化?

原子性(“ACID”意义上的)要求 对于对数据库执行的一系列操作,要么一起提交,要么全部回滚;不允许中间状态。对于现实世界的混乱的代码来说,这是天赐之物。

这些更改将被恢复,而不是导致生产环境中的错误更改数据,然后使其永久损坏。在处理数百万个请求时,由于间歇性问题和其他意外状态而中途断开的长尾连接可能会造成不便,但不会扰乱您的数据。

Postgres 的实现尤其以很少的开销提供强大的事务语义而闻名。虽然我已经使用它很多年了,但我从来没有理解过它。 Postgres 工作得足够可靠,以至于我能够将它视为一个黑匣子——非常有用,但其内部工作原理却是一个谜。

本文探讨了 Postgres 如何记录其事务、事务如何原子提交,以及理解这一切的关键概念1 。

管理并发访问

假设您构建了一个简单的数据库,可以读取和写入磁盘上的 CSV 文件。当单个客户端发出请求时,它会打开文件,读取一些信息,然后写回更改。大部分情况都运行良好,但有一天您决定使用一项复杂的新功能(多客户端支持)来增强您的数据库!

不幸的是,新的实现立即受到问题的困扰,当两个客户端几乎同时尝试访问数据时,这些问题似乎尤其明显。用户打开 CSV 文件,读取、修改和写入一些数据,但该更改会立即被尝试执行相同操作的另一个客户端破坏。

在这里插入图片描述

两个客户端之间的争用导致数据丢失。

这是并发访问的问题,可以通过引入并发控制来解决。有很多简单的解决方案。我们可以确保任何进程在读取或写入文件之前获取文件上的独占锁,或者我们可以通过单个流控制点推送所有操作,以便它们一次只运行一个。这些解决方法不仅速度慢,而且无法扩展以使我们的数据库完全符合 ACID 标准。现代数据库有一个更好的方式,MVCC(多版本并发控制)。

在 MVCC 下,语句在内部作为 transaction执行,并且不是直接覆盖数据,而是创建它的新版本。原始数据仍然可供其他可能需要它的客户端使用,并且任何新数据都将保持隐藏状态,直到事务提交。客户端不再直接争用,并且数据可以安全地保留,因为它们不会覆盖彼此的更改。

当事务启动时,它会拍摄一个快照来捕获数据库当时的状态。数据库中的每笔事务都是串行应用的 顺序,使用全局锁确保只有一个正在被执行 一次确认已提交或中止。快照是一个 完美表示两者之间的数据库状态 事务。

为了避免已删除和隐藏的行没完没了地累积,数据库 将通过真空进程(或者在某些情况下,与其他查询一起发生的可能的“微真空”)来删除过时的数据,但它们只会执行此操作以获取打开的快照不再需要的信息。

Postgres 使用 MVCC 管理并发访问。让我们看看它是如何工作的。

事务、元组和快照

这是 Postgres 用于表示事务的数据结构(来自proc.c ):

typedef struct PGXACT
{
    TransactionId xid;   /* id of top-level transaction currently being
                          * executed by this proc, if running and XID
                          * is assigned; else InvalidTransactionId */

    TransactionId xmin;  /* minimal running XID as it was when we were
                          * starting our xact, excluding LAZY VACUUM:
                          * vacuum must not remove tuples deleted by
                          * xid >= xmin ! */

    ...
} PGXACT;

事务通过xid (事务或“xact”ID)进行标识。作为一项优化,Postgres 仅在事务开始修改数据时才为其分配xid ,因为只有在此时其他进程才需要开始跟踪其更改。只读事务不需要xid

当一个事务启动时,‘ xmin ’会立即被设置为所有正在运行的事务中最小的‘ xid ’ 。

真空进程通过获取所有活动事务的xmin 来计算它们需要保留的最小数据边界。

元组生命周期

Postgres 中的数据行通常称为 元组。虽然 Postgres 使用 B 树等常见查找结构来加快检索速度,但索引并不存储元组的完整数据集或其任何可见性信息。相反,它们存储一个tid (元组 ID),可用于从物理存储(也称为“堆”)检索行。 tid为 Postgres 提供了一个起点,它可以开始扫描堆,直到找到满足当前快照可见性的元组。

下面是堆元组的 Postgres 实现(与索引元组相反,索引元组是在索引中找到的结构),以及表示其头部信息的其他一些结构(来自htup.hhtup_details.h ):

typedef struct HeapTupleData
{
    uint32          t_len;         /* length of *t_data */
    ItemPointerData t_self;        /* SelfItemPointer */
    Oid             t_tableOid;    /* table the tuple came from */
    HeapTupleHeader t_data;        /* -> tuple header and data */
} HeapTupleData;

/* referenced by HeapTupleData */
struct HeapTupleHeaderData
{
    HeapTupleFields t_heap;

    ...
}

/* referenced by HeapTupleHeaderData */
typedef struct HeapTupleFields
{
    TransactionId t_xmin;        /* inserting xact ID */
    TransactionId t_xmax;        /* deleting or locking xact ID */

    ...
} HeapTupleFields;

与事务一样,元组跟踪其自己的xmin ,但在元组的情况下,它被记录为表示元组变得可见的第一个事务(即创建它的事务)。它还跟踪xmax最后一个 元组可见的事务(即 删除它的事务) 2 .

在这里插入图片描述

使用 xmin 和 xmax 跟踪堆元组的生命周期。

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
{
    /*
     * The remaining fields are used only for MVCC snapshots, and are normally
     * just zeroes in special snapshots.  (But xmin and xmax are used
     * specially by HeapTupleSatisfiesDirty.)
     *
     * An MVCC snapshot can never see the effects of XIDs >= xmax. It can see
     * the effects of all older XIDs except those listed in the snapshot. xmin
     * is stored as an optimization to avoid needing to search the XID arrays
     * for most tuples.
     */
    TransactionId xmin;            /* all XID < xmin are visible to me */
    TransactionId xmax;            /* all XID >= xmax are invisible to me */

    /*
     * For normal MVCC snapshot this contains the all xact IDs that are in
     * progress, unless the snapshot was taken during recovery in which case
     * it's empty. For historic MVCC snapshots, the meaning is inverted, i.e.
     * it contains *committed* transactions between xmin and xmax.
     *
     * note: all ids in xip[] satisfy xmin <= xip[i] < xmax
     */
    TransactionId *xip;
    uint32        xcnt; /* # of xact ids in xip[] */

    ...
}

快照的xmin的计算方式与事务的 xmin 相同(即创建快照时正在运行的事务中的最低xid ),但目的不同。快照xmin是数据可见性的下限。由xid < xmin的事务创建的元组对快照可见。

它还定义了一个xmax ,它被设置为最后提交的xid加一。 xmax跟踪可见性的上限; xid >= xmax的事务对于快照不可见。

最后,快照定义了*xip ,一个包含所有 创建快照时正在活动的事务的xid*xip是需要的,因为即使已经存在xmin的可见性边界,仍然可能有一些事务已经提交,并且xid大于xmin ,但大于正在进行的事务的xid (因此它们不能包含在xmin中)。

我们希望xid > xmin的所有已提交事务的结果可见,但隐藏任何正在运行的事务的结果。 *xip存储创建快照时处于活动状态的事务列表,以便我们可以区分哪个是哪个。

在这里插入图片描述

针对数据库执行的事务和捕获某个时刻的快照。

开始事务

当执行BEGIN语句时,Postgres会执行一些基本的簿记操作,但它会尽可能地推迟更昂贵的操作。例如,在新事务开始修改数据之前,系统将延迟分配“xid”,以减少其在其他位置跟踪所带来的开销。

新事务也不会立即获得快照。 当它运行第一个查询时,它将 exec_simple_query (在postgres.c中)会将一个压入堆栈。即使是简单的SELECT 1;足以触发它:

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

    /*
     * Set up a snapshot if parse analysis/planning will need one.
     */
    if (analyze_requires_snapshot(parsetree))
    {
        PushActiveSnapshot(GetTransactionSnapshot());
        snapshot_set = true;
    }

    ...
}

创建新快照是机器真正开始发挥作用的地方。这是GetSnapshotData (在procarray.c中):

Snapshot
GetSnapshotData(Snapshot snapshot)
{
    /* xmax is always latestCompletedXid + 1 */
    xmax = ShmemVariableCache->latestCompletedXid;
    Assert(TransactionIdIsNormal(xmax));
    TransactionIdAdvance(xmax);

    ...

    snapshot->xmax = xmax;
}

该函数执行大量初始化操作,但正如我们所讨论的,它的一些最重要的工作是设置快照的xminxmax*xip 。其中最简单的是xmax ,它是从 postmaster 管理的共享内存中检索的。每个提交的事务都会通知 postmaster 它已提交,并且如果xid高于其已持有的值, latestCompletedXid将被更新。 (稍后会详细介绍)。

注意,该函数的责任是在最后一个xid上加一。这并不像递增它那么简单,因为 Postgres 中的事务 ID 可能回绕。事务 ID 被定义为一个简单的无符号 32 位整数(来自ch ):

typedef uint32 TransactionId;

尽管xid分配很节约(如上所述,读取不需要分配),但吞吐量很大的系统很容易达到 32 位的界限,因此系统需要能够回绕到“重置” xid 根据需要顺序。这是由一些预处理器处理的 魔法(在transam.h中):

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

...

/* advance a transaction ID variable, handling wraparound correctly */
#define TransactionIdAdvance(dest)    \
    do { \
        (dest)++; \
        if ((dest) < FirstNormalTransactionId) \
            (dest) = FirstNormalTransactionId; \
    } while(0)

前几个 ID 被保留为特殊标识符,因此我们总是跳过这些 ID 并从3开始。

回到GetSnapshotData ,我们得到xminxip 迭代所有正在运行的事务(再次参见 上面的快照解释了它们的作用):

/*
 * Spin over procArray checking xid, xmin, and subxids.  The goal is
 * to gather all active xids, find the lowest xmin, and try to record
 * subxids.
 */
for (index = 0; index < numProcs; index++)
{
    volatile PGXACT *pgxact = &allPgXact[pgprocno];
    TransactionId xid;
    xid = pgxact->xmin; /* fetch just once */

    /*
     * If the transaction has no XID assigned, we can skip it; it
     * won't have sub-XIDs either.  If the XID is >= xmax, we can also
     * skip it; such transactions will be treated as running anyway
     * (and any sub-XIDs will also be >= xmax).
     */
    if (!TransactionIdIsNormal(xid)
        || !NormalTransactionIdPrecedes(xid, xmax))
        continue;

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

    /* Add XID to snapshot. */
    snapshot->xip[count++] = xid;

    ...
}

...

snapshot->xmin = xmin;

提交事务

事务通过CommitTransaction (在xact.c中)提交。这个函数非常复杂,但以下是它的一些重要部分:

static void
CommitTransaction(void)
{
    ...

    /*
     * We need to mark our XIDs as committed in pg_xact.  This is where we
     * durably commit.
     */
    latestXid = RecordTransactionCommit();

    /*
     * Let others know about no transaction in progress by me. Note that this
     * must be done _before_ releasing locks we hold and _after_
     * RecordTransactionCommit.
     */
    ProcArrayEndTransaction(MyProc, latestXid);

    ...
}

持久性和 WAL

Postgres 完全是围绕持久性的目标而设计的,这意味着即使在崩溃或断电等极端事件中,已提交的事务也应保持已提交状态。与许多优秀的系统一样,它使用预写日志WAL ,或“xlog”)来实现这种持久性。所有更改都会写入并刷新到磁盘,即使突然终止,Postgres 也可以重播它在 WAL 中找到的内容,以恢复未写入其数据文件的任何更改。

上面代码片段中的RecordTransactionCommit处理将事务状态更改发送到 WAL:

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

    /*
     * If we haven't been assigned an XID yet, we neither can, nor do we want
     * to write a COMMIT record.
     */
    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);

        /*
         * Now we may update the CLOG, if we wrote a COMMIT record above
         */
        if (markXidCommitted)
            TransactionIdCommitTree(xid, nchildren, children);
    }

    ...
}

提交日志

除了 WAL 之外,Postgres 还有一个提交日志(或 “clog”或“pg_xact”)总结了每笔事务并 无论它是提交还是中止。这就是 TransactionIdCommitTree正在执行上面的操作 – 大部分 信息先写到WAL,然后 TransactionIdCommitTree遍历并将提交日志中的事务状态设置为“已提交”。

尽管提交日志被称为“日志”,但它实际上更像是分布在共享内存和磁盘上的多个页面上的提交状态位图。在现代编程中很少见的节俭示例中,事务的状态只能用两位来记录,因此我们可以在每个字节中存储四个事务,即在标准 8k​​ 页中存储 32,768 个事务。

来自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 和提交日志,但不必立即刷新(fsync),因为即使发生崩溃,我们也不会丢失任何信息。在崩溃恢复期间,Postgres 会注意到未标记的事务,并假设它们已中止。

防御性编程

TransactionIdCommitTree (在transam.c中,及其实现TransactionIdSetTreeStatus在 clog.c )提交一棵“树”,因为提交 可能有 子提交。我不会进入任何子提交 细节,但值得注意的是,因为 TransactionIdCommitTree不能保证是原子的,每个子提交都被记录为单独提交,而父提交则被记录为最后一步。当 Postgres 在崩溃后恢复时,在读取父记录并确认提交之前,子提交记录不会被视为已提交(即使它们被标记为已提交)。

这又是以原子性的语义;系统本来可以成功记录每个子提交,但在写入父提交之前就崩溃了。

clog.c中的内容如下:

/*
 * Record the final state of transaction entries in the commit log for
 * all entries on a single page.  Atomic only on this page.
 *
 * Otherwise API is same as TransactionIdSetTreeStatus()
 */
static void
TransactionIdSetPageStatus(TransactionId xid, int nsubxids,
                           TransactionId *subxids, XidStatus status,
                           XLogRecPtr lsn, int pageno)
{
    ...

    LWLockAcquire(CLogControlLock, LW_EXCLUSIVE);

    /*
     * Set the main transaction id, if any.
     *
     * If we update more than one xid on this page while it is being written
     * out, we might find that some of the bits go to disk and others don't.
     * If we are updating commits on the page with the top-level xid that
     * could break atomicity, so we subcommit the subxids first before we mark
     * the top-level commit.
     */
    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);
            }
        }

        /* ... then the main transaction */
        TransactionIdSetStatusBit(xid, status, lsn, slotno);
    }

    ...

    LWLockRelease(CLogControlLock);
}

通过共享内存发出完成信号

将事务记录到提交日志后,就可以安全地向系统的其余部分发出其完成的信号。这发生在上面CommitTransaction的第二次调用中(进入 procarray.c ):

void
ProcArrayEndTransaction(PGPROC *proc, TransactionId latestXid)
{
    /*
     * We must lock ProcArrayLock while clearing our advertised XID, so
     * that we do not exit the set of "running" transactions while someone
     * else is taking a snapshot.  See discussion in
     * 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)
{
    ...

    /* Also advance global latestCompletedXid while holding the lock */
    if (TransactionIdPrecedes(ShmemVariableCache->latestCompletedXid,
                              latestXid))
        ShmemVariableCache->latestCompletedXid = latestXid;
}

您可能想知道“ProcArray”是什么。与许多其他类似守护进程的服务不同,Postgres 使用进程fork模型来处理并发而不是线程。当它接受新连接时,Postmaster 会fork一个新后端(在postmaster.c中)。后端由PGPROC结构(在proc.h中)表示,整个活动进程集在共享内存中跟踪,即“ProcArray”。

现在记住,当创建快照时,我们如何设置它的 xmaxlatestCompletedXid + 1 ?通过设置 全局共享内存中的latestCompletedXid改为xid 刚刚提交的事务,我们刚刚完成了 结果对从此开始的每个新快照可见 向前指向任何后端。

使用LWLockConditionalAcquire和 LWLockConditionalAcquire 来查看锁获取和释放调用 LWLockRelease 。大多数时候,Postgres 是完美的 很高兴让进程并行工作,但是有一个 少数地方需要获取锁来避免 竞争,这就是其中之一。临近年初 这篇文章我们讨论了 Postgres 中的事务处理 按串行顺序提交或中止,一次一个。 ProcArrayEndTransaction获取独占锁,以便它可以更新latestCompletedXid而不会被另一个进程否定其工作。

响应客户

在整个过程中,客户端一直在同步等待其事务被确认。原子性保证的一部分是不可能出现误报,即数据库在事务尚未提交时将其标记为已提交。失败可能发生在很多地方,但如果有一个地方,客户会发现它并有机会重试或以其他方式解决问题。

可见性检查

我们之前介绍了可见性信息如何存储在堆元组上。 heapgettup (在heapam.c中)是负责扫描堆中满足快照可见性标准的元组的方法:

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

    /*
     * advance the scan until we find a qualifying tuple or run out of stuff
     * to scan
     */
    lpp = PageGetItemId(dp, lineoff);
    for (;;)
    {
        /*
         * if current tuple qualifies, return it.
         */
        valid = HeapTupleSatisfiesVisibility(tuple,
                                             snapshot,
                                             scan->rs_cbuf);

        if (valid)
        {
            return;
        }

        ++lpp;            /* move forward in this page's ItemId array */
        ++lineoff;
    }

    ...
}

HeapTupleSatisfiesVisibility是一个预处理器宏, 将调用“satisfies”函数,例如 HeapTupleSatisfiesMVCC (在tqual.c中):

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

    else if (XidInMVCCSnapshot(HeapTupleHeaderGetRawXmin(tuple), snapshot))
        return false;
    else if (TransactionIdDidCommit(HeapTupleHeaderGetRawXmin(tuple)))
        SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
                    HeapTupleHeaderGetRawXmin(tuple));

    ...

    /* xmax transaction committed */

    return false;
}

XidInMVCCSnapshot根据快照的 xid 进行初始检查以查看元组的xid是否可见 xminxmaxxip 。这是一个简化的实现,显示了对每个(来自tqual.c )的检查:

static bool
XidInMVCCSnapshot(TransactionId xid, Snapshot snapshot)
{
    /* Any xid < xmin is not in-progress */
    if (TransactionIdPrecedes(xid, snapshot->xmin))
        return false;
    /* Any xid >= xmax is in-progress */
    if (TransactionIdFollowsOrEquals(xid, snapshot->xmax))
        return true;

    ...

    for (i = 0; i < snapshot->xcnt; i++)
    {
        if (TransactionIdEquals(xid, snapshot->xip[i]))
            return true;
    }

    ...
}

注意,与您直观的想法相比,该函数的返回值是相反的 , false表示xid对快照可见。尽管令人困惑,但您可以通过将返回值与调用它的位置进行比较来了解它正在做什么。

确认xid可见后,Postgres 使用TransactionIdDidCommit (来自transam.c )检查其提交状态:

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

    xidstatus = TransactionLogFetch(transactionId);

    /*
     * If it's marked committed, it's committed.
     */
    if (xidstatus == TRANSACTION_STATUS_COMMITTED)
        return true;

    ...
}

进一步探索落实 TransactionLogFetch将显示它的工作原理与宣传的一样。它根据给定的事务 ID 计算提交日志中的位置,并访问该位置以获取该事务的提交状态。提交的事务是否用于帮助确定元组的可见性。

这里的关键是,为了保持一致性,提交日志被视为提交状态(以及扩展的可见性)的规范来源3 。无论 Postgres 是在几小时前成功提交事务,还是在服务器刚刚从中恢复的崩溃前几秒,都将返回相同的信息。

Hint bits 提示位

上面的HeapTupleSatisfiesMVCC在从可见性检查返回之前还做了一件事:

SetHintBits(tuple, buffer, HEAP_XMIN_COMMITTED,
            HeapTupleHeaderGetRawXmin(tuple));

检查提交日志以查看元组的xmin或 提交xmax事务是一项昂贵的操作。为了避免每次都必须访问它,Postgres 将为扫描的堆元组设置称为“提示位”的特殊提交状态标志。后续操作可以检查元组的提示位,并保存到提交日志本身。

黑盒子

当我对数据库运行事务时:

BEGIN;

SELECT * FROM users WHERE email = 'brandur@example.com';

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

COMMIT;

我不会停下来思考发生了什么事。我获得了一个强大的高级抽象(以 SQL 的形式),我知道它会可靠地工作,并且正如我们所见,Postgres 在幕后完成了所有繁重的工作。好的软件是一个黑盒子,而 Postgres 是一个特别黑的盒子(尽管内部结构很容易访问)。

感谢Peter Geoghegan耐心地回答了我所有关于 Postgres 事务和快照的业余问题,并为我提供了一些查找相关代码的指导。


1警告几句:Postgres 源代码相当庞大,因此我掩盖了一些细节以使本文更容易理解。它也正在积极开发中,因此随着时间的推移,其中一些代码示例可能会变得相当过时。

2读者可能会注意到,虽然xminxmax可以很好地跟踪元组的创建和删除,但它们不足以处理更新。为了简洁起见,我现在掩盖了更新的工作原理。

3请注意,提交日志最终将被截断,但只会超出快照的xmin范围,因此在必须在 WAL 中进行检查之前,可见性检查会短路。


原文地址:How Postgres Makes Transactions Atomic — brandur.org


http://www.kler.cn/a/422035.html

相关文章:

  • 视频监控汇聚平台Liveweb视频安防监控实时视频监控系统操作方案
  • Linux中dos2unix详解
  • 自然语言处理:基于BERT预训练模型的中文命名实体识别(使用PyTorch)
  • 力扣hot100道【贪心算法后续解题方法心得】(三)
  • Navicat连接SQL Server及SpringBoot连接SQL Server(jtds)
  • C++学习日记---第16天
  • [每周一更]-(第125期):模拟面试|NoSQL面试思路解析
  • 备赛蓝桥杯--算法题目(2)
  • 基于Matlab地形和环境因素的森林火灾蔓延模拟与可视化研究
  • Windows系统搭建Docker
  • 040集——CAD中放烟花(CAD—C#二次开发入门)
  • qt6 oob
  • 微服务即时通讯系统的实现(服务端)----(3)
  • 基于Python 哔哩哔哩网站热门视频数据采集与可视化分析设计与实现,有聚类有网络语义研究
  • 【数据集】细胞数据集:肿瘤-胎儿重编程的内皮细胞驱动肝细胞癌中的免疫抑制性巨噬细胞(Sharma等人)
  • helm部署golang服务
  • numpy 计算两组向量是否相等,以及在一定误差内相等
  • QT - (qrc->binary)
  • 人工智能学习框架:构建AI应用的基石
  • Rust面向对象特性
  • 第三方Express 路由和路由中间件
  • 攻防世界-fileclude-文件包含
  • springboot 项目 层级架构
  • aisuite - 一个接口调用多个大模型
  • 大语言模型在研究领域的应用---下
  • MySQL、Oracle、SQL Server 和 PostgreSQL 的分页查询