前端与Node不得不说的故事(数据库与多线程)

72 阅读14分钟

主要内容

承接上回,我们同事分享了node+koa进阶搭建,那么本篇文章就继续由浅入深继续探究!!!本文主要涵盖内容:

  1. 数据库与redis与锁不得不说的故事
  2. node多线程使用介绍

数据库、Redis、锁

1.数据库基本使用

简介:数据库是存储和管理数据的关键。我们将会学习如何使用数据库来存储和检索产品信息。

数据存储与管理

数据库(Database)是用于存储、管理和检索数据的系统。它允许用户以结构化的方式存储大量数据,并通过查询语言(如SQL)高效地访问和操作这些数据。数据库是现代信息系统的核心组件,广泛应用于各种场景,如网站、企业管理系统、移动应用等。

数据库类型

  • 数据库主要分为关系型数据库、非关系型数据库。
  • 常见的关系型数据库:MySQL、PostgreSQL、Oracle、SQL Serve
  • 常见的非关系型数据库:MongoDB(文档型)、Redis(键值型,数据存储在内存中)
Node使用mysql

1.安装:使用 npm 安装 mysql2 库:npm install mysql2

2.引入:在代码中引入 mysql2 库:const mysql = require('mysql2/promise');

3.配置:创建配置文件,使用连接池连接 MySQL 数据库,避免手动关闭连接的风险。

4.操作:编写通用的操作方法,例如查询、插入、更新和删除数据。

image.png

在mapper中编写增删改查类,然后实现。ex:

image.png

image.png 注意:删除时大家记得使用逻辑删,而非直接删数据

注意事项

1.使用时注意预热,否则第一次查询会比较慢 2.采用逻辑删除而不是物理删除,以便保留历史数据并避免意外数据丢失。 3.如果批量写入,一定要写事务,避免写一半挂了(本次没有涉及事务,大家可自行学习)。 4.实施严格的权限控制,所有操作一定要经过登录验证,权限验证,查询/修改/删除记得匹配,操作要有留痕,确保只有授权用户可以访问和操作数据库数据。 5.记录所有数据库操作,以便进行审计和追溯问题,保证数据安全性和可追溯性。

image.png

2.redis的基本使用

当接口速度慢,请求频繁,对性能有一定要求情况下,为了降低成本,仅仅是数据库可能就满足不了我们了,这时候我们可以对一些接口使用redis辅助我们进行数据的快速返回,降低服务器负载。

简介:

  • Redis 是一个高性能的 内存数据库,通常用于解决传统关系型数据库(如 MySQL、PostgreSQL)在高并发、低延迟场景下的性能瓶颈问题
  • 常规情况下,我们使用它的核心目的就是为了当作缓存使用,加速数据访问,减少对后端数据库的压力。
  • 例如,装备商城将商品详情缓存到 Redis 中,用户访问时直接从 Redis 获取数据,而不是查询数据库。
  • 优点:内存读写速度快,显著提升系统性能;减少数据库的查询负载。
Node使用redis

1.安装:使用 npm 安装 ioredis 库:npm install ioredis

2.引入:在代码中引入 ioredis 库:const Redis = require('ioredis');

3.配置:创建配置文件,使用连接池连接redis,避免手动关闭连接的风险。

4.缓存策略:我们这里选择 Cache Aside模式(不同的业务选择不同的思想,不要锁死自己),其核心思想为读数据时,先读缓存,如果有就返回。没有再读数据源,将数据放到缓存写数据时,先写数据源,然后让缓存失效。

redis和mysql一样也记得预热哦

// 创建 Redis 客户端实例
const redis = new Redis({
  host: '127.0.0.1', // 服务器 IP 地址
  port: 16379, // Redis 服务器端口
  password: 'root', // 如果有密码的话
  db: 0, // 选择数据库
  maxRetriesPerRequest: 3, // 最大重试次数
  enableReadyCheck: true, // 启用连接检查
  maxConnections: 10, // 最大连接数
});
// redis预热
async function warmUpRedisPool() {
  try {
    // 预热连接池
    await redis.ping();
    console.log('Redis 连接池预热完成');
  } catch (error) {
    console.error('Redis 连接池预热失败:', error);
  }
}
缓存模式介绍

1.Cache-Aside:

  • 读操作:应用先去查询缓存,命中则返回;没命中应用则会去数据库读取数据,写入缓存后返回。
  • 写操作:应用先更新数据库再删除缓存,然后返回。
  • 重点:只有增删,没有改

2.Read/Write-Through

  • 核心思想:应用需要操作数据时只与缓存组件进行交互;缓存里的数据不会过期。(全都从缓存走,写也是缓存写数据库,前端所有来源都是缓存)
  • 核心思想:应用需要操作数据时只与缓存组件进行交互;缓存里的数据不会过期。
  • 存在的问题:1.因为应用操作数据时只与缓存组件交互,相对于Cache-Aside而言数据不一致的概率要低一些。 2.因为此模式下缓存没有过期时间,所以缓存的使用量会非常大。

3.Write-Back

  • Write-Back也称Write-Behind,这种模式是承接Write-Through的,在对数据进行数据持久化存储回
  • 写时一般采用异步回写,也可以间隔一定时间后批量回写
  • 适用场景:读少写多
  • 存在的问题:异步或间隔一定时间的批量回写会导致数据延迟或数据丢失的情形出现。
/**Cache-Aside 模式**/

// 读数据ex:
// 这种模式的优点是简单易实现,并且能够有效地减少对数库的访问次数。
// 缺点是,如果 Redis 中的数据失效,会导致应用程序访问数据库,从而影响性能。
// 此外,如果数据库中的数据更新了,Redis 中的缓存数据可能不会及时更新,从而导致数据不一致。
// redis操作
exports.getIdFn = async (ctx, next) => {
  const id = ctx.params.id;
  // 1. 尝试从 Redis 中获取数据
  const cachedData = await redis.get(`${id}`);
  let fileInfo;
  //如果缓存中有,使用缓存数据,缓存中没有,使用sql
  if (cachedData) {
    console.log('使用缓存数据');
    fileInfo = JSON.parse(cachedData);
  } else {
    console.log('使用sql数据');
    fileInfo = await fileModel.findById(id);
    // 3. 将数据存入 Redis,设置过期时间(例如 60 秒)--fileInfo不存在的话是不会走redis的
    fileInfo && (await redis.set(`${id}`, JSON.stringify(fileInfo), 'EX', 60));
  }
  if (fileInfo) {
    ctx.body = res.success(fileInfo);
  } else {
    ctx.body = res.error(404, '未查询到当前文件');
  }
};
/**Cache-Aside 模式**/

// 写数据ex:
// 写数据时,不做更新操作,只写数据库,不操作redis。
// 异步写入
exports.setFn = async (ctx, next) => {
  const file = ctx.request.files.file;
  const { name, job_id } = ctx.request.body; // 文件信息 ctx上下文 拿到信息  服务器的文件夹
  //这里注意,如果文件名相同会导致文件覆盖,所以这里加上uuid保证文件名唯一
  const docsPath = path.resolve(__dirname, `../../articles/${uuid() + name}`); // 目标路径
  const { code = 0, data } = await writeArticleDetail(file, docsPath);
  if (code === 0) {
    //此时开始写数据,因为是新建所以不考虑双写,同时基于Cache Aside策略,只有读才会进行写数据,所以redis无操作
    const id = await fileModel.create({ name, job_id, url: docsPath, create_date: new Date().getTime() });
    ctx.body = res.success(`新建文件成功`);
  } else {
    ctx.body = res.error(code, data);
  }
};
关于双写
  • 当你使用了redis就会有数据不一致问题
  • 双写在业界目前争议比较大,并没有完美的解决方案,只能依赖具体的业务场景来确保一致性(如分布式锁等)。
  • 本分享采用的是相对通用的普通双删策略,作为Cache Aside写入策略的兜底方案。
  • 由于网络的不确定性,无法保证强一致性,对于我们来讲目前双删策略足以支撑我们大部分场景(这里没有考虑数据库/redis更新失败的问题,如果涉及到更新失败,事务回滚,要考虑到的问题就更多了)。

Cache Aside可能出现的问题: image.png

在写入数据时,A请求数据,我们删除了缓存,B同时请求,缓存失效,查询数据库,查询sql执行快,优先返回,更新缓存为123,此时数据库数据跟新完成456,但是缓存目前为123,造成数据不一致

双删可能出现的问题:

image.png 当数据更新时,首先从 Redis 缓存中删除,然后操作数据库更新数据。数据库更新完成后,再次删除Redis缓存。

// 代码实现
exports.updateFn = async (ctx, next) => {
  try {
    const { name, id } = JSON.parse(ctx.request.body);
    if (!name) {
      ctx.body = res.error(500, '请输入完整的文件信息');
      return;
    }
    //此时开始写数据,双写的情况来临了,基于Cache Aside策略,不去写数据,普通双删
    //先删缓存
    await redis.del(`${id}`);
    const affectedRows = await fileModel.update(id, { name });
    //数据库更新完成,继续删缓存
    await redis.del(`${id}`);
    if (affectedRows > 0) {
      ctx.body = res.success(`更新成功`);
    } else {
      ctx.body = res.error(404, '未查询到当前文件');
    }
  } catch (err) {
    ctx.body = res.error(500, `更新文件${id}信息失败`);
  }
};

延时双删可能出现的问题:

image.png

首先,删除缓存,数据库更新,考虑并发操作,等待数据库更新完成后,延迟几秒后,在进行删除缓存操作。 存在的问题:极端场景下依然会有数据不一致性。在等待删除过程中,还是会有大量用户读取了脏数据。

exports.delFn = async (ctx, next) => {
  try {
    const { id } = JSON.parse(ctx.request.body);
    //此时开始写数据,双写的情况来临了,基于Cache Aside策略,不去写数据,走延时双删
    //先删缓存
    await redis.del(`${id}`);
    //状态变动
    const affectedRows = await fileModel.deleteId(id);
    //继续删缓存
    setTimeout(async () => {
      await redis.del(`${id}`);
    }, 1000);
    if (affectedRows > 0) {
      ctx.body = res.success(`删除成功`);
    } else {
      ctx.body = res.error(404, '未查询到当前文件');
    }
  } catch (err) {
    console.log(err);
    ctx.body = res.error(500, '删除文件失败');
  }
};

3.锁的基本使用

从上面读操作的场景中,我们可以发现一个问题,我们使用redis,核心目的就是为了降低并发时频繁查库的,但是此入1w个并发过来了,此时redis中没有数据,那1w条并发就都去查库了,redis没有解决我们的核心问题,所以这里,我们就需要加锁来补足一下

互斥锁

在 一般服务中,大多数情况下,互斥锁(Mutex)是更合适的选择

- 特点: 阻塞等待:当一个线程获取锁时,其他线程会被阻塞,直到锁被释放。 上下文切换:阻塞等待会导致上下文切换,可能会增加系统开销。

  • 适用场景:适用于临界区较长的操作,或者在等待锁的时间较长的情况下。
  • 优点:1. 简单易用:大多数编程语言和库都提供了互斥锁的实现。2.避免忙等待:不会浪费 CPU 资源在忙等待上。
  • 缺点: 上下文切换开销:阻塞等待会导致上下文切换,可能会增加系统开销。。
互斥锁的使用

异步锁 async-mutex 是一个轻量级的 JavaScript 库,用于实现异步操作中的互斥锁。它可以确保一次只有一个异步操作可以访问共享资源。在单服务部署的情况下,async-mutex 是一个有效的工具。然而,对于大规模部署需要多服务协作,则需要使用分布式锁来保证数据的一致性。

  1. 安装:安装 npm install async-mutex
  2. 引入:const { Mutex } = require('async-mutex');
  3. 创建:const mutex = new Mutex();

image.png

1.基本使用

此时,我们对读写操作进行一番优化:

const { Mutex } = require('async-mutex');
const mutex = new Mutex();
// redis读操作
exports.getIdFn = async (ctx, next) => {
  const id = ctx.params.id;
  // 1. 尝试从 Redis 中获取数据
  const cachedData = await redis.get(`${id}`);
  let fileInfo;
  //如果缓存中有,使用缓存数据,缓存中没有,使用sql
  if (cachedData) {
    console.log('使用缓存数据');
    fileInfo = JSON.parse(cachedData);
  } else {
    console.log('使用sql数据');
    // 2. 获取锁
    const release = await mutex.acquire();
    try {
      // 3. 再次检查缓存(防止在等待锁的过程中数据已被缓存)
      const cachedDataAgain = await redis.get(`${id}`);
      if (cachedDataAgain) {
        console.log('使用缓存数据(加锁后再次检查)');
        fileInfo = JSON.parse(cachedDataAgain);
      } else {
        // 4. 获取数据库数据
        fileInfo = await fileModel.findById(id);
        // 5. 将数据存入 Redis,设置过期时间(例如 60 秒)
        if (fileInfo) {
          await redis.set(`${id}`, JSON.stringify(fileInfo), 'EX', 60);
        }
      }
    } finally {
      // 6. 释放锁
      release();
    }
  }
  // 7. 返回结果
  if (fileInfo) {
    ctx.body = res.success(fileInfo);
  } else {
    ctx.body = res.error(404, '未查询到当前文件');
  }
};
// redis写操作
exports.updateFn = async (ctx, next) => {
  // 1. 获取锁
  const release = await mutex.acquire();
  try {
    const { name, id } = JSON.parse(ctx.request.body);
    if (!name) {
      ctx.body = res.error(500, '请输入完整的文件信息');
      return;
    }
    //先删缓存
    await redis.del(`${id}`);
    const affectedRows = await fileModel.update(id, { name });
    //数据库更新完成,继续删缓存
    await redis.del(`${id}`);
    if (affectedRows > 0) {
      ctx.body = res.success(`更新成功`);
    } else {
      ctx.body = res.error(404, '未查询到当前文件');
    }
  } catch (err) {
    ctx.body = res.error(500, `更新文件${id}信息失败`);
  } finally {
    // 5. 释放锁
    release();
  }
};
2.独立锁

此时产生一个问题,在写入时不同的数据都被同一把锁拦住了,我们使用独立的互斥锁进行优化:

  • 独立锁是针对某个特定数据资源进行加锁,而不是整个对象。每个数据资源都拥有自己的锁,可以独立控制。
  • 独立锁的优点是它们不会阻塞其他对非锁定数据资源的访问,提高了并发性。
const { Mutex } = require('async-mutex');
// 创建锁管理器
const lockManager = new Map();
// 获取或创建锁
function getLock(id) {
  if (!lockManager.has(id)) {
    lockManager.set(id, new Mutex());
  }
  return lockManager.get(id);
}

// 读操作
exports.getIdFn = async (ctx, next) => {
  const id = ctx.params.id;
  // 1. 尝试从 Redis 中获取数据
  const cachedData = await redis.get(`${id}`);
  let fileInfo;
  //如果缓存中有,使用缓存数据,缓存中没有,使用sql
  if (cachedData) {
    console.log('使用缓存数据');
    fileInfo = JSON.parse(cachedData);
  } else {
    console.log('使用sql数据');
    // 2. 获取独立锁,定义释放函数
    const lock = getLock(id);
    const release = await lock.acquire();
    try {
      // 3. 再次检查缓存(防止在等待锁的过程中数据已被缓存)
      const cachedDataAgain = await redis.get(`${id}`);
      if (cachedDataAgain) {
        console.log('使用缓存数据(加锁后再次检查)');
        fileInfo = JSON.parse(cachedDataAgain);
      } else {
        // 4. 获取数据库数据
        fileInfo = await fileModel.findById(id);
        // 5. 将数据存入 Redis,设置过期时间(例如 60 秒)
        if (fileInfo) {
          await redis.set(`${id}`, JSON.stringify(fileInfo), 'EX', 60);
        }
      }
    } finally {
      // 6. 释放锁
      release();
    }
  }
  // 7. 返回结果
  if (fileInfo) {
    ctx.body = res.success(fileInfo);
  } else {
    ctx.body = res.error(404, '未查询到当前文件');
  }
};
// 写
exports.updateFn = async (ctx, next) => {
  const { name = null, id } = JSON.parse(ctx.request.body);
  // 2. 获取独立锁,定义释放函数
  const lock = getLock(id);
  const release = await lock.acquire();
  try {
    if (!name) {
      ctx.body = res.error(500, '请输入完整的文件信息');
      return;
    }  
    //先删缓存
    await redis.del(`${id}`);
    const affectedRows = await fileModel.update(id, { name });
    //数据库更新完成,继续删缓存
    await redis.del(`${id}`);
    if (affectedRows > 0) {
      ctx.body = res.success(`更新成功`);
    } else {
      ctx.body = res.error(404, '未查询到当前文件');
    }
  } catch (err) {
    ctx.body = res.error(500, `更新文件${id}信息失败`);
  } finally {
    // 5. 释放锁
    release();
  }
};
3.读写锁

读写锁是一种同步机制,允许多个线程同时读取共享资源,但一次只能有一个线程写入资源。这在读操作比写操作更频繁的情况下非常有用。 读写锁有三种主要类型:

  • 共享读锁:多个线程可以同时获取共享读锁,以便读取共享资源。
  • 独占写锁:一次只能有一个线程获取独占写锁,以便写入共享资源。
  • 升级锁:线程可以先获取共享读锁,然后升级为独占写锁,以进行写入操作。这可以提高效率,因为线程不需要释放读锁并重新获取写锁。
const { Mutex } = require('async-mutex');
// 创建锁管理器
const lockManager = new Map();
// 获取或创建读写锁
function getReadWriteLock(id) {
  if (!lockManager.has(id)) {
    lockManager.set(id, {
      readLock: new Mutex(),
      writeLock: new Mutex(),
      readerCount: 0, // 当前读操作的数量
    });
  }
  return lockManager.get(id);
}

// 读操作
exports.getIdFn = async (ctx, next) => {
  const id = ctx.params.id;
  // 1. 尝试从 Redis 中获取数据
  const cachedData = await redis.get(`${id}`);
  let fileInfo;
  //如果缓存中有,使用缓存数据,缓存中没有,使用sql
  if (cachedData) {
    console.log('使用缓存数据');
    fileInfo = JSON.parse(cachedData);
  } else {
    console.log('使用sql数据');
    // 2. 获取读锁
    const lock = getReadWriteLock(id);
    const releaseReadLock = await lock.readLock.acquire();
    try {
      // 3. 增加读操作计数
      lock.readerCount++;
      // 4. 再次检查缓存(防止在等待锁的过程中数据已被缓存)
      const cachedDataAgain = await redis.get(`${id}`);
      if (cachedDataAgain) {
        fileInfo = JSON.parse(cachedDataAgain);
      } else {
        // 5. 获取数据库数据
        fileInfo = await fileModel.findById(id);
        // 6. 将数据存入 Redis,设置过期时间(例如 60 秒)
        if (fileInfo) {
          await redis.set(`${id}`, JSON.stringify(fileInfo), 'EX', 60);
        }
      }
    } finally {
      // 7. 减少读操作计数
      lock.readerCount--;
      // 8. 释放锁
      releaseReadLock();
    }
  }
  // 7. 返回结果
  if (fileInfo) {
    ctx.body = res.success(fileInfo);
  } else {
    ctx.body = res.error(404, '未查询到当前文件');
  }
};
// 写操作
exports.updateFn = async (ctx, next) => {
  const { name = null, id } = JSON.parse(ctx.request.body);
  // 1. 获取写锁
  const lock = getReadWriteLock(id);
  const releaseWriteLock = await lock.writeLock.acquire();
  try {
    // 2. 等待所有读操作完成
     while (lock.readerCount > 0) {
       await new Promise((resolve) => setTimeout(resolve, 10)); // 等待 10 毫秒
     }
    if (!name) {
      ctx.body = res.error(500, '请输入完整的文件信息');
      return;
    }
    //4.先删缓存
    await redis.del(`${id}`);
    //5.更新数据库
    const affectedRows = await fileModel.update(id, { name });
    //6.数据库更新完成,继续删缓存
    await redis.del(`${id}`);
    // 7.返回结果
    if (affectedRows > 0) {
      ctx.body = res.success(`更新成功`);
    } else {
      ctx.body = res.error(404, '未查询到当前文件');
    }
  } catch (err) {
    ctx.body = res.error(500, `更新文件${id}信息失败`);
  } finally {
    // 8. 释放写锁
    releaseWriteLock();
  }
};

多线程介绍

众所周知node非常擅长处理I/O型密集任务,不擅长cpu密集型任务,在node中,对于io密集操作,是不需要我们主动管理的,当遇到io任务时,node基于事件循环将任务丢给底层c去处理,当处理完成再基于回调再pool中执行,但是对于cpu密集型任务,因为node是基于v8引擎的,v8是单线程引擎,就导致对于计算任务,同步任务,不处理完当前任务就无法执行下一个任务,所以对于cpu密集任务,大部分都不是node开发。为了解决这些问题,node引入了子进程,集群,子线程等多种解决方案。

多线程介绍

我们知道,如果在单线程中运行长时间任务,那么应用程序可能会被阻塞。 这意味着其他任务将无法运行,直到第一个任务完成。

Node.js 多线程适合哪些场景? 例如,在处理图像、数据分析和加密算法等任务时,多线程可以显著提高性能和响应速度。

注意事项

  • 线程开销:创建线程有一定开销,避免频繁创建和销毁线程(node开启多线程耗费资源较大,因为每一个线程都要启动一个v8引擎(相对与java的1-2mb,node差不多要10-30mb),这也是在多线程方面被人诟病的一点)。
  • 线程安全:多线程操作共享数据时,注意线程安全问题。
  • 调试:多线程调试较为复杂,可以使用日志或调试工具辅助。
  • 资源限制:线程数量受系统资源限制,避免创建过多线程。
Worker Threads工作线程
  • 简介: Node.js 从 v10.5.0 开始引入了 worker_threads 模块,允许创建真正的多线程
  • 特点: 1.每个工作线程有自己的 V8 实例和事件循环。 2.适合 CPU 密集型任务。 3.线程之间通过消息传递通信。
  • 核心概念: 1.主线程:Node.js 的主线程,负责创建和管理工作线程。 2.工作线程:独立的线程,可以执行 CPU 密集型任务。 3.消息传递:主线程和工作线程之间通过 postMessage 和 on('message') 进行通信。
这里我们可以利用线程池来进行多线程管理;

手动搭建一个线程池:也比较简单,大家可以自己尝试

使用workpool进行线程池管理

// threads-config.js
const workerpool = require('workerpool');
const { cpus } = require('os');
// 永远不会阻塞主线程,并且可以处理CPU密集型任务
console.log(cpus().length);
const workPool = workerpool.pool({ minWorkers: 4, maxWorkers: cpus().length, workerType: 'auto' });
module.exports = workPool;

// serveice.js
exports.sumFn = async (ctx) => {
  try {
    //定义任务为sum求和任务
    const i = 2000000000;
    const data = await workPool.exec(sumTask, [i]);
    console.log(data);
    ctx.body = res.success(data);
  } catch (e) {
    console.log(e);
  }
};

Cluster(集群)
  • 共享端口:Node.js 提供了 cluster 模块,以创建多个工作进程来共享同一个端口,充分利用多核 CPU。
  • 独立实例:每个工作进程拥有自己的 V8 实例和事件循环,实现隔离和并发执行,适合 CPU 密集型任务。
  • 消息传递:工作进程之间通过消息传递进行通信,例如进程间事件或数据共享。
Child Processes(子进程)
  • child_process:Node.js 提供了 child_process 模块,可以创建子进程来执行外部命令或其他 Node.js 脚本。 。
  • 独立实例:每个子进程是独立的进程,有自己的内存空间。
  • 消息传递:进程之间通过 IPC(进程间通信)通信。

以上几种方式并不是独立的,也是可以结合的常见的有Worker Threads和集群模块,实现多进程多线程调度

总结

本次分享我们介绍了,如何使用mysql(增删改查)以及如何降低sql压力,使用redis,同时为了保证数据一致性,讲了双写操作,最后为了确保不被干扰,讲了一些锁的使用(乐观锁大家可以自己了解一下,也是很实用的锁),最后为了处理密集cpu,介绍了一下多线程的基本使用,希望大家可以得到自己的收获。