工业网关数据持久化:从同步到 WAL 的工程实战

工业网关要处理的不只是"把数据写出去",还包括断电后能不能恢复、进程崩溃后会不会丢状态、存储写满时会不会覆盖旧数据、现场掉电重连后能不能从断点继续补传。

很多项目的持久化问题并不是因为没有调用 write(),而是因为把"写入接口返回成功"误认为"数据已经可靠落盘"。本文按工程落地顺序梳理:持久化语义、同步与异步策略、WAL 记录格式、Group Commit、Checkpoint、原子写入、文件系统选择、掉电测试和监控指标。

一、先把持久化目标定义清楚

讨论持久化前,先明确系统要防御哪类故障。

故障边界 需要的机制 典型效果
应用异常退出 状态可序列化,或使用进程外存储 内存队列会丢,文件/数据库可恢复
操作系统崩溃 fsync / fdatasync / 数据库事务 已提交状态不依赖 page cache
网关突然掉电 存储介质真实持久化,必要时禁用易失写缓存 掉电前已确认的数据可恢复
存储损坏或写满 校验、告警、保留策略、备份 能发现故障,而不是静默覆盖
断网后恢复 本地 WAL / 队列水位 / 补传状态 数据不丢、不乱序、不无限重复

因此,工业网关要先定义每个数据流的 RPO:

  • 遥测数据能接受丢最近多少秒;
  • 控制命令、配置变更、计量结算数据是否要求返回前必须落盘;
  • 断网缓存最多保留多久、占多少空间;
  • 存储写满时淘汰低价值数据,还是停止接收高价值数据;
  • 补传时如何保证幂等,避免同一条记录重复入库。

这些答案会直接决定是否每条记录 fsync,还是允许 WAL 批量提交。

二、同步写入、异步写入与 WAL 的取舍

1. 同步写入

每次写入后执行 fsyncfsync 返回后才向业务确认。

优点是确认语义强:调用成功后,进程崩溃、系统崩溃和掉电场景下数据都应可恢复。代价是延迟高、IOPS 低,对 eMMC / SD 卡的写入寿命也更不友好。

适合控制命令、授权变更、计量和审计等低频但高价值数据。

2. 异步写入

数据先进入内存队列,后台线程定期批量写文件。

优点是性能好、写入平滑。问题是进程崩溃或掉电时,未落盘的数据会丢;如果队列无上限,故障时还会造成内存膨胀。

异步写入必须配置:

  • 队列最大长度或最大字节数;
  • 最大等待时间;
  • 磁盘写满策略;
  • 背压策略;
  • 重启后的恢复水位。

3. Write-Ahead Log

WAL(Write-Ahead Log,预写日志)的核心原则是:先把变更写入可恢复的日志,确认日志持久化后,再修改主状态或向业务确认成功

它不是简单追加文件。一个可用的 WAL 至少要处理:

  • 记录长度与边界;
  • CRC 或其他完整性校验;
  • 单调序列号;
  • 半条记录写入的情况;
  • 版本与 schema 兼容;
  • 并发写入串行化;
  • Checkpoint 后的日志截断或轮转。

4. Group Commit

如果每条记录都单独 fsync,延迟会被大量小事务放大。Group Commit 把一段时间内的多条记录合成一次写盘和一次 fsync,所有参与者等这批日志持久化后一起收到确认。

它不会降低"已确认数据必须可恢复"的语义,只是把强制落盘的成本分摊到多条记录上。

策略 确认时是否已落盘 掉电 RPO 吞吐 适用
单条同步写 已确认数据不丢 命令、配置、审计
异步批量写 可能丢一批 非关键采样
WAL + 单条 fsync 已确认数据不丢 低频关键事件
WAL + Group Commit 已确认数据不丢 较高 高频事件、断网缓存

这里的"不丢"有一个前提:存储设备自身在掉电后能真实保存已完成写入。如果磁盘或 eMMC 模组存在易失写缓存,而系统没有正确处理,软件层的 fsync 也无法提供期望的掉电持久性。

三、先修正几个 fsync 误区

1. write() 成功不等于落盘

write() 通常只把数据交给内核 page cache。函数返回成功,说明用户态缓冲区已经交出去,并不保证设备已经写入。

2. Python flush() 不等于 fsync

Python 文件对象的 flush() 主要把 Python / C 库用户态缓冲刷到内核。要让已写入数据和必要元数据持久化,仍需:

python 复制代码
f.flush()
os.fsync(f.fileno())

3. close() 不保证 fsync

关闭文件会释放描述符,不等于显式执行了 fsync。依赖关闭时偶然发生的回写,不能作为可靠性设计。

4. rename 前后都要考虑持久化

原子替换文件的常见流程是:

  1. 写临时文件;
  2. flushfsync 临时文件;
  3. rename 临时文件为目标文件;
  4. fsync 所在目录。

第 4 步经常被漏掉。目录项变更本身也属于元数据,掉电后可能出现"数据文件已持久化,但 rename 未持久化"的情况。

5. 检查存储写缓存语义

fsync 要求存储栈完成到介质的持久化,但如果设备存在未受控的易失写缓存,语义可能被破坏。工程上要确认:

  • SSD / eMMC 是否有掉电保护电容;
  • 设备写缓存是否安全启用;
  • NVMe / SATA 的缓存语义;
  • 嵌入式板卡电源保持时间是否足够完成最后一批写入;
  • 文件系统、驱动和介质是否有厂商认证组合。

对工业网关,电源设计和存储选型也是持久化设计的一部分。

四、一个更完整的 WAL 记录格式

只写 type + length + payload 不够。掉电时可能只写入半条记录,日志中段也可能因存储异常出现位翻转。下面的教学版实现加入魔数、版本、序列号和 CRC:

python 复制代码
import os
import struct
import zlib
from dataclasses import dataclass
from fcntl import flock, LOCK_EX, LOCK_SH, LOCK_UN

MAGIC = b"EW01"
HEADER = struct.Struct("!4sHHQI")
MAX_PAYLOAD = 8 * 1024 * 1024


@dataclass(frozen=True)
class WalRecord:
    version: int
    record_type: int
    sequence: int
    payload: bytes


class SimpleWal:
    def __init__(self, path: str):
        self.path = path
        self.next_sequence = 1
        self.healthy = True
        self.fd = os.open(
            path,
            os.O_RDWR | os.O_CREAT | os.O_APPEND,
            0o600,
        )
        self._recover_next_sequence()

    def _recover_next_sequence(self):
        last_sequence = 0
        for record in self.replay():
            last_sequence = record.sequence
        self.next_sequence = last_sequence + 1

    @staticmethod
    def _write_all(fd: int, data: bytes):
        view = memoryview(data)
        while view:
            written = os.write(fd, view)
            if written <= 0:
                raise OSError("short write to WAL")
            view = view[written:]

    @staticmethod
    def _read_exact(fd: int, size: int) -> bytes:
        chunks = []
        while size:
            chunk = os.read(fd, size)
            if not chunk:
                break
            chunks.append(chunk)
            size -= len(chunk)
        return b"".join(chunks)

    def append_many(self, items: list[tuple[int, bytes]]) -> int:
        if not items:
            return self.next_sequence - 1
        if not self.healthy:
            raise RuntimeError("WAL is in failed state")

        frames = []
        sequence = self.next_sequence
        for record_type, payload in items:
            if not payload:
                raise ValueError("empty payload")
            if len(payload) > MAX_PAYLOAD:
                raise ValueError("payload too large")

            crc = zlib.crc32(payload) & 0xFFFFFFFF
            frames.append(HEADER.pack(
                MAGIC,
                1,
                record_type,
                sequence,
                len(payload),
            ))
            frames.append(payload)
            frames.append(struct.pack("!I", crc))
            sequence += 1

        flock(self.fd, LOCK_EX)
        try:
            self._write_all(self.fd, b"".join(frames))
            os.fsync(self.fd)
        except BaseException:
            self.healthy = False
            raise
        else:
            self.next_sequence = sequence
            return sequence - 1
        finally:
            flock(self.fd, LOCK_UN)

    def replay(self):
        os.lseek(self.fd, 0, os.SEEK_SET)
        flock(self.fd, LOCK_SH)
        expected_sequence = None

        try:
            while True:
                header = self._read_exact(self.fd, HEADER.size)
                if not header:
                    break
                if len(header) < HEADER.size:
                    self._report_torn_tail(len(header), 0)
                    break

                magic, version, record_type, sequence, length = HEADER.unpack(header)
                if magic != MAGIC:
                    raise ValueError("WAL magic mismatch: possible mid-file corruption")
                if version != 1:
                    raise ValueError(f"unsupported WAL version: {version}")
                if length > MAX_PAYLOAD:
                    raise ValueError(f"invalid WAL payload length: {length}")
                if expected_sequence is not None and sequence != expected_sequence:
                    raise ValueError(f"WAL sequence gap: got {sequence}, expect {expected_sequence}")

                payload = self._read_exact(self.fd, length)
                crc_bytes = self._read_exact(self.fd, 4)
                if len(payload) != length or len(crc_bytes) != 4:
                    self._report_torn_tail(len(header), len(payload))
                    break

                (crc,) = struct.unpack("!I", crc_bytes)
                if (zlib.crc32(payload) & 0xFFFFFFFF) != crc:
                    raise ValueError(f"WAL CRC mismatch at sequence {sequence}")

                record = WalRecord(version, record_type, sequence, payload)
                yield record

                expected_sequence = sequence + 1
        finally:
            flock(self.fd, LOCK_UN)

    @staticmethod
    def _report_torn_tail(header_size: int, payload_size: int):
        # 生产实现应记录 offset、header_size、payload_size,并进入只读告警状态。
        print(f"torn WAL tail: header={header_size}, payload={payload_size}")

    def close(self):
        if self.fd >= 0:
            os.close(self.fd)
            self.fd = -1

这个实现仍然是教学版。生产系统还要考虑日志轮转、只读错误处理、恢复时截断半条尾部、schema 演进、跨进程写入协议和磁盘满行为。

WAL 恢复时必须区分三类问题

问题 现象 处理
半条尾部记录 最后一帧 header 或 payload 不完整 停在最后一条完整记录,记录告警
CRC 错误 长度合法但校验失败 不允许静默跳过,应进入诊断模式
序列号跳变 中间丢帧或多写入者交错 停止恢复,检查并发与存储

特别是中段 CRC 错误,不能简单"从下一条 magic 继续扫描"。这可能掩盖日志被覆盖、偏移错位或存储损坏。

五、Group Commit:合并 fsync,不降低确认语义

下面是一个 asyncio 版 Group Commit 框架。请求方把 payload 放入有界队列,后台提交者攒批后调用 append_many(),一次 fsync 成功后再唤醒所有请求。

python 复制代码
from __future__ import annotations

import asyncio


class GroupCommitPipeline:
    def __init__(
        self,
        wal,
        *,
        max_batch_records: int = 256,
        max_batch_bytes: int = 1024 * 1024,
        max_wait: float = 0.01,
        max_queue: int = 10000,
    ):
        self.wal = wal
        self.max_batch_records = max_batch_records
        self.max_batch_bytes = max_batch_bytes
        self.max_wait = max_wait
        self.queue: asyncio.Queue[tuple[int, bytes, asyncio.Future]] = asyncio.Queue(
            maxsize=max_queue
        )
        self.worker: asyncio.Task | None = None
        self.closing = False

    def start(self):
        if self.worker is None:
            self.worker = asyncio.create_task(self._run(), name="wal-group-commit")

    async def stop(self):
        if self.worker is None:
            return
        self.closing = True
        await self.queue.join()
        self.worker.cancel()
        try:
            await self.worker
        except asyncio.CancelledError:
            pass
        self.worker = None

    async def commit(self, record_type: int, payload: bytes) -> int:
        if self.closing:
            raise RuntimeError("group commit pipeline is closing")
        loop = asyncio.get_running_loop()
        future = loop.create_future()
        await self.queue.put((record_type, payload, future))
        return await future

    async def _take_batch(self):
        first = await self.queue.get()
        batch = [first]
        batch_bytes = len(first[1])

        while len(batch) < self.max_batch_records:
            remaining = self.max_batch_bytes - batch_bytes
            if remaining <= 0:
                break
            try:
                item = await asyncio.wait_for(self.queue.get(), timeout=self.max_wait)
            except asyncio.TimeoutError:
                break

            _, payload, _ = item
            if len(payload) > remaining and batch:
                self.queue.put_nowait(item)
                break

            batch.append(item)
            batch_bytes += len(payload)

        return batch

    async def _run(self):
        while True:
            batch = await self._take_batch()
            try:
                last_sequence = self.wal.append_many(
                    [(record_type, payload) for record_type, payload, _ in batch]
                )
                for *_, future in batch:
                    if not future.done():
                        future.set_result(last_sequence)
            except BaseException as exc:
                for *_, future in batch:
                    if not future.done():
                        future.set_exception(exc)
                # 连续失败应触发熔断,避免业务继续填满内存队列。
                await asyncio.sleep(0.2)
            finally:
                for _ in batch:
                    self.queue.task_done()

关键点:

  • max_queue 必须有界,否则磁盘变慢时上游会耗尽内存;
  • max_wait 是攒批上限,不是允许丢数据的窗口;
  • 提交成功前不能给业务返回成功;
  • 批大小同时限制记录数和字节数,避免超大帧阻塞;
  • WAL 写入失败时,要区分可重试 I/O 错误和不可重试的存储故障;
  • 优雅停机时要 drain 队列,再关闭 WAL。

如果业务流量有毛刺,还要记录:

  • 请求进入队列到提交开始的等待时间;
  • WAL 写入和 fsync 耗时;
  • 队列长度和丢弃率;
  • 每秒提交记录数和字节数。

六、Checkpoint:控制 WAL 长度和恢复时间

WAL 不能无限增长。Checkpoint 的作用是把某个序列号之前的状态固化成快照,让恢复时只需回放其后的增量。

1. Checkpoint 不是简单 truncate

一个危险流程是:

text 复制代码
写快照文件
成功后立刻清空 WAL

如果快照尚未真正持久化、目录项尚未持久化,或者清空 WAL 后进程崩溃,可能出现快照和日志都无法恢复的窗口。

更稳妥的顺序是:

text 复制代码
暂停或锁定状态变更
  ↓
复制一致状态,并记录 last_sequence
  ↓
快照写入临时文件 + fsync
  ↓
rename 快照 + fsync 目录
  ↓
确认快照可加载
  ↓
轮转或截断 sequence <= last_sequence 的 WAL
  ↓
记录 checkpoint 元数据

实际数据库通常还有更精细的两阶段和崩溃恢复机制,但原则相同:新快照未确认可恢复前,不能破坏旧快照和旧日志。

2. 恢复时按 sequence 过滤

如果 checkpoint 后 WAL 没有及时删除,重启时可能既读到旧快照,又回放快照之前的日志。恢复逻辑必须记住快照的 last_sequence,只应用 sequence > last_sequence 的记录。

python 复制代码
def should_apply(snapshot_last_sequence: int, record_sequence: int) -> bool:
    return record_sequence > snapshot_last_sequence

3. 保留上一份快照

推荐至少保留两代:

text 复制代码
snapshot-000123.json     当前快照
snapshot-000120.json     上一份快照
wal-000124.log           当前日志
wal-000121.log           checkpoint 前待确认日志
checkpoint.json          指向当前快照和日志的清单

清理旧文件时,要从 checkpoint 元数据出发,不要只按文件时间删。现场存储异常时,多保留一代往往比省几 MB 空间更有价值。

4. 应用必须幂等

WAL 回放和断网补传都可能重复执行。记录设计要支持幂等:

  • 每条业务事件有唯一 ID;
  • 使用 (source, event_id)(device_id, sequence) 去重;
  • 状态更新使用版本号或条件写;
  • 副作用命令要区分"重放日志"和"重新下发控制命令";
副作用类型 回放建议
更新内存状态 可按日志重放
写本地数据库 使用唯一键或事务幂等
上报云端 云端按 event_id 去重
下发设备命令 通常不能盲目重放,需要业务确认

七、原子写入:防止半份状态文件

下面是带文件和目录 fsync 的原子写示例:

python 复制代码
import os
import tempfile


def atomic_write(path: str, data: bytes):
    directory = os.path.dirname(path) or "."
    os.makedirs(directory, exist_ok=True)

    fd, tmp_path = tempfile.mkstemp(
        prefix=".tmp-",
        dir=directory,
    )

    try:
        view = memoryview(data)
        while view:
            written = os.write(fd, view)
            if written <= 0:
                raise OSError("short write")
            view = view[written:]

        os.fsync(fd)
        os.close(fd)
        fd = -1

        os.replace(tmp_path, path)

        dir_fd = os.open(directory, os.O_RDONLY | os.O_DIRECTORY)
        try:
            os.fsync(dir_fd)
        finally:
            os.close(dir_fd)
    finally:
        if fd >= 0:
            os.close(fd)
        try:
            os.unlink(tmp_path)
        except FileNotFoundError:
            pass

工程上还要注意:

  • 权限、owner、SELinux 标签是否需要在替换后恢复;
  • 并发写入者是否需要应用级锁;
  • 临时文件必须和目标文件在同一个文件系统;
  • 对大状态文件应优先使用快照 + WAL,而不是频繁整文件重写;
  • 配置文件中不要保存明文密钥。

八、文件系统与存储介质选择

文件系统 特点 工程注意点
ext4 工业网关常用,日志和工具链成熟 仍需显式 fsync,建议评估 data=ordered 与挂载参数
XFS 高吞吐、大文件表现好 更适合大容量写入和归档场景
f2fs 面向闪存设计 需验证内核版本、介质和厂商支持
btrfs 快照、校验、压缩 数据库 / WAL 文件要谨慎处理 CoW 与碎片,确认厂商支持
overlayfs 容器常见 容器内路径不一定等于真正持久卷,要挂载独立 volume

工业网关还要确认:

  • eMMC / SD / NVMe / SATA 的写入寿命和容量规划;
  • 小块随机写造成的写放大;
  • 磨损均衡和保留空间;
  • 掉电后文件系统恢复时间;
  • fsck 是否适合现场自动执行;
  • 是否存在只读保护机制;
  • 存储满时系统日志会不会挤掉业务数据。

挂载选项里的 noatime 可以减少不必要写入,但 commit= 参数不能替代业务 fsync。文件系统 journal 保证元数据一致性,不等价于每个业务文件内容都已按期望时机持久化。

九、优先评估成熟存储引擎

自研 WAL 适合作为教学或非常小的状态机。多数项目应优先评估 SQLite、嵌入式时序库、嵌入式 KV 存储或远端数据库,把精力放在业务状态和幂等设计上。

以 SQLite 为例:

sql 复制代码
PRAGMA journal_mode=WAL;
PRAGMA synchronous=FULL;
PRAGMA busy_timeout=5000;

journal_mode=WAL 与本文讨论的通用 WAL 思想相似,但实现细节由 SQLite 管理。synchronous=FULL 提供更强的掉电持久性;NORMAL 在 WAL 模式下通常能保证一致性,但可能牺牲部分提交后的掉电持久性,关键数据不要只按默认值配置。

使用嵌入式数据库时,还要关注:

  • WAL 文件增长和 checkpoint 频率;
  • 事务大小和锁竞争;
  • flash 写放大;
  • 数据库文件所在 volume 是否真的持久化;
  • 备份时使用在线快照 API,而不是直接复制正在写的文件;
  • 升级时的 schema migration 和回滚策略。

十、掉电和故障测试不能省

kill -9 只验证进程崩溃,不能验证掉电。持久化功能至少要覆盖以下测试。

测试 目的
进程 kill 验证 page cache 之外的状态可恢复
磁盘写满 验证背压、告警和不会覆盖关键 WAL
半条 WAL 尾部 验证恢复停在最后完整记录
WAL 中段 CRC 错误 验证进入告警,而不是静默跳过
断电重启 验证真实介质持久化和文件系统恢复
短时反复掉电 验证电源保持、写缓存和存储栈
断网 24 小时 验证缓存上限、水位和补传幂等
升级后回放旧 WAL 验证 schema 版本兼容
存储只读 验证网关进入安全模式并告警

上线前建议把掉电测试自动化成固定脚本,并在真实网关硬件上执行。只在上位机模拟器里测试,无法暴露电源、eMMC、驱动和文件系统的组合问题。

十一、监控指标

指标 含义 告警建议
wal_queue_depth 等待提交的记录数 持续增长说明磁盘变慢或上游突发
wal_queue_wait_ms 请求进入队列到提交耗时 关注 p95 / p99
wal_fsync_ms 单次 WAL 提交耗时 与设备基线比较
wal_bytes_written WAL 写入速率 用于容量和寿命评估
wal_size_bytes 当前日志大小 结合 checkpoint 水位
checkpoint_duration_ms 快照耗时 过长说明状态过大或存储抖动
checkpoint_last_age 最近 checkpoint 距今时间 超过阈值告警
disk_free_bytes 剩余空间 分层告警,避免写满
disk_readonly 文件系统是否只读 立即告警
wal_replay_records 启动回放记录数 反映恢复时间和 checkpoint 效果
data_loss_window_seconds 设计 RPO 超过业务阈值告警

监控本身也要持久化边界清楚。如果监控日志和业务数据共享同一块小 eMMC,需要设置轮转和保留策略,否则观测系统可能先把存储写满。

十二、常见坑

坑 1:把 write() 当落盘

write() 成功只说明数据进入内核,不保证掉电后可读。需要落盘语义时必须 fsync 或使用数据库事务。

坑 2:WAL 没有 CRC 和序列号

掉电可能产生半条记录,存储也可能损坏。没有校验的 replay 会把脏数据当成业务状态。

坑 3:所有异常都当可重试

存储只读、权限错误、schema 不兼容、payload 过大,通常不会因为重试而变好。连续失败应熔断并进入安全模式。

坑 4:队列无上限

磁盘一旦变慢,无界队列会耗尽内存。必须有界,并在高水位触发背压或降采样。

坑 5:Checkpoint 后直接清 WAL

新快照未确认持久化前,不能删除旧 WAL。恢复时也要按 last_sequence 过滤,避免重复应用。

坑 6:忽视目录 fsync

文件内容正确,但 rename / unlink 的目录项没有持久化,掉电后仍可能恢复到旧状态。

坑 7:在容器里写镜像层

容器内路径可能是 overlayfs 镜像层,容器重建后数据消失。网关状态必须挂载明确的数据卷。

坑 8:只测 kill,不测掉电

kill -9 不代表掉电。真实电源、写缓存、eMMC 和文件系统行为必须上机验证。

十三、落地清单

  1. 给每类数据标注 RPO、保留时长和幂等键;
  2. 明确确认成功必须发生在 fsync 之后,还是允许批量提交;
  3. WAL 记录包含版本、类型、序列号、长度和 CRC;
  4. 恢复时处理半条尾部、中段损坏和序列跳变;
  5. Group Commit 队列有界,并输出排队、提交和失败指标;
  6. Checkpoint 记录 last_sequence,保留上一代快照;
  7. 原子写入同时 fsync 文件和目录;
  8. 确认存储介质、写缓存、电源保持时间和挂载路径;
  9. 磁盘写满时优先保护关键日志和状态;
  10. 建立掉电、只读、断网、升级回放和 CRC 错误的自动化测试。

在 Zenova EdgeOS 的边缘数据链路中,持久化水位、WAL 增长、fsync 延迟和 checkpoint 结果会被纳入统一的运行时观测与告警体系,减少网关在断电、断网或存储老化时出现不可解释的数据缺口。

TL;DR

持久化的第一步是定义故障边界和 RPO。write()flush()fsync()、rename 和目录 fsync 的语义不同,不能混用。WAL 的核心是"先持久化日志,再改主状态",并且要有 CRC、序列号、版本和可处理的半条尾部。Group Commit 可以合并 fsync,但不能让未落盘数据提前返回成功。Checkpoint 通过 last_sequence 控制 WAL 长度和恢复时间,必须保证新快照确认可恢复后再清理旧日志。工业网关还要同时评估 eMMC 寿命、电源掉电、文件系统、容器数据卷、磁盘写满和真实掉电测试。

相关推荐
企查查数据服务1 小时前
全军禁入下客商准入风控,关联图谱与穿透核查
大数据·开发语言·数据库·php
冰帆<1 小时前
DBViewer — 把数据库工作台,安装进浏览器
数据库·数据可视化·数据同步
晴天161 小时前
前端 SSR、BFF 原理介绍
前端·javascript·网络·html
学Linux的语莫1 小时前
LangChain Prompts 提示词模板
数据库·人工智能·langchain
敲代码的嘎仔1 小时前
从集群锁失效到异步领券:我用分布式锁 + Redisson + MQ 把领券接口 RT 从 200ms 压到 15ms
java·数据库·redis·分布式·缓存·ai·wpf
步行cgn1 小时前
Spring 的模块体系详解
java·数据库·spring
vx-程序开发2 小时前
【计算机毕设】基于Spring Boot的古城景区管理系统88564
java·数据库·spring boot·后端·spring·elasticsearch·课程设计
80s7772 小时前
动态ip地址分配通常是由_动态IP与静态IP之间的区别
网络·网络协议·tcp/ip
写后端的胖头鱼2 小时前
【高频面试题】深分页问题
数据库·mysql·数据库优化·深分页