В этой небольшой статье я хочу дать ответ на вопрос, который возник у меня, когда я познакомился с сессиями в SQLAlchemy. Если сформулировать его кратко, то звучит он примерно так: “А зачем оно надо вообще”? Меня, как человека пришедшего из мира джанги, сессии приводили в уныние и я считал их откровенной фигней, которая усложняет жизнь. Но я ошибался. Как оказалось, сессии в алхимии - штука очень даже полезная. И вот почему.

Cессии являются неотъемлемой частью SQLAlchemy ORM и реализуют шаблоны Unit Of Work и Identity Map. Что это за шаблоны и зачем они нужны мы сейчас и разберем.

Unit Of Work (UoW, Единица работы)

Сессии в рамках этого паттерна отслеживают изменения, сделанные в рамках одной бизнес-транзакции, а затем “сбрасывают” их пачкой в базу, предварительно выполнив топологическую сортировку по зависимостям и сгруппировав повторяющиеся операции.

Чтобы понять зачем это надо, возьмем пару сниппетов с ActiveRecord ORM-ом и посмотрим какие проблемы там возникают и как сессии их решают.

class User(models.Model):
   username = models.CharField(max_length=255)
   name = models.CharField(max_length=255)
   last_name = models.CharField(max_length=255)


class PhoneNumber(models.Model):
   number = models.CharField(max_length=255)
   user = models.ForeignKey(User, on_delete=models.CASCADE)
def process_users(users_records):
   for user_record in users_records:
       u = User(**user_record['user'])
       # Мы не сможем сохранить пользователя, если отсутствуют обязательные поля
       u.save()

       for entry in user_record['entries']:
           if entry['type'] == 'phone':
               p = PhoneNumber(user=u, number=entry['phone'])
               # Если мы не сохранили пользователя выше, то мы не сможем добавить телефон
               p.save()

           elif entry['type'] == 'fields':
               u.name = entry['name']
               u.last_name = entry['last_name']
               u.save()

Какие проблемы мы здесь можем увидеть?:

  1. Мы отправляем множество мелких запросов, чем увеличиваем нагрузку на базу, т.к. при каждом вызове метода .save() ORM отправит в базе запрос INSERT/UPDATE. При вызове .delete() происходит то же самое, к слову.

  2. Нам необходимо самим поддерживать правильный порядок запросов, что увеличивает сложность и может приводить к ошибкам. Мы не сможем, к примеру, создать пользователя, если у него не заполнены все обязательные поля, как не сможем записать телефон, если не смогли сохранить пользователя. Для решения этой проблемы мы можем держать объекты в памяти и отправлять запросы уже в самом конце, но в таком случае нам нужно отправлять запросы в правильном порядке и порядок этот поддерживать вручную.

Теперь запишем все то же самое, но только с применением сессий:

class User(Base):
   __tablename__ = "users"

   id = Column(Integer, primary_key=True)
   username = Column(String(255))
   name = Column(String(255))
   last_name = Column(String(255))

   phones = relationship(
       "PhoneNumber", back_populates="user"
   )


class PhoneNumber(Base):
   __tablename__ = 'phone_numbers'

   id = Column(Integer, primary_key=True)
   number = Column(String(255))
   user_id = Column(Integer, ForeignKey(User.id))

   user = relationship(User, back_populates="phones")
def process_users(users_records):
   with Session() as sess:
       for user_record in users_records:
           user = User(**user_record['user'])

           # Никаких запросов в базу не пойдет
           sess.add(user)

           for entry in user_record['entries']:
               if entry['type'] == 'phone':
                   phone = PhoneNumber(user=user, number=entry['phone'])

                   # Здесь также никаких запросов в базу не отправляется
                   sess.add(phone)
               elif entry['type'] == 'fields':
                   user.name = entry['name']
                   user.last_name = entry['last_name']

       # Здесь сессия откроет транзакцию, отправит запросы и выполнит commit
       sess.commit()

Чем же этот вариант лучше?

  1. Во-первых сессия откроет транзакцию прозрачным для программиста образом перед самой отправкой запросов в базу, т.е. мы не держим транзакцию долго открытой.

  2. Во-вторых, сессия сгруппирует операции обновления данных и мы избавимся от множества мелких запросов и снизим нагрузку на базу.

  3. В-третьих, сессия за наc аггрегирует в памяти изменения и отправит запросы в правильном порядке, выполнив сортировку по зависимостям.

Чтобы было нагляднее, приведу пример входных данных и sql, который прийдет для них в базу.

USERS_RECORDS = [
   {
       'user': {
           'username': 'donald',
           'name': 'Donald',
           'last_name': 'Duck'
       },
       'entries': [
           {
               'type': 'phone',
               'phone': '+7 941 234 43 45'
           }
       ]
   },
   {
       'user': {
           'username': 'bullwi',
           'name': 'Bullwinkle',
           'last_name': 'Moose'
       },
       'entries': [
           {
               'type': 'fields',
               'name': 'Rocky',
               'last_name': 'Squirrel'
           }
       ]
   }
]

process_users(USERS_RECORDS)
BEGIN
INSERT INTO users (username, name, last_name) VALUES ('donald', 'Donald', 'Duck'),('bullwi', 'Rocky', 'Squirrel') RETURNING users.id
INSERT INTO phone_numbers (number, user_id) VALUES ('+7 941 234 43 45', 3) RETURNING phone_numbers.id
COMMIT

Обратите внимание на порядок запросов и их число. В частности сначала создаются пользователи вне зависимости от того, в каком порядке объекты создавались в приложении, а для создания пользователей нам потребовался всего один запрос.

Identity Map (IM, Карта идентичности)

Этот паттерн гарантирует, что объекты, загруженные из базы присутствуют в приложении только в одном экземпляре. Помимо гарантии уникальности сессия в рамках этого шаблона может сокращать число запросов к базе. Но не во всех случаях.

Рассмотрим пример.

u1 = User.objects.filter(username='donald').first()
u2 = User.objects.filter(username='donald').first()
u3 = User.objects.get(3)  # Donald
u4 = User.objects.filter(id=3).first()  # Donald

assert u1 is u2 is u3 is u4  # Fails

В случае ActiveRecord-а в базу уйдет 4 запроса на выборку и мы получим 4 разных объекта на уровне приложения. Это приводит опять же к тому, что:

  1. Нам нужно следить за порядком операций

  2. Объекты могут содержать устаревшие данные

def process_user_one():
   u = User.objects.get(3)  # Donald
   u.name = 'Don'
   return u


def process_user_two():
   u = User.objects.get(3)  # Donald
   if u.name == 'Don':
       p = PhoneNumber(user=u, number='+1 234 443 23 42')
       p.save()
   u.name = 'Donald'
   return u


user1 = process_user_one()
user1.save()
user2 = process_user_two()
user2.save()

assert user1.name == 'Donald'  # Fails, user1.name == ‘Don’
assert user2.name == 'Donald'

Как видим, нам нужно позаботиться, чтобы user1 был сохранен в базу раньше вызова process_user_two(), в противном случае результат будет другим. Вторая же проблема - это устаревшие данные: user1 все еще зовут Don. В большом приложении это может стать источником неприятных ошибок.

В случае использования сессий обе эти проблемы будут решены, плюс число запросов к базе может сократиться.

u1 = session.query(User).filter_by(username='donald').one()
u2 = session.query(User).filter_by(username='donald').one()
u3 = session.query(User).get(3)  # Donald
u4 = session.query(User).filter_by(id=3).one()  # Donald

assert u1 is u2 is u3 is u4  # Success

В данном примере в базу уйдет только 3 запроса. Когда мы вызовем метод .get(), сессия возьмет нашего Дональда из своей карты объектов без доп. запроса.

def process_user_one(session):
   u = session.query(User).get(3)
   u.name = 'Don'
   return u


def process_user_two(session):
   u = session.query(User).get(3)
   if u.name == 'Don':
       p = PhoneNumber(user=u, number='+1 234 443 23 42')
       session.add(p)
   u.name = 'Donald'
   return u


with Session() as sess:
   user1 = process_user_one(sess)
   user2 = process_user_two(sess)

   assert user1.name == 'Donald'  # Success
   assert user2.name == 'Donald'  # Success

Здесь же нам не обязательно сохранять user1 в базу раньше времени и оба объекта содержат свежие значение.

Комментарии (1)


  1. danilovmy
    27.12.2021 11:11

    Спасибо @romblin, а что насчет builk_create и atomic transaction, разве это не аналогичный функционал в Джанго?

    Соответственно если во втором примере - создание телефона для Don в Джанго - сделать через один запрос Phone.filter(user_Name=Don).create(**kwargs) - то в чем преимущество сессии?

    Спрашиваю,т.к. смотрел на алхимию из Джанго-орм но больших преимуществ не нашел.