indexDB

1. 在项目中使用IndexDB缓存多类型数据(对话、题目、会话状态)时,你是如何设计对象存储空间(Object Store)的?为什么不采用单-store存储所有类型数据?请结合数据特点说明设计思路。

参考答案

设计思路是按数据类型拆分3个独立的Object Store ,分别为conversations(对话)、questions(题目)、sessionStates(会话状态),核心设计逻辑如下:

  1. 主键策略差异化

    • 对话数据(conversations):需唯一标识单条对话,且需按时间排序,故主键设为复合结构{ sessionId: string, timestamp: number }(会话ID+时间戳),避免同一会话内重复时间戳的冲突;
    • 题目数据(questions):题目ID全局唯一(如后端生成的UUID),故主键直接设为questionId: string,便于快速查询单题详情;
    • 会话状态(sessionStates):一个会话对应一个状态,主键设为sessionId: string,保证"会话-状态"的1:1映射。
  2. 索引需求不同

    • conversations需支持"按会话ID筛选所有对话",故基于sessionId单独建立索引;
    • questions需支持"按题目类型(如单选/多选)筛选",故基于questionType建立索引;
    • sessionStates仅需按sessionId查询,无需额外索引。

不采用单-store的原因:

  • 单-store存储会导致主键冲突(如对话的timestamp与题目的questionId格式不同,无法共用主键);
  • 索引冗余:为满足多类型数据的查询需求,单-store需建立大量无关索引,降低读写性能;
  • 数据隔离性差:增删改某类数据时(如删除过期对话),需额外过滤类型字段,易误操作其他数据,且事务无法保证"仅操作目标类型"的原子性。

2. 处理JSON序列化的深拷贝兼容问题时,你遇到了哪些具体场景(比如特殊数据类型、循环引用等)?分别采用了什么解决方案?如何验证序列化/反序列化后的完整性?

参考答案

一、核心问题场景与解决方案

  1. 特殊数据类型丢失/变形

    • 场景1:Date对象 :JSON序列化会将new Date()转为"2024-05-20T12:00:00.000Z"字符串,反序列化后无法恢复为Date类型;
      方案 :自定义JSON.stringifyreplacer函数,将Date对象标记为{ __type: 'Date', value: date.toISOString() };反序列化时通过reviver函数识别__type: 'Date',调用new Date(value)重建。
    • 场景2:RegExp对象 :序列化后会丢失flags(如/abc/g变为{});
      方案 :replacer阶段记录source(正则表达式体)和flags(如g/i),格式为{ __type: 'RegExp', source: 'abc', flags: 'g' };reviver阶段通过new RegExp(source, flags)重建。
    • 场景3:BigInt精度丢失 :超过2^53的BigInt(如后端返回的19位ID)会被转为Number导致精度丢失;
      方案 :replacer将BigInt转为{ __type: 'BigInt', value: bigInt.toString() },reviver阶段通过BigInt(value)恢复。
  2. 循环引用报错

    • 场景 :会话状态中可能包含"当前对话引用",导致JSON.stringify直接抛出TypeError: Converting circular structure to JSON
      方案 :用WeakMap记录已遍历的对象,replacer函数中检测到循环引用时,标记为{ __type: 'Circular', ref: 唯一标识 },反序列化时通过唯一标识重新建立引用;或使用成熟库(如lodash.clonedeep)的循环引用处理逻辑,但需二次封装适配项目数据类型。

二、完整性验证方案

  1. 单元测试覆盖 :针对每种特殊类型(Date/RegExp/BigInt)和循环引用场景,编写"序列化-反序列化"对比用例,断言:
    • 数据类型一致(如反序列化后instanceof Datetrue);
    • 核心属性一致(如RegExp的sourceflags、BigInt的数值);
    • 引用关系一致(如循环引用对象的内存地址指向正确)。
  2. 生产环境日志监控:在反序列化失败时,记录原始JSON字符串和错误栈,排查未覆盖的异常类型(如后端新增的特殊字段)。

3. IndexDB是异步操作,在缓存多类型数据时,如何保证"同时写入对话和会话状态"这类关联操作的一致性?如果中途失败(如浏览器崩溃),如何做数据恢复?

参考答案

一、关联操作一致性保证:基于IndexDB事务(Transaction)

IndexDB的事务是"原子性"的核心,具体实现如下:

  1. 事务创建 :调用db.transaction(['conversations', 'sessionStates'], 'readwrite'),同时指定两个需要操作的store,模式为readwrite(读写模式);
  2. 批量操作 :在事务内执行"写入对话"和"更新会话状态"的操作(如conversations.add(convData)sessionStates.put(stateData));
  3. 结果监听
    • 事务的oncomplete事件:所有操作成功时触发,确认数据一致性;
    • 事务的onerror事件:任意一步失败时触发,事务自动回滚(所有已执行的操作都会撤销,不会出现"对话写入成功、状态更新失败"的中间态)。

二、中途失败的数据恢复:版本控制+操作日志表

  1. 设计操作日志表 :新增syncLogs Object Store,记录每笔关联操作的元数据,格式为:

    javascript 复制代码
    {
      logId: string, // 唯一日志ID(UUID)
      operationType: 'createConvWithState', // 操作类型,标识关联操作
      status: 'pending'|'completed'|'failed', // 操作状态
      data: { convData, stateData }, // 操作的原始数据
      timestamp: number // 操作时间
    }
  2. 崩溃后恢复流程

    • 页面重新初始化时,通过IndexDB的versionchange事件检测数据库连接,优先查询syncLogsstatus: 'pending'的日志;
    • 对每笔pending日志,重新执行对应的关联操作(基于日志中的data),并更新日志状态为completed
    • 若重试失败(如数据格式已变更),将日志状态改为failed,并触发前端告警,由用户手动触发同步或后端介入。

4. 项目中"重构同步逻辑覆盖所有增删改场景",具体是如何设计同步策略的?比如本地修改与远程数据冲突时(如A在本地修改,B在远程删除同一条数据),采用什么冲突解决规则?

参考答案

一、全场景同步策略:基于"版本号+操作标记"的增量同步

  1. 数据元信息设计:为所有本地数据(对话/题目/会话状态)增加3个核心字段:

    • version: number // 数据版本号(初始为1,每次修改+1);
    • lastModifiedTime: number // 最后修改时间戳;
    • operation: 'none'|'create'|'update'|'delete' // 本地操作标记(默认none,增删改后更新为对应类型)。
  2. 同步流程(本地→远程+远程→本地)

    • 步骤1:本地增量上报
      筛选本地operation !== 'none'的数据,按类型批量上报给后端,携带versionlastModifiedTime
      后端验证版本号(如仅接受比远程当前版本高的数据),处理完成后返回"处理结果+远程最新版本号";
      本地收到结果后,将对应数据的operation改为none,并更新version为远程最新版本。
    • 步骤2:远程增量拉取
      本地携带"各类型数据的最大version"请求后端,后端返回"版本号高于本地的数据"(包含增删改记录);
      本地按operation类型处理:create→新增、update→覆盖(基于version)、delete→标记本地数据为删除。

二、冲突解决规则:分层优先级+业务场景适配

针对"本地修改vs远程删除"等冲突,采用"业务优先级+时间戳兜底"的规则:

  1. 冲突场景1:本地修改 vs 远程删除

    • 若数据为"会话状态"(强关联当前用户):优先保留本地修改,同步时告知后端"恢复删除并应用本地修改",后端更新数据并记录冲突日志;
    • 若数据为"公共题目"(多用户共享):优先遵循远程删除,本地标记数据为isDeleted: true,并提示用户"该题目已被管理员删除,本地修改未同步"。
  2. 冲突场景2:本地删除 vs 远程修改

    • 对比本地删除时间戳与远程修改时间戳:
      • 若本地删除时间更新(后于远程修改):保留本地删除,同步时请求后端删除远程数据;
      • 若远程修改时间更新:放弃本地删除,拉取远程最新数据并覆盖本地,提示用户"该数据已被更新,删除操作已取消"。
  3. 冲突场景3:本地修改 vs 远程修改

    • 基于version号:仅接受版本号更高的修改(避免旧数据覆盖新数据);
    • 若版本号相同(并发修改):按lastModifiedTime兜底,保留时间更新的修改,并合并非冲突字段(如对话内容修改 vs 对话标签修改,合并两者字段)。

5. 对于IndexDB中的"会话状态"这类高频读写的数据,你做了哪些性能优化?如何避免频繁读写导致的性能瓶颈?

参考答案

针对高频读写的"会话状态",核心优化思路是"减少IndexDB直接操作次数+优化读写链路",具体方案如下:

  1. 增加内存缓存层(Map暂存)

    • 初始化时,将所有会话状态从IndexDB加载到内存sessionStateCache: Map<string, StateData>(key为sessionId);
    • 高频读写操作(如切换会话、更新状态)优先操作内存Map,避免每次都调用IndexDB的get/put
    • 批量同步到IndexDB:设置"防抖同步"(如300ms防抖),或在"会话切换完成""页面隐藏"等时机,将内存中变更的状态批量写入IndexDB,减少IO次数。
  2. 优化IndexDB读写链路

    • 避免长事务:会话状态读写单独用短事务(仅操作sessionStates store),不与其他慢操作(如批量写入对话)共用事务,防止事务阻塞;
    • 禁用不必要的索引:sessionStates仅需按sessionId查询,无需额外索引,减少写入时的索引更新开销;
    • 使用put而非delete+add:更新状态时直接用put(存在则更新,不存在则新增),避免先删除再新增的两次IO。
  3. 控制数据规模

    • 过期数据清理:定期(如每周)删除"30天未活跃会话"的状态数据,减少IndexDB存储量;
    • 状态字段精简:会话状态仅保留核心字段(如currentStepisActivelastAccessTime),避免存储大体积数据(如完整对话列表),降低读写数据量。
  4. 监控与兜底

    • 监听IndexDB的onblocked事件:若其他页面占用数据库连接导致当前操作阻塞,降级为"直接操作内存+页面刷新后重试同步";
    • 性能监控:通过performance.mark记录"内存缓存命中耗时""IndexDB同步耗时",若某环节耗时超阈值(如同步超500ms),触发告警优化。

6. JSON序列化无法处理BigInt类型,但项目中如果有大数字ID(如超过2^53的ID),会导致精度丢失,你是如何解决这个问题的?

参考答案

针对BigInt精度丢失问题,项目采用"类型标记+自定义序列化/反序列化"方案,兼顾兼容性和易用性,具体实现如下:

一、核心解决方案:结构化标记转换

  1. 序列化阶段(JSON.stringify增强)

    自定义stringifyWithBigInt函数,通过replacer识别BigInt类型,转为包含类型标记的对象:

    javascript 复制代码
    function stringifyWithBigInt(data) {
      return JSON.stringify(data, (key, value) => {
        if (typeof value === 'bigint') {
          // 标记BigInt类型,value转为字符串避免精度丢失
          return { __type: 'BigInt', value: value.toString() };
        }
        return value;
      });
    }

    示例:12345678901234567890123n会被序列化为{"__type":"BigInt","value":"12345678901234567890123"}

  2. 反序列化阶段(JSON.parse增强)

    自定义parseWithBigInt函数,通过reviver识别标记对象,重建BigInt类型:

    javascript 复制代码
    function parseWithBigInt(jsonStr) {
      return JSON.parse(jsonStr, (key, value) => {
        if (value?.__type === 'BigInt' && typeof value.value === 'string') {
          return BigInt(value.value);
        }
        return value;
      });
    }

    示例:上述序列化结果反序列化后,会恢复为12345678901234567890123n

二、方案优化与兼容处理

  1. 与后端协同 :约定后端返回的BigInt类型字段(如userIdconversationId),在JSON中统一按"字符串+类型标记"格式返回,避免前端单独处理;
  2. 边界情况适配
    • 若BigInt嵌套在数组中(如{ ids: [123n, 456n] }),上述函数可递归处理,无需额外修改;
    • 若遇到未标记的Number类型大数字(如后端误传为Number),在反序列化后检测"数值是否超过2^53",若超过则提示后端修正数据格式;
  3. 性能优化 :对超大规模数据(如上万条含BigInt的列表),通过JSONStream等流式处理库,避免一次性序列化导致的内存占用过高。

7. 在覆盖"删除场景"的同步逻辑中,如何处理"本地删除后,远程又新增同ID数据"的情况?是否需要设计墓碑(Tombstone)机制?为什么?

参考答案

一、核心处理方案:必须设计墓碑(Tombstone)机制

针对"本地删除+远程新增同ID"的冲突,直接物理删除本地数据会导致"远程新增数据无法同步到本地"(因为本地无该ID数据,同步时会误判为"新数据",但实际ID已存在),因此必须通过墓碑机制实现"逻辑删除+状态追踪"。

二、墓碑机制的具体设计

  1. 数据层面:增加逻辑删除标记

    为所有可删除的数据(对话/题目/会话状态)增加isDeleted: boolean字段(默认false),本地删除时不执行store.delete(id),而是执行store.put({ id, isDeleted: true, ...otherFields }),保留数据的ID和元信息(如versionlastModifiedTime)。

  2. 同步层面:区分"逻辑删除"与"物理删除"

    • 本地同步删除操作时,上报数据的isDeleted: trueversion,后端标记该ID数据为"逻辑删除",但不物理删除;
    • 远程新增同ID数据时,后端会检测到"该ID已存在逻辑删除记录",返回"冲突提示"和"远程新增数据的完整信息";
    • 本地收到冲突后,按规则处理:
      • 若本地删除时间戳 < 远程新增时间戳:说明远程新增是"后操作",本地将isDeleted改为false,并覆盖为远程新增数据;
      • 若本地删除时间戳 > 远程新增时间戳:说明本地删除是"后操作",保留isDeleted: true,并请求后端删除远程新增数据。
  3. 墓碑清理策略

    • 定期清理:每周执行一次"墓碑清理任务",筛选"isDeleted: truelastModifiedTime距当前超30天"的数据,执行store.delete(id)物理删除,减少存储占用;
    • 同步确认后清理:若本地逻辑删除同步到后端,且后端返回"已确认删除",则立即物理删除本地墓碑(避免冗余)。

三、不设计墓碑机制的风险

  • 数据一致性丢失:本地物理删除后,远程新增同ID数据同步到本地时,会被当作"全新数据"插入,导致同一ID存在两条数据(本地旧删除记录已消失,无法冲突检测);
  • 同步链路断裂:后端无法区分"本地未同步过该ID"和"本地已删除该ID",可能重复推送已删除数据,导致前端数据冗余。

8. 对比LocalStorage,为什么选择IndexDB存储多类型数据?在实际使用中,IndexDB的哪些特性对你的场景至关重要?

参考答案

选择IndexDB而非LocalStorage的核心原因是LocalStorage的局限性无法满足"多类型数据+复杂操作"的需求,具体对比及IndexDB关键特性如下:

一、LocalStorage与IndexDB的核心差异对比

对比维度 LocalStorage IndexDB
存储容量 约5MB,容量固定且较小 无严格上限(依赖浏览器/设备,通常GB级)
数据类型 仅支持字符串(需手动JSON序列化) 支持复杂对象(Array/Object/Blob等)
操作方式 同步操作(阻塞UI线程,高频读写卡顿) 异步操作(非阻塞,适合大量数据)
查询能力 仅支持"键-值"精准查询(无索引) 支持索引、游标、范围查询(如按会话ID筛选)
事务支持 无事务,无法保证多操作原子性 支持事务,保证关联操作一致性

二、IndexDB对项目场景至关重要的特性

  1. 大容量存储:项目需缓存"对话记录(可能上万条)+题目库(上千题)+会话状态",总数据量易超5MB,LocalStorage容量不足会导致数据丢失,IndexDB的GB级容量可满足需求。

  2. 复杂查询能力

    • 场景需求:需"按会话ID查询所有对话""按题目类型筛选题目",LocalStorage需遍历所有数据手动过滤(性能差),IndexDB可通过索引快速定位(如conversations基于sessionId建索引,查询耗时从"秒级"降至"毫秒级")。
  3. 异步非阻塞:会话状态是高频读写数据(如每切换一次会话就更新状态),LocalStorage的同步操作会阻塞UI线程(导致页面卡顿),IndexDB的异步操作可避免此问题,保证用户交互流畅。

  4. 事务与数据一致性:项目需"同时写入对话和更新会话状态",LocalStorage无事务支持,易出现"对话写入成功、状态更新失败"的中间态,IndexDB的事务可确保两者要么同时成功,要么同时回滚,避免数据不一致。

  5. Blob类型支持:若后续扩展"缓存对话中的图片/文件",IndexDB可直接存储Blob类型,无需转为Base64字符串(LocalStorage需转码,增加数据体积和性能开销)。

9. 当数据量较大(如上万条对话记录)时,从IndexDB查询特定会话的历史数据,如何保证查询性能?你是如何设计索引的?

参考答案

针对"大数据量下查询特定会话的历史数据",核心优化思路是"精准索引设计+高效查询方式",具体方案如下:

一、索引设计:按"查询场景+数据特点"建立分层索引

  1. 核心索引:会话ID单字段索引(满足基础查询)

    • 场景需求:"查询某会话(如sessionId: 's123')的所有历史对话";

    • 索引创建:在conversations store中,基于sessionId字段建立单字段索引:

      javascript 复制代码
      // 初始化Object Store时创建索引
      const convStore = db.createObjectStore('conversations', { keyPath: ['sessionId', 'timestamp'] });
      convStore.createIndex('idx_sessionId', 'sessionId'); // 索引名:idx_sessionId,索引字段:sessionId
    • 查询方式:通过索引打开游标,直接筛选目标会话的对话:

      javascript 复制代码
      const transaction = db.transaction('conversations', 'readonly');
      const convStore = transaction.objectStore('conversations');
      const sessionIndex = convStore.index('idx_sessionId');
      // 仅查询sessionId为's123'的对话
      const cursorRequest = sessionIndex.openCursor(IDBKeyRange.only('s123'));
  2. 优化索引:会话ID+时间戳复合索引(满足排序+分页)

    • 场景需求:"查询某会话的历史对话,并按时间戳倒序(最新对话在前)+分页加载(每次加载20条)";

    • 复合索引创建:基于[sessionId, timestamp]建立复合索引(利用IndexDB的"复合索引按字段顺序排序"特性):

      javascript 复制代码
      convStore.createIndex('idx_sessionId_timestamp', ['sessionId', 'timestamp'], { descending: true });
      // descending: true:按时间戳倒序(最新对话在前)
    • 分页查询实现:通过游标范围查询,指定"起始时间戳"和"数量限制",避免全量加载:

      javascript 复制代码
      // 加载sessionId='s123'、时间戳<1716000000000的前20条对话(分页第二页)
      const range = IDBKeyRange.bound(
        ['s123', 1716000000000], // 下界:会话ID+起始时间戳
        ['s123', 0], // 上界:会话ID+最小时间戳
        false, // 不包含下界
        true  // 包含上界
      );
      const cursorRequest = sessionIndex.openCursor(range, 'prev'); // 'prev':倒序
      let count = 0;
      cursorRequest.onsuccess = (e) => {
        const cursor = e.target.result;
        if (cursor && count < 20) {
          pushToConversationList(cursor.value); // 加入结果列表
          count++;
          cursor.continue(); // 继续下一条
        }
      };

二、其他性能优化手段

  1. 避免全表扫描 :禁用"先获取所有对话再过滤"的方式(如convStore.getAll()),必须通过索引查询,减少IO次数(全表扫描需读取所有数据,索引查询仅读取目标会话数据)。

  2. 游标复用与批量处理 :使用cursor.continue()而非多次get(),减少数据库连接开销;若需处理大量数据(如导出对话),通过cursor.advance(100)批量读取(每次读100条),避免一次性加载导致内存溢出。

  3. 索引维护 :定期清理"过期对话"(如删除3个月前的记录),减少索引数据量;避免建立无关索引(如不为conversationContent(对话内容)建索引,因其不用于查询,仅增加写入时的索引更新开销)。

  4. 预加载与缓存:对"用户频繁访问的会话",将其历史对话缓存到内存Map中,后续查询优先读取内存(如用户连续查看某会话的对话,无需重复查询IndexDB),缓存失效时间设为1小时(平衡性能与数据新鲜度)。

10. 在测试同步逻辑时,你是如何模拟"网络中断""部分数据同步失败"等异常场景的?如何保证同步逻辑的健壮性?

参考答案

一、异常场景模拟方案:分层模拟(网络层+数据层+业务层)

  1. 网络中断模拟

    • 工具层面:使用Chrome DevTools的Network面板,切换到"Offline"模式,模拟完全断网;或使用Throttling功能(如"3G""高延迟"),模拟弱网环境下的同步超时。

    • 代码层面:封装fetch/axios请求拦截器,在测试环境中手动抛出NetworkError,或延迟返回响应(模拟超时):

      javascript 复制代码
      // 测试环境请求拦截器
      if (process.env.NODE_ENV === 'test') {
        axios.interceptors.request.use(config => {
          if (config.url.includes('/sync/data')) {
            // 模拟10%概率网络中断
            if (Math.random() < 0.1) {
              throw new Error('Network Error: Failed to connect');
            }
            // 模拟500ms延迟(弱网)
            return new Promise(resolve => setTimeout(() => resolve(config), 500));
          }
          return config;
        });
      }
  2. 部分数据同步失败模拟

    • 后端配合:在测试环境的同步接口中,增加"随机失败"逻辑(如对10%的同步数据返回{ code: 500, msg: '部分数据处理失败' }),并携带"失败数据ID列表"。
    • 前端构造:手动构造"格式错误的数据"(如缺失version字段的对话),调用同步接口,模拟后端因数据非法导致的部分失败。
  3. 并发冲突模拟

    • 双页面测试:打开两个浏览器标签页,同时对同一条对话进行修改,然后分别触发同步,模拟"本地A修改+本地B修改"的并发冲突;
    • 时间戳篡改:在测试代码中手动修改数据的lastModifiedTime(如将本地时间戳改为1小时前),模拟"本地旧数据同步到远程新数据"的冲突。

二、保证同步逻辑健壮性的核心手段

  1. 操作幂等设计

    • 为每笔同步操作生成唯一operationId(UUID),后端基于operationId去重(即使前端重试,也不会重复处理);
    • 同步接口支持"重复调用"(如本地重试时,后端若已处理过该operationId,直接返回成功结果,不重复执行逻辑)。
  2. 失败重试与退避策略

    • 基础重试:网络中断或部分失败时,触发"立即重试1次"(解决临时网络波动);
    • 退避重试:连续失败时,采用"指数退避"策略(如第1次重试间隔1s,第2次2s,第3次4s,最多重试5次),避免频繁请求压垮后端。
  3. 同步状态可视化与告警

    • 前端增加"同步状态指示器"(如右上角小图标:绿色=同步完成,黄色=同步中,红色=同步失败),让用户感知状态;
    • 同步失败(重试5次仍失败)时,触发前端告警(如弹窗提示"同步失败,请检查网络或联系客服"),并记录详细日志(失败operationId、数据内容、错误栈),便于排查问题。
  4. 数据备份与回滚

    • 同步前备份本地数据(如将待同步的数据复制到backup store),若同步失败且无法重试,可从备份恢复本地数据,避免同步过程中数据损坏;
    • 远程同步到本地时,先将数据写入"临时store",验证数据完整性(如字段校验、版本号检查)后,再覆盖正式store,避免脏数据写入。
  5. 单元测试与E2E测试覆盖

    • 单元测试:针对"冲突解决逻辑""序列化/反序列化"编写测试用例(如模拟10种冲突场景,断言处理结果符合预期);
    • E2E测试:使用Cypress/Puppeteer模拟"大数据量同步""网络中断恢复"等场景,验证端到端流程的正确性(如上万条对话同步后,查询性能仍达标)。
相关推荐
Java水解2 小时前
100道互联网大厂面试题+答案
java·后端·面试
ytadpole3 小时前
Java并发编程:从源码分析ThreadPoolExecutor 的三大核心机制
java·面试
z晨晨3 小时前
互联网大厂Java求职面试场景
java·redis·spring·面试·多线程·互联网大厂
小高0074 小时前
🎯GC 不是 “自动的” 吗?为什么还会内存泄漏?深度拆解 V8 回收机制
前端·javascript·面试
用户095 小时前
Swift Feature Flags:功能切换的应用价值
面试·swiftui·swift
yinke小琪5 小时前
凌晨2点,我删光了所有“精通多线程”的代码
java·后端·面试
道可到6 小时前
字节面试 Java 面试通关笔记 03| java 如何实现的动态加载(面试可复述版)
java·后端·面试
聪明的笨猪猪6 小时前
Spring Boot & Spring Cloud高频面试清单(含通俗理解+生活案例)
java·经验分享·笔记·面试