Transaction / ACID / Lock

이재홍·2022년 6월 4일
0

서비스에서 가장 치명적인 문제는 데이터의 오염이다.
중요한 데이터를 오염시키지 않기 위해
트랜잭션을 만들어 성공했을때는 모두 성공을 실패했을 때는 롤백 시켜주어야 한다.

TypeORM을 이용한 트랜잭션 처리

typeorm에서는 트랜잭션 기능을 제공해준다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { User } from '../user/entities/user.entity';
import {
  PointTransaction,
  POINT_TRANSACTION_STATUS_ENUM,
} from './entities/pointTransaction.entity';

@Injectable()
export class PointTransactionService {
  constructor(
    @InjectRepository(PointTransaction)
    private readonly pointTransactionRepository: Repository<PointTransaction>,

    @InjectRepository(User)
    private readonly userRepository: Repository<User>,

    private readonly connection: Connection, // 커넥션 주입받기
  ) {}

  async create({ impUid, amount, currentUser }) {
    const queryRunner = await this.connection.createQueryRunner(); // 쿼리러너 실행
    await queryRunner.connect(); // 디비에 연결 (연결 제한 개수가 정해져있으므로 디비에서 늘려줘야한다.)
    await queryRunner.startTransaction(); // 트랜잭션 시작
    try {
      // 1. pointTransaction 테이블에 거래기록 생성
      const pointTansaction = await this.pointTransactionRepository.create({
        impUid,
        amount,
        user: currentUser,
        status: POINT_TRANSACTION_STATUS_ENUM.PAYMENT,
      });
      // await this.pointTransactionRepository.save(pointTansaction);
      await queryRunner.manager.save(pointTansaction); // 임시저장

      // throw new Error(); // 임의의 에러발생

      // 2. 유저정보를 조회
      const user = await this.userRepository.findOne({ id: currentUser.id });

      // 3. 유저의 돈 업데이트
      // await this.userRepository.update(
      //   { id: user.id },
      //   { point: user.point + amount },
      // );
      // 유저 객체 생성
      const updatedUser = this.userRepository.create({
        ...user,
        point: user.point + amount,
      });
      await queryRunner.manager.save(updatedUser); // 임시저장
      await queryRunner.commitTransaction(); // 에러없을시 커밋

      // 4. 최종결과 돌려주기
      return pointTansaction;
    } catch (error) {
      await queryRunner.rollbackTransaction(); // 임시저장 하나라도 실패시 트랜잭션 전체 롤백.
    } finally {
      await queryRunner.release(); // 접속(커넥트) 종료 // 종료시켜주지않으면 허용개수 초과시 에러발생.
    }
  }
}
show databases; -- 데이터베이스 목록 조회

use myproject; -- myproject 데이터베이스 사용

show tables; -- 테이블 목록 조회 

show variables; -- 데이터베이스에서 관리하는 변수 목록 조회 (max_connections로 커넥션 최대값 확인)

show status; -- 현재 데이터베이스 상태 조회 (Threads_connected로 현재 커넥션 개수 확인)

set GLOBAL max_connections = 1000; -- 커넥션 최대갓 조정

show processlist; -- 현재 프로세스 목록 확인 (현재 연결된 커넥션 목록 확인)

kill 20; -- 프로세스 아이디로 강제종료 시키기 (실수로 종료 안시킨 프로세스 종료시 사용)

트랜잭션의 속성들 - ACID

  1. Atomicity(원자성) : 모두 성공 or 모두 실패
  2. Consistency(일관성) : 조회할 때 일관된 결과
  3. Isolation(격리성) : 트랜잭션이 격리되어 기다렸다 하나씩 실행
  4. Durability(지속성) : 성공 후에는 장애발생시에도 유지되어야함

Isolation-Level

Isolation-LevelDirty-ReadNon-Repeatable-ReadPhantom-Read
Read-UncommittedOOO
Read-CommittedXOO
Repeatable-ReadXXO
SerializableXXX

READ UNCOMMITTED

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { Payment } from './entities/payment.entity';

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly paymentRepository: Repository<Payment>,

    private readonly connection: Connection,
  ) {}

  async create({ amount }) {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ UNCOMMITTED'); // READ UNCOMMITTED => 가장 낮은 단계(Dirty-Read 등 발생): 롤백되기전까진 잠깐의 저장된 상태가 조회가된다.
    try {
      const payment = await this.paymentRepository.create({ amount });
      await queryRunner.manager.save(payment);

      // 5초뒤에 특정 이유로 실패
      setTimeout(async () => {
        await queryRunner.rollbackTransaction();
      }, 5000);
      // await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }

  async findAll() {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ UNCOMMITTED');
    try {
      // 만약 5초 이내에 조회하면, 위에서 등록한 금액(커밋되지 않은 금액)이 조회됨
      const payment = await queryRunner.manager.find(Payment);
      await queryRunner.commitTransaction();
      return payment;
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }
}

READ COMMITTED

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { Payment } from './entities/payment.entity';

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly paymentRepository: Repository<Payment>,

    private readonly connection: Connection,
  ) {}

  async findAll() {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ COMMITTED'); // 커밋되지않은 데이터가 조회되는 Dirty-Read는 해결됐지만 Non-Repeatable-Read => 한 트랜잭션에서 반복된 조회시 커밋된 데이터들의 조회값이 다르게 나옴.
    try {
      // 하나의 트랜잭션 내에서 500원이 조회됐으면,
      // 해당트랜잭션이 끝나기 전까지는(커밋 전까지는) 다시 조회하더라도 항상 500원이 조회(Repeatable-Read) 되어야 함.
      // 1초간 반복해서 조회하는 중에, 누군가 등록하면(create), Repeatable-Read가 보장되지 않음 => Non-Repeatable-Read 문제!!!
      setInterval(async () => {
        const payment = await queryRunner.manager.find(Payment);
        console.log(payment);
      }, 1000);

      // await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }

  async create({ amount }) {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('READ COMMITTED');
    try {
      // 중간에 돈 추가해보기
      const payment = await this.paymentRepository.create({ amount });
      await queryRunner.manager.save(payment);
      await queryRunner.commitTransaction();
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }
}

SERIALIZABLE

팬텀리드는 Non-Repeatable-Read는 트랜잭션에 ID를 부여하여 트랜잭션이 끝나기전까지는 데이터가 커밋되어도 조회 결과가 바뀌지 않도록 해결하였지만 데이터의 개수를 조회하거나 업데이트를 해주거나 한 경우에는 한 조회 트랜잭션내에서 조회값이 바뀌는 경우이다. (데이터 저장 커밋 같은 경우에는 이전값을 undo에 임시 백업해두어 트랜잭션ID로 비교후 값을 가져오기때문에 해결가능했다)
SERIALIZABLE은 이 문제를 차단해준다.
(mysql에서는 phantom-read를 자동차단해준다. - 트랜잭션 설정없을시 Repeatable-Read가 기본값 + 팬텀리드차단 (보통 DB들은 Read-Committed가 기본값 Repeatable-Read시에도 팬텀리드 자동차단X))

또한 SERIALIZABLE 레벨에서는 lock을 걸 수 있다.

lock

  • 공유락(Shared Lock) - pessimistic_read(읽기전용 쓰기잠금)
  • 베타락(Exclusive Lock) - pesimistic_write(읽기쓰기 모두잠금)

락을 검으로써 해당 데이터를 여러사용자가 동시에 조작(읽고 쓰기)하는것을 방지할 수 있다.
대기상태가 되었다가 실행되므로 성능은 느려지기에 오염이 발생할 수 있는 중요 데이터에만 락을 건다.

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Connection, Repository } from 'typeorm';
import { Payment } from './entities/payment.entity';

@Injectable()
export class PaymentService {
  constructor(
    @InjectRepository(Payment)
    private readonly paymentRepository: Repository<Payment>,

    private readonly connection: Connection,
  ) {}

  async findAll() {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('SERIALIZABLE');
    try {
      // 조회시 락을 걸고 조회함으로써, 다른 쿼리에서 조회 못하게 막음(대기시킴) => Select ~ For Update
      const payment = await queryRunner.manager.find(Payment, {
        lock: { mode: 'pessimistic_write' },
      });
      console.log(payment);

      // 처리에 5초간의 시간이 걸림을 가정!!
      setTimeout(async () => {
        await queryRunner.commitTransaction();
      }, 5000);
      return payment;
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }

  async create({ amount }) {
    const queryRunner = await this.connection.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction('SERIALIZABLE');
    try {
      // 조회를 했을 때, 바로 조회되지 않고 락이 풀릴 때까지 대기
      const payment = await queryRunner.manager.find(Payment); // 동시 조회 불가능
      console.log('========== 철수가 시도 =============');
      console.log(payment);
      console.log('=================================');
      await queryRunner.commitTransaction();
      return payment;
    } catch (error) {
      await queryRunner.rollbackTransaction();
    } finally {
      //await queryRunner.release();
    }
  }
}

0개의 댓글