使用 Cassandra、Jotai 与 Shadcn UI 构建高吞吐量实时日志流的虚拟化前端实践


渲染一个持续不断的、每秒可能新增上千条的日志流,同时要保证浏览器界面流畅不卡顿,这是一个相当棘手的挑战。传统的做法,即简单地将新日志追加到DOM列表中,当节点数量超过几千个时,会导致内存占用飙升和UI线程阻塞,最终页面崩溃。在最近一个项目中,我们面临的正是这样一个需求:构建一个高性能的实时监控面板。

最初的构想很直接:用一个React state数组存储日志,通过WebSocket接收新数据,然后setLogs(prev => [...newLogs, ...prev])。这个方案在演示环境中运行良好,但在接入真实数据流后,不到一分钟,页面就变得无法响应。这迫使我们必须从数据存储、后端接口、前端状态管理到渲染机制进行一次彻底的重新设计。

技术选型决策:一套应对极端场景的组合拳

要解决这个问题,必须在技术栈的每一层都做出正确的选择。

  1. 后端存储: Apache Cassandra
    日志数据是典型的时间序列数据,写入量远大于读取量,且几乎没有更新操作。Cassandra的无主架构和基于LSM树的存储引擎为高吞t量写入提供了完美的保障。更关键的是它的数据模型,我们可以通过精心设计的PRIMARY KEY来高效查询时间范围内的日志,这对于实现日志回溯和分页至关重要。

  2. UI组件库: Shadcn UI
    我们不需要一个重量级、样式固化的UI框架。Shadcn UI提供的是一系列无样式的、可通过组合构建复杂UI的基础组件。它的ScrollAreaTable为我们提供了构建虚拟化列表所需的、可靠且无障碍的“骨架”,而无需引入额外的样式冲突或性能开销。

  3. 前端状态管理: Jotai
    虚拟化列表的内部状态相当复杂:总数据、滚动偏移、视口尺寸、每项的估算高度、已加载的数据窗口等等。如果使用useState,会导致状态散乱,逻辑难以追踪。如果使用Redux这类集中式存储,任何微小的状态更新(如滚动偏移)都可能触发整个组件树的不必要重渲染。Jotai的原子化(atomic)状态模型在这里堪称绝配。每个状态都是一个独立的atom,组件可以精确订阅自己依赖的atom,只有当该atom变化时才会重渲染。这使得管理复杂、高频更新的状态变得极其高效。

  4. 测试库: React Testing Library (RTL)
    对于虚拟化这种重逻辑的UI组件,我们关心的不是其内部状态变量的值,而是它在用户交互下的行为是否正确。用户滚动时,DOM中是否只渲染了可见窗口内的数据?滚动到底部时,是否正确触发了加载更多的逻辑?RTL鼓励我们编写这种面向用户行为的测试,确保组件的健壮性。

第一步:Cassandra数据建模与后端API

在真实项目中,一个常见的错误是没有为查询模式优化数据模型。对于日志系统,最常见的查询是“获取某个时间段内的最新N条日志”。

我们的Cassandra表结构设计如下:

-- astra.cql
CREATE TABLE IF NOT EXISTS logs.realtime_logs (
    app_id text,
    time_bucket text, // e.g., '2023-11-20'
    log_id timeuuid,
    level text,
    message text,
    metadata map<text, text>,
    PRIMARY KEY ((app_id, time_bucket), log_id)
) WITH CLUSTERING ORDER BY (log_id DESC);

这里的关键在于PRIMARY KEY的设计:

  • Partition Key (app_id, time_bucket): 我们按应用ID和日期对数据进行分区。这确保了同一应用一天内的所有日志都落在同一个分区(或少数几个分区)内,便于按天查询。
  • Clustering Key log_id: 使用timeuuid作为日志ID,并按其DESC(降序)排列。这使得获取最新的日志成为一个极其高效的操作,Cassandra只需读取每个分区顶部的少数几行数据即可。

基于这个模型,我们用Node.js和官方cassandra-driver库构建后端API。一个常见的坑是,对于海量数据的分页,绝不能使用OFFSET,而应使用Cassandra提供的pagingState

// server/routes/logs.js
const express = require('express');
const cassandra = require('cassandra-driver');
const router = express.Router();

// 生产环境中,配置应从环境变量或配置文件中读取
const client = new cassandra.Client({
  cloud: {
    secureConnectBundle: './secure-connect-bundle.zip',
  },
  credentials: {
    username: 'YOUR_USERNAME',
    password: 'YOUR_PASSWORD',
  },
  keyspace: 'logs',
});

// 日志记录器,实际项目中应使用Winston等库
const logger = {
  error: console.error,
  info: console.log,
};

router.get('/:appId', async (req, res) => {
  const { appId } = req.params;
  // pageState是Cassandra用于高效分页的token
  const { pageState, limit = 100 } = req.query; 

  // 简单地使用当天的time_bucket,真实场景可能需要跨日期查询
  const today = new Date().toISOString().split('T')[0];

  const query = 'SELECT log_id, level, message, metadata FROM realtime_logs WHERE app_id = ? AND time_bucket = ?';
  const params = [appId, today];
  const options = { 
    prepare: true, 
    fetchSize: parseInt(limit, 10),
    pageState: pageState ? Buffer.from(pageState, 'hex') : undefined,
  };

  try {
    const result = await client.execute(query, params, options);
    res.json({
      logs: result.rows,
      // 将下一页的pageState返回给前端
      nextPageState: result.pageState ? result.pageState.toString('hex') : null,
    });
  } catch (error) {
    logger.error(`Failed to fetch logs for app [${appId}]:`, error);
    res.status(500).json({ message: 'Internal server error while fetching logs.' });
  }
});

module.exports = router;

这个API实现了基于pagingState的稳定分页,这是构建无限滚动前端的基础。

第二步:Jotai原子化状态设计

现在进入前端的核心。我们将虚拟化列表所需的所有状态都分解为独立的Jotai atom

graph TD
    A[user scrolls] --> B(onScrollHandler);
    B --> C(scrollOffsetAtom);
    
    subgraph Derived State
        D(startIndexAtom) -- reads --> C;
        E(endIndexAtom) -- reads --> C;
        F(visibleLogsAtom) -- reads --> D;
        F -- reads --> E;
        F -- reads --> G[allLogsAtom];
    end
    
    H(React Component) -- renders --> I{DOM};
    H -- subscribes to --> F;

    J[API Fetch] --> K(update allLogsAtom);
    K --> G;

这种数据流非常清晰,每个atom只负责一件事。

// src/atoms/logAtoms.ts
import { atom } from 'jotai';

// 定义日志条目的类型
export interface LogEntry {
  log_id: string;
  level: string;
  message: string;
  metadata: Record<string, string>;
}

// -- 核心状态原子 --

// 存储所有已加载的日志
export const allLogsAtom = atom<LogEntry[]>([]);
// 服务器下一页的token
export const nextPageStateAtom = atom<string | null>(null);
// 加载状态
export const isLoadingAtom = atom<boolean>(false);
// 滚动容器的引用
export const scrollContainerRefAtom = atom<React.RefObject<HTMLDivElement> | null>(null);

// -- UI交互状态原子 --

// 滚动偏移量,由onScroll事件驱动
export const scrollOffsetAtom = atom<number>(0);
// 视口高度
export const viewportHeightAtom = atom<number>(0);

// -- 配置与估算值 --

// 每行日志的估算高度,这是虚拟化的关键
export const itemHeightAtom = atom<number>(25); // 估算一个初始值 (px)
// 渲染缓冲区大小,用于在视口上下方预渲染一些项目,避免滚动时出现白屏
export const overscanCountAtom = atom<number>(10);

// -- 派生原子 (Derived Atoms) --
// 这部分是Jotai的精髓,它们是无状态的,其值由其他atom计算而来

// 计算总内容高度
export const totalContentHeightAtom = atom((get) => get(allLogsAtom).length * get(itemHeightAtom));

// 计算当前渲染窗口的起始索引
export const startIndexAtom = atom((get) => {
  const scrollOffset = get(scrollOffsetAtom);
  const itemHeight = get(itemHeightAtom);
  const overscan = get(overscanCountAtom);
  const start = Math.floor(scrollOffset / itemHeight);
  return Math.max(0, start - overscan);
});

// 计算当前渲染窗口的结束索引
export const endIndexAtom = atom((get) => {
  const viewportHeight = get(viewportHeightAtom);
  const itemHeight = get(itemHeightAtom);
  const overscan = get(overscanCountAtom);
  const start = get(startIndexAtom);
  const visibleItemCount = Math.ceil(viewportHeight / itemHeight);
  return start + visibleItemCount + overscan * 2;
});

// 根据起止索引,从总日志中切片出需要渲染的日志
export const visibleLogsAtom = atom((get) => {
  const allLogs = get(allLogsAtom);
  const startIndex = get(startIndexAtom);
  const endIndex = get(endIndexAtom);
  // 使用slice创建新数组,只包含可见项
  return allLogs.slice(startIndex, Math.min(endIndex, allLogs.length));
});

第三步:构建虚拟化React组件

有了原子化的状态,组件的实现就变得非常直观。我们使用Shadcn UI的ScrollArea作为容器,并在其内部实现虚拟化逻辑。

// src/components/VirtualizedLogViewer.tsx
import React, { useRef, useEffect, useCallback } from 'react';
import { useAtom, useSetAtom } from 'jotai';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table'; // 来自 Shadcn UI
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area';
import { useResizeObserver } from '@/hooks/useResizeObserver'; // 一个自定义hook

import {
  allLogsAtom,
  nextPageStateAtom,
  isLoadingAtom,
  scrollOffsetAtom,
  viewportHeightAtom,
  totalContentHeightAtom,
  startIndexAtom,
  visibleLogsAtom,
  LogEntry,
} from '@/atoms/logAtoms';

const APP_ID = 'your-app-id'; // 在真实应用中,这应是动态的

export const VirtualizedLogViewer = () => {
  const [allLogs, setAllLogs] = useAtom(allLogsAtom);
  const [nextPageState, setNextPageState] = useAtom(nextPageStateAtom);
  const [isLoading, setIsLoading] = useAtom(isLoadingAtom);
  
  const setScrollOffset = useSetAtom(scrollOffsetAtom);
  const setViewportHeight = useSetAtom(viewportHeightAtom);

  const totalContentHeight = useAtom(totalContentHeightAtom)[0];
  const startIndex = useAtom(startIndexAtom)[0];
  const visibleLogs = useAtom(visibleLogsAtom)[0];

  const scrollContainerRef = useRef<HTMLDivElement>(null);

  // 加载更多数据的函数
  const fetchMoreLogs = useCallback(async () => {
    if (isLoading || !nextPageState) return;

    setIsLoading(true);
    try {
      const response = await fetch(`/api/logs/${APP_ID}?limit=100&pageState=${nextPageState}`);
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }
      const data = await response.json();
      setAllLogs((prev) => [...prev, ...data.logs]);
      setNextPageState(data.nextPageState);
    } catch (error) {
      console.error("Failed to fetch more logs:", error);
    } finally {
      setIsLoading(false);
    }
  }, [isLoading, nextPageState, setAllLogs, setNextPageState, setIsLoading]);

  // 组件挂载时加载初始数据
  useEffect(() => {
    const initialFetch = async () => {
       setIsLoading(true);
       try {
        const response = await fetch(`/api/logs/${APP_ID}?limit=100`);
        const data = await response.json();
        setAllLogs(data.logs);
        setNextPageState(data.nextPageState);
       } catch (error) {
        console.error("Failed to fetch initial logs:", error);
       } finally {
         setIsLoading(false);
       }
    };
    initialFetch();
  }, [setAllLogs, setNextPageState, setIsLoading]);

  // 滚动事件处理
  const handleScroll = (event: React.UIEvent<HTMLDivElement>) => {
    const scrollTop = event.currentTarget.scrollTop;
    setScrollOffset(scrollTop);

    // 检查是否滚动到底部附近以触发加载更多
    if (
      event.currentTarget.scrollHeight - scrollTop - event.currentTarget.clientHeight < 200 &&
      !isLoading &&
      nextPageState
    ) {
      fetchMoreLogs();
    }
  };

  // 使用 ResizeObserver 动态更新视口高度
  const onResize = useCallback((target: HTMLDivElement) => {
    setViewportHeight(target.clientHeight);
  }, [setViewportHeight]);

  useResizeObserver(scrollContainerRef, onResize);

  return (
    <ScrollArea
      ref={scrollContainerRef}
      className="h-[80vh] w-full border rounded-md"
      onScroll={handleScroll}
    >
      <div style={{ height: `${totalContentHeight}px`, position: 'relative' }}>
        <Table
          style={{
            position: 'absolute',
            top: `${startIndex * 25}px`, // 25是估算的itemHeight
            width: '100%',
          }}
        >
          <TableHeader>
            <TableRow>
              <TableHead className="w-[180px]">Timestamp</TableHead>
              <TableHead className="w-[80px]">Level</TableHead>
              <TableHead>Message</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {visibleLogs.map((log) => (
              <LogItem key={log.log_id} log={log} />
            ))}
          </TableBody>
        </Table>
        {isLoading && <div className="text-center p-4">Loading more logs...</div>}
      </div>
      <ScrollBar orientation="vertical" />
    </ScrollArea>
  );
};

// 单独的LogItem组件,以优化重渲染
const LogItem = React.memo(({ log }: { log: LogEntry }) => (
  <TableRow>
    <TableCell className="font-mono text-xs">{new Date(parseInt(log.log_id.substring(0, 8), 16) * 1000).toISOString()}</TableCell>
    <TableCell>{log.level}</TableCell>
    <TableCell className="font-mono text-sm">{log.message}</TableCell>
  </TableRow>
));

这里的核心是CSS position 的运用:

  • 外层div的高度由totalContentHeight撑开,这为浏览器提供了正确的滚动条。
  • 内部的Table使用position: absolute,其top值根据startIndex动态计算,从而将渲染的DOM节点精确地“移动”到视口中。

第四步:使用React Testing Library验证行为

虚拟化组件的逻辑很容易出错。一个好的测试套件是必不可少的。

// src/components/VirtualizedLogViewer.test.tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { Provider } from 'jotai';
import { VirtualizedLogViewer } from './VirtualizedLogViewer';
import { allLogsAtom, nextPageStateAtom } from '@/atoms/logAtoms';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';

// 生成模拟日志数据
const generateMockLogs = (count: number, offset = 0) =>
  Array.from({ length: count }, (_, i) => ({
    log_id: `0000000${i + offset}-...`, // 简化版timeuuid
    level: 'INFO',
    message: `Log message ${i + offset}`,
    metadata: {},
  }));

// 使用 MSW (Mock Service Worker) 拦截API请求
const server = setupServer(
  http.get('/api/logs/your-app-id', ({ request }) => {
    const url = new URL(request.url);
    const pageState = url.searchParams.get('pageState');

    if (pageState === 'page2') {
      return HttpResponse.json({
        logs: generateMockLogs(50, 50),
        nextPageState: null, // 最后一页
      });
    }

    return HttpResponse.json({
      logs: generateMockLogs(50, 0),
      nextPageState: 'page2',
    });
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe('VirtualizedLogViewer', () => {
  it('should render initial logs and only the visible ones', async () => {
    // 渲染在Jotai Provider中
    render(
      <Provider>
        <VirtualizedLogViewer />
      </Provider>
    );

    // 等待初始数据加载完成
    await waitFor(() => {
      expect(screen.getByText('Log message 0')).toBeInTheDocument();
    });

    // 假设视口只能显示20条
    // 验证第一条可见
    expect(screen.getByText('Log message 0')).toBeInTheDocument();
    // 验证视口外的第一条 *不* 在DOM中,这是虚拟化的核心
    expect(screen.queryByText('Log message 40')).not.toBeInTheDocument();
  });

  it('should load more logs on scroll to bottom', async () => {
    // 使用可覆盖的atom初始值进行测试
    const initialLogs = generateMockLogs(50, 0);
    const JotaiProvider = ({ children }: { children: React.ReactNode }) => (
      <Provider>
        {children}
      </Provider>
    );

    const { container } = render(
      <JotaiProvider>
         <VirtualizedLogViewer />
      </JotaiProvider>
    );
    
    await waitFor(() => {
      expect(screen.getByText('Log message 49')).toBeInTheDocument();
    });
    
    // 模拟滚动
    const scrollArea = container.querySelector('[data-radix-scroll-area-viewport]')!;
    fireEvent.scroll(scrollArea, { target: { scrollTop: 10000 } }); // 滚动到底部

    // 等待下一页数据加载和渲染
    await waitFor(() => {
      expect(screen.getByText('Log message 50')).toBeInTheDocument();
      expect(screen.getByText('Log message 99')).toBeInTheDocument();
    });

    // 验证总日志数是否正确
    expect(screen.getAllByRole('row').length).toBeGreaterThan(50); 
  });
});

这个测试验证了两个关键行为:

  1. 初始渲染时,只有视口内的元素存在于DOM中。
  2. 当用户滚动到底部时,会触发API调用并渲染新的数据。

当前方案的局限性与未来迭代

这套方案有效地解决了海量数据渲染的性能问题,但在生产环境中,仍有几个可以优化的点。

首先,动态行高是一个挑战。当前实现依赖于固定的itemHeight。如果日志消息长度不一导致行高动态变化,就需要引入更复杂的机制,如使用react-windowreact-virtual这类库,它们内部实现了对动态高度的测量和缓存。

其次,实时数据注入。当前模型是拉(pull)模型,通过轮询或滚动加载。对于真正的实时流,应采用推(push)模型,如WebSocket。当新日志通过WebSocket推送到前端时,我们需要决定如何将其“注入”allLogsAtom。通常是添加到数组的开头,但这会改变所有后续元素的索引和位置,需要小心处理滚动定位,避免用户正在查看的旧日志位置发生跳动。

最后,后端查询优化。虽然按天分区很有效,但对于需要跨天查询的场景,后端API需要实现并发查询多个分区并合并结果的逻辑。此外,若要支持全文搜索,仅靠Cassandra本身能力有限,通常会结合Elasticsearch等外部索引来解决。


  目录