Одна из недавних встреч питонеров (Moscow Python meetup) была посвещена теме NoSQL. Я отношу скептически к повсеместному переходу на NoSQL, но всё же нахожу ему применение в отдельных задачах. Так, на встрече я рассказал про блокирование редактируемых объектов на базе memcache. Проблема вполне типичная для всех редакторских интерфейсов в CMS. Один и тот же объект могут одновременно начать редактировать несколько пользователей, в этом случае правки одного из пользователей перетираются другим. Более того, иногда возникают ситуации, когда у одного пользователя оказываются открыты несколько окон редактирования одного объекта и он перетирает собственные изменения.
В моём варианте решения при открытии страницы редактирования берётся блокировка (если объект ещё не заблокирован), а затем со страницы периодически шлётся AJAX запрос на её обновление. Ответ может быть как успешный, так и нет, если другой редактор насильно перехватил блокировку. При завершении редактирования или уходе со страницы блокировка снимается. Кроме того, если блокировку не обновлять, то она через некоторое время протухает автоматически — это решает проблему снятия блокировки, хоть и с запозданием, при закрытии окна (падении браузера, выключении питания у компьютера и т.д.). Переменная
edit_session
необходима для решения проблемы нескольких открытых окон редактирования одного объекта, фактически она содержит идентификатор одного такого окна. Для обновления блокировки используются команды memcache gets
и cas
, чтобы обеспечить атомарность операций (исключить условие гонки). Ниже приведена серверная часть, слегка переписанная, чтобы оторвать от контекста нашего движка (функции и переменные сделаны глобальными).
import os, logging
from time import time
logger = logging.getLogger(__name__)
class LockError(Exception):
def __str__(self):
return 'Problems with object lock'
class LockedByOther(LockError):
def __init__(self, user):
LockError.__init__(self, user)
self.user = user
def __str__(self):
return 'Object is already locked by user: %s (%s)' % \
(self.user.name, self.user.login)
class LockIsLost(LockError):
def __str__(self):
return 'The object lock is lost'
def create_lock(obj_key, user, force=False):
'''Marks model object as editted. Returns edit session on success or
raises exception. obj_key is global identifier of object. When force is
True the current lock is ignored.'''
CACHE.clear_cas()
edit_session = os.urandom(5).encode('hex')
value = dict(edit_session=edit_session,
user_id=user.id,
time=time())
if force:
if CACHE.set(obj_key, value, time=MODEL_LOCK_TIMEOUT):
return edit_session
else:
raise LockError()
for i in range(3):
if CACHE.add(obj_key, value, time=MODEL_LOCK_TIMEOUT):
return edit_session
old_value = CACHE.gets(obj_key)
if old_value is None:
# Should try add() again
continue
if not old_value or time()-old_value['time'] > MODEL_LOCK_TIMEOUT:
# Somebody's lock is already expired
if CACHE.cas(obj_key, value, time=MODEL_LOCK_TIMEOUT):
return edit_session
else:
continue
# Somebody holds active lock, no farther attempts
break
else:
logger.error('Failed to lock model object. Problem with memcached?')
raise LockError()
lock_user = get_user(id=old_value['user_id'])
assert lock_user is not None
raise LockedByOther(lock_user)
def update_lock(obj_key, user, edit_session):
'''Updates model object lock as being active. Raises exception on
error. obj_key is global identifier of object.'''
CACHE.clear_cas()
for i in range(3):
old_value = CACHE.gets(obj_key)
if not old_value:
raise LockIsLost()
elif old_value['edit_session']!=edit_session:
lock_user = get_user(id=old_value['user_id'])
assert lock_user is not None
raise LockedByOther(lock_user)
new_value = dict(edit_session=edit_session,
user_id=user.id,
time=time())
if CACHE.cas(obj_key, new_value, time=MODEL_LOCK_TIMEOUT):
return
else:
# No runtime error here since we want to give user a chance to
# restore lock.
raise LockIsLost()
def remove_lock(obj_key, edit_session):
'''Removes lock for model object. obj_key is global identifier of
object.'''
CACHE.clear_cas()
# We can't garuantee memcache's delete method will remove only our
# lock, so we update the record with empty value and minimal (1 sec)
# timeout.
old_value = CACHE.gets(obj_key)
# It's too late to do something in case of error, so we just ignore
# returned value.
if old_value and old_value['edit_session']==edit_session:
CACHE.cas(obj_key, '', time=1)
Надеюсь, назначение и работа методов понятна из названий и комментариев.
А какие способы используете вы для организации одновременного редактирования объектов?
2 комментария:
А можно пояснить, зачем три попытки поставить блокировку?
Данные в memcache не протухают мгновенно после истечения таймаута. Поэтому создание блокировки может идти по двум сценариям: коммандой add, если записи с данным ключём нет, или командой cas, если она есть, но "протухшая". В memcache пару операций можно сделать атомарной только по оптимистичному сценарию, то есть вторая операция может провалиться, если ситуация с момента выполнения первой изменилась. В таких случаях нужно повторить попытку.
Отправить комментарий