Перевод средств между аккаунтами

32. Реализовать перевод средств между аккаунтами

Условие задачи:
Есть сервис перевода денег между аккаунтами. Все данные хранятся в памяти, обращения к БД и сетевого взаимодействия нет.

Необходимо реализовать метод:

void transferAmount(Account from, Account to, BigDecimal amount)

который переводит указанную сумму с одного аккаунта на другой.

Требования:

  • проверить входные параметры;

  • сумма перевода должна быть положительной;

  • нельзя переводить деньги между одним и тем же аккаунтом;

  • на исходном аккаунте должно быть достаточно средств;

  • баланс не должен терять точность при денежных вычислениях;

  • перевод должен атомарно изменять оба баланса;

  • при конкурентных переводах не должно возникать дедлоков.

Код:

class Account {
    // TODO: можно добавлять поля
}

class TransferService {

    void transferAmount(
            Account from,
            Account to,
            BigDecimal amount
    ) {
        // TODO
    }
}

Спойлеры к решению

Подсказки
💡 Для денежных значений лучше использовать BigDecimal, а не double или float.
💡 Проверь from, to и amount на null.
💡 Сумма должна быть строго больше нуля.
💡 Проверка достаточности средств и изменение обоих балансов должны выполняться атомарно.
💡 Если сервис вызывается из нескольких потоков, блокируй аккаунты всегда в одном порядке, например по уникальному id.

Решение
class Account {
    private final long id;
    private BigDecimal balance;

    public Account(long id, BigDecimal initialBalance) {
        Objects.requireNonNull(initialBalance);

        if (initialBalance.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException(
                    "Начальный баланс не может быть отрицательным"
            );
        }

        this.id = id;
        this.balance = initialBalance;
    }

    public long getId() {
        return id;
    }

    public synchronized BigDecimal getBalance() {
        return balance;
    }

    synchronized void debit(BigDecimal amount) {
        balance = balance.subtract(amount);
    }

    synchronized void credit(BigDecimal amount) {
        balance = balance.add(amount);
    }
}
class TransferService {

    public void transferAmount(
            Account from,
            Account to,
            BigDecimal amount
    ) {
        Objects.requireNonNull(from, "Source account must not be null");
        Objects.requireNonNull(to, "Target account must not be null");
        Objects.requireNonNull(amount, "Amount must not be null");

        if (amount.compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException(
                    "Amount must be positive"
            );
        }

        if (from == to || from.getId() == to.getId()) {
            throw new IllegalArgumentException(
                    "Cannot transfer to the same account"
            );
        }

        Account first = from.getId() < to.getId() ? from : to;
        Account second = first == from ? to : from;

        synchronized (first) {
            synchronized (second) {
                if (from.getBalance().compareTo(amount) < 0) {
                    throw new IllegalStateException(
                            "Insufficient funds"
                    );
                }

                from.debit(amount);
                to.credit(amount);
            }
        }
    }
}

Для суммы используется BigDecimal.

Использовать double для денег нежелательно, поскольку двоичное представление чисел с плавающей точкой не может точно представить многие десятичные значения:

0.1 + 0.2

может дать значение, отличающееся от точного 0.3.

BigDecimal выполняет десятичные вычисления без такой потери точности:

BigDecimal amount = new BigDecimal("10.50");

При создании денежных значений из литералов также лучше использовать строку или BigDecimal.valueOf():

new BigDecimal("10.50");
BigDecimal.valueOf(10.50);

а не:

new BigDecimal(10.50);

Перед переводом проверяется, что средств достаточно:

if (from.getBalance().compareTo(amount) < 0) {
    throw new IllegalStateException("Insufficient funds");
}

Для BigDecimal значения сравниваются через compareTo(), а не через equals(), поскольку:

10.0 и 10.00

численно равны, хотя equals() считает их различными из-за разного scale.

Оба аккаунта блокируются в одном порядке:

Account first = from.getId() < to.getId() ? from : to;
Account second = first == from ? to : from;

Например, для одновременных переводов:

Account 1 → Account 2
Account 2 → Account 1

оба потока сначала попытаются получить блокировку аккаунта с меньшим id. Это исключает циклическое ожидание и тем самым предотвращает дедлок.

Проверка баланса и обе операции выполняются внутри одной критической секции:

from.debit(amount);
to.credit(amount);

поэтому другой перевод не сможет вклиниться между проверкой остатка и списанием.

Такое решение предполагает, что id аккаунта уникален и не меняется после создания.

Для фиксированной валюты альтернативой может быть хранение суммы в минимальных денежных единицах, например копейках, через long. Но если требуется работать с десятичными денежными значениями непосредственно, BigDecimal — наиболее естественный выбор.

Время выполнения перевода без учёта ожидания блокировок — O(1), дополнительная память — O(1).