SpringBoot implementa la solución de MySQL para exportar millones de datos y evitar OOM!

La exportación dinámica de datos es una función que está involucrada en proyectos generales. Su lógica de implementación básica es consultar datos de MySQL, cargarlos en la memoria y luego crear Excel o CSV desde la memoria y responder al front-end en forma de secuencia. Así es básicamente como sobresalen las descargas de SpringBoot.

Aunque esta es una solución factible, una vez que la cantidad de datos MySQL es demasiado grande, alcanzando 100.000, 100.000, 10 millones, cargar datos a gran escala en la memoria inevitablemente causará problemas OutofMemoryError.

1. Para considerar cómo evitar OOM, generalmente hay dos formas de pensar.

1. Por un lado intentar no hacerlo en la medida de lo posible, primero hagamos las siguientes preguntas sobre el producto:

  • ¿Por qué exportamos tantos datos? ¿Quién es tan estúpido como para mirar una cantidad tan grande de datos? ¿Es razonable este diseño?

  • ¿Cómo hacer un buen trabajo de control de autoridad? Exportación de datos a nivel de millones ¿Está seguro de que no revelará secretos comerciales?

  • Si desea exportar millones de datos, ¿por qué no buscar directamente big data o DBA para hacerlo? ¿No se puede entregar por correo?

  • ¿Por qué debería implementarse mediante la lógica del backend, sin considerar el costo de tiempo y tráfico?

  • Si exporta por paginación, solo se importan 20.000 elementos cada vez que hace clic en el botón. ¿No puede la exportación en lotes satisfacer las necesidades comerciales?

Si el producto dice "La parte A es el padre, vaya y hable con la Parte A", "¡El cliente dice que se hará antes de considerar pagar el saldo!" Considere cómo implementarlo.

2. Por otro lado, técnicamente hablando, para evitar OOM, debemos prestar atención a un principio:

No se puede cargar la cantidad total de datos en la memoria al mismo tiempo.

La carga completa no es factible, por lo que nuestro objetivo es cómo cargar datos en lotes. De hecho, Mysql en sí admite consultas de Stream. Podemos obtener datos a través de Stream y luego escribirlos en el archivo uno por uno. Después de cada vez que se escribe el archivo, los datos se eliminan de la memoria para evitar OOM.

Dado que los datos se introducen en el archivo uno por uno y la cantidad de datos llega a millones, el formato del archivo no debe ser Excel. Excel2007 solo admite un máximo de 1,04 millones de filas de datos. Recomendado aquí:

Utilice csv en lugar de excel.

2. MyBatis implementa la exportación de datos a nivel de millones

MyBatis implementa la obtención de datos uno por uno, que deben personalizarse ResultHandlery luego agregarse a la declaración de selección correspondiente en el archivo mapper.xml fetchSize="-2147483648".

Finalmente, pase el ResultHandler personalizado a SqlSession para ejecutar la consulta y procesar los resultados devueltos.

3. MyBatis implementa un ejemplo específico de exportación de datos de un millón de niveles.

El siguiente es MyBatis Streamun ejemplo completo de proyecto basado en la exportación. Verificaremos la efectividad de la exportación de archivos Stream comparando la diferencia en el uso de memoria entre la exportación de archivos Stream y la forma tradicional.

Primero definimos una clase de herramienta DownloadProcessor, que encapsula un HttpServletResponseobjeto internamente y se usa para escribir el objeto en csv.

public class DownloadProcessor {
    private final HttpServletResponse response;
     
    public DownloadProcessor(HttpServletResponse response) {
        this.response = response;
        String fileName = System.currentTimeMillis() + ".csv";
        this.response.addHeader("Content-Type", "application/csv");
        this.response.addHeader("Content-Disposition", "attachment; filename="+fileName);
        this.response.setCharacterEncoding("UTF-8");
    }
     
    public <E> void processData(E record) {
        try {
            response.getWriter().write(record.toString()); //如果是要写入csv,需要重写toString,属性通过","分割
            response.getWriter().write("\n");
        }catch (IOException e){
            e.printStackTrace();
        }
    }
}

Luego, a través de la implementación org.apache.ibatis.session.ResultHandler, personalizamos el nuestro ResultHandler, que se utiliza para obtener objetos Java y luego se pasa a la DownloadProcessorclase de procesamiento anterior para las operaciones de escritura de archivos:

public class CustomResultHandler implements ResultHandler {

    private final DownloadProcessor downloadProcessor;
     
    public CustomResultHandler(
            DownloadProcessor downloadProcessor) {
        super();
        this.downloadProcessor = downloadProcessor;
    }
     
    @Override
    public void handleResult(ResultContext resultContext) {
        Authors authors = (Authors)resultContext.getResultObject();
        downloadProcessor.processData(authors);
    }
}

Clase de entidad:

public class Authors {
    private Integer id;
    private String firstName;
     
    private String lastName;
     
    private String email;
     
    private Date birthdate;
     
    private Date added;
     
    public Integer getId() {
        return id;
    }
     
    public void setId(Integer id) {
        this.id = id;
    }
     
    public String getFirstName() {
        return firstName;
    }
     
    public void setFirstName(String firstName) {
        this.firstName = firstName == null ? null : firstName.trim();
    }
     
    public String getLastName() {
        return lastName;
    }
     
    public void setLastName(String lastName) {
        this.lastName = lastName == null ? null : lastName.trim();
    }
     
    public String getEmail() {
        return email;
    }
     
    public void setEmail(String email) {
        this.email = email == null ? null : email.trim();
    }
     
    public Date getBirthdate() {
        return birthdate;
    }
     
    public void setBirthdate(Date birthdate) {
        this.birthdate = birthdate;
    }
     
    public Date getAdded() {
        return added;
    }
     
    public void setAdded(Date added) {
        this.added = added;
    }
     
    @Override
    public String toString() {
        return this.id + "," + this.firstName + "," + this.lastName + "," + this.email + "," + this.birthdate + "," + this.added;
    }
}

Interfaz del mapeador:

public interface AuthorsMapper {
   List<Authors> selectByExample(AuthorsExample example);
    
   List<Authors> streamByExample(AuthorsExample example); //以stream形式从mysql获取数据
}

El fragmento principal del archivo Mapper xml. La única diferencia entre las dos opciones siguientes es que hay un atributo adicional en la forma en que la secuencia obtiene datos: fetchSize="-2147483648"

<select id="selectByExample" parameterType="com.alphathur.mysqlstreamingexport.domain.AuthorsExample" resultMap="BaseResultMap">
    select
    <if test="distinct">
      distinct
    </if>
    'false' as QUERYID,
    <include refid="Base_Column_List" />
    from authors
    <if test="_parameter != null">
      <include refid="Example_Where_Clause" />
    </if>
    <if test="orderByClause != null">
      order by ${orderByClause}
    </if>
  </select>
  <select id="streamByExample" fetchSize="-2147483648" parameterType="com.alphathur.mysqlstreamingexport.domain.AuthorsExample" resultMap="BaseResultMap">
    select
    <if test="distinct">
      distinct
    </if>
    'false' as QUERYID,
    <include refid="Base_Column_List" />
    from authors
    <if test="_parameter != null">
      <include refid="Example_Where_Clause" />
    </if>
    <if test="orderByClause != null">
      order by ${orderByClause}
    </if>
  </select>

El servicio principal para obtener datos es el siguiente: dado que es solo una simple demostración, soy demasiado vago para escribirlo como una interfaz. El  streamDownload método es la implementación de streaming para recuperar datos y escribir archivos, que obtendrán datos de MySQL con una huella de memoria muy baja; además, también proporciona traditionDownloadun método, que es un método de descarga tradicional que obtiene todos los datos en lotes, y luego escribe cada objeto para importar el archivo.

@Service
public class AuthorsService {
    private final SqlSessionTemplate sqlSessionTemplate;
    private final AuthorsMapper authorsMapper;

    public AuthorsService(SqlSessionTemplate sqlSessionTemplate, AuthorsMapper authorsMapper) {
        this.sqlSessionTemplate = sqlSessionTemplate;
        this.authorsMapper = authorsMapper;
    }

    /**
     * stream读数据写文件方式
     * @param httpServletResponse
     * @throws IOException
     */
    public void streamDownload(HttpServletResponse httpServletResponse)
            throws IOException {
        AuthorsExample authorsExample = new AuthorsExample();
        authorsExample.createCriteria();
        HashMap<String, Object> param = new HashMap<>();
        param.put("oredCriteria", authorsExample.getOredCriteria());
        param.put("orderByClause", authorsExample.getOrderByClause());
        CustomResultHandler customResultHandler = new CustomResultHandler(new DownloadProcessor (httpServletResponse));
        sqlSessionTemplate.select(
                "com.alphathur.mysqlstreamingexport.mapper.AuthorsMapper.streamByExample", param, customResultHandler);
        httpServletResponse.getWriter().flush();
        httpServletResponse.getWriter().close();
    }

    /**
     * 传统下载方式
     * @param httpServletResponse
     * @throws IOException
     */
    public void traditionDownload(HttpServletResponse httpServletResponse)
            throws IOException {
        AuthorsExample authorsExample = new AuthorsExample();
        authorsExample.createCriteria();
        List<Authors> authors = authorsMapper.selectByExample (authorsExample);
        DownloadProcessor downloadProcessor = new DownloadProcessor (httpServletResponse);
        authors.forEach (downloadProcessor::processData);
        httpServletResponse.getWriter().flush();
        httpServletResponse.getWriter().close();
    }
}

Controlador de entrada descargado:

@RestController
@RequestMapping("download")
public class HelloController {
    private final AuthorsService authorsService;

    public HelloController(AuthorsService authorsService) {
        this.authorsService = authorsService;
    }

    @GetMapping("streamDownload")
    public void streamDownload(HttpServletResponse response)
            throws IOException {
        authorsService.streamDownload(response);
    }

    @GetMapping("traditionDownload")
    public void traditionDownload(HttpServletResponse response)
            throws IOException {
        authorsService.traditionDownload (response);
    }
}   

La declaración de creación de la estructura de la tabla correspondiente a la clase de entidad:

CREATE TABLE `authors` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `first_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
  `last_name` varchar(50) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
  `email` varchar(100) CHARACTER SET utf8 COLLATE utf8_unicode_ci NOT NULL,
  `birthdate` date NOT NULL,
  `added` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10095 DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;

Primero inicie el proyecto y luego abra el directorio jdk bin jconsole.exe

En primer lugar, probamos el uso de memoria al descargar archivos de la forma tradicional, acceso directo al navegador: http://localhost:8080/download/traditionDownload.

Se puede ver que antes de que comience la descarga, el uso de memoria es de aproximadamente decenas de M. Después de que comienza la descarga, el uso de memoria aumenta rápidamente y el valor máximo alcanza casi 2,5 G. Incluso después de que se completa la descarga, la memoria del montón aún Mantiene una alta ocupación, lo cual es realmente terrible. Si el entorno de producción se atreve a hacer esto, habrá un desbordamiento de memoria sin accidente.

Luego probamos el uso de memoria de la descarga de archivos en tiempo real y el acceso al navegador: http://localhost:8080/download/streamDownloadcuando comienza la descarga, el uso de memoria también tendrá un aumento significativo, pero el valor máximo es de solo 500 M. En comparación con el método anterior, ¡el uso de memoria se ha reducido en un 80%! ¿Cómo estás? ¿Estás emocionado?

Luego abrimos los dos archivos descargados a través del Bloc de notas y descubrimos que no faltaba el contenido, ambos tenían 2,727,127 líneas, ¡perfecto!

Bueno, eso es todo por este artículo. Bienvenidos amigos a dejar un mensaje en segundo plano, díganme ¿qué método utilizaron para exportar millones de datos en el proyecto? Bienvenido a venir y comunicarse.

Supongo que te gusta

Origin blog.csdn.net/dreaming317/article/details/129938737
Recomendado
Clasificación